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/.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/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/nightly.yml b/.github/workflows/nightly.yml index a5002fec5..39ad8810e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -47,13 +47,18 @@ 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 - - - name: Setup pnpm - run: corepack enable && corepack prepare pnpm@latest --activate + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -75,6 +80,9 @@ jobs: 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 }}" @@ -90,6 +98,7 @@ jobs: 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 }} @@ -123,7 +132,7 @@ jobs: # Collect release artifacts from goreleaser dist/ ASSETS=() - for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do + 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 @@ -135,4 +144,3 @@ jobs: --prerelease \ --latest=false \ "${ASSETS[@]}" - diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2d544d4f0..795fa5eba 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -41,10 +41,11 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ce341770..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,13 +53,18 @@ 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 - - - name: Setup pnpm - run: corepack enable && corepack prepare pnpm@latest --activate + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -93,6 +86,9 @@ jobs: 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@v7 with: @@ -104,6 +100,7 @@ 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 }} 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 b869ecc33..e1736f56b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ dist/ # Windows Application Icon/Resource *.syso +.cache/ +web/frontend/.pnpm-store/ +_tmp_* +web/frontend/_tmp_* # Test telegram integration cmd/telegram/ @@ -67,3 +71,5 @@ web/backend/dist/* .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 9c26de34f..fe43a0921 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,11 +9,10 @@ git: before: hooks: - - go mod tidy - go generate ./... - - sh -c 'cd web/frontend && pnpm install && pnpm build:backend' - - go install github.com/tc-hib/go-winres@latest - - go-winres make --in web/backend/winres/winres.json --out web/backend/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 @@ -27,7 +26,7 @@ builds: - -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={{ .Env.GOVERSION }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} goos: - linux - windows @@ -67,6 +66,10 @@ builds: - 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 @@ -97,45 +100,6 @@ builds: - goos: netbsd goarch: arm - - id: picoclaw-launcher-tui - binary: picoclaw-launcher-tui - env: - - CGO_ENABLED=0 - tags: - - goolm - - stdjson - ldflags: - - -s -w - goos: - - linux - - windows - - darwin - - freebsd - - netbsd - goarch: - - amd64 - - arm64 - - riscv64 - - loong64 - - arm - - s390x - - mipsle - goarm: - - "6" - - "7" - gomips: - - softfloat - main: ./cmd/picoclaw-launcher-tui - ignore: - - goos: windows - goarch: arm - - goos: netbsd - goarch: s390x - - goos: netbsd - goarch: mips64 - - goos: netbsd - goarch: arm - dockers_v2: - id: picoclaw dockerfile: docker/Dockerfile.goreleaser @@ -159,7 +123,6 @@ dockers_v2: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui images: - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' @@ -177,7 +140,6 @@ notarize: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui sign: certificate: "{{.Env.MACOS_SIGN_P12}}" password: "{{.Env.MACOS_SIGN_PASSWORD}}" @@ -208,7 +170,6 @@ nfpms: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui package_name: picoclaw file_name_template: >- {{ .PackageName }}_ @@ -245,6 +206,8 @@ changelog: 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 9581fa633..3fa41bc24 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,49 @@ -.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}') +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 +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) @@ -69,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) @@ -93,17 +133,54 @@ ifeq ($(UNAME_S),Linux) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin - WEB_GO=CGO_ENABLED=1 go + 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) @@ -114,37 +191,50 @@ 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 "$(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) - @if [ ! -f web/backend/dist/index.html ]; then \ - echo "Building frontend..."; \ - cd web/frontend && pnpm install && pnpm build:backend; \ - fi - @$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend - @ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher - @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher" + @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-tui: Build the picoclaw-launcher TUI binary -build-launcher-tui: - @echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..." - @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui - @ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui - @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui" +build-launcher-frontend: + @$(MAKE) -C web build-frontend ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate @@ -186,11 +276,44 @@ build-linux-mipsle: generate $(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) @@ -207,7 +330,7 @@ build-all: generate 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 "All builds complete" + @echo "Core builds complete" ## install: Install picoclaw to system and copy builtin skills install: build @@ -238,7 +361,11 @@ 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 @@ -256,9 +383,14 @@ test: generate 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 --build-tags $(GO_BUILD_TAGS) + @./scripts/lint-docs.sh ## fix: Fix linting issues fix: @@ -274,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 @@ -321,16 +453,34 @@ docker-clean: ## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) -build-macos-app: +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 - @cd web && $(MAKE) build && cd .. - @./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH) + @./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.md b/README.md index e963fb1cf..30ac67d8f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ 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.my.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** @@ -56,6 +56,8 @@ ## 📢 News +2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download) + 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-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**! @@ -110,7 +112,7 @@ _*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is -> **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR! +> **[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 @@ -162,22 +164,32 @@ Alternatively, download the binary for your platform from the [GitHub Releases]( ### 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 cd picoclaw make deps -# Build core binary +# Install frontend dependencies +(cd web/frontend && pnpm install --frozen-lockfile) + +# Build the core binary for the current platform make build -# Build Web UI Launcher (required for WebUI mode) +# Build the Web UI Launcher (required for WebUI mode) make build-launcher -# Build for multiple platforms +# 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 @@ -213,7 +225,7 @@ picoclaw-launcher WebUI Launcher

-**Getting started:** +**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! @@ -279,29 +291,30 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l -### 💻 TUI Launcher (Recommended for Headless / SSH) - -The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Getting started:** - -Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat! - -For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io). - + ### 📱 Android Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. -**Option 1: Termux (available now)** +**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: @@ -318,13 +331,6 @@ Then follow the Terminal Launcher section below to complete configuration. PicoClaw on Termux -**Option 2: APK Install (coming soon)** - -A standalone Android APK with built-in WebUI is in development. Stay tuned! - -
-Terminal Launcher (for resource-constrained environments) - 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** @@ -355,8 +361,8 @@ This creates `~/.picoclaw/config.json` and the workspace directory. ``` > 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_configuration.md` for more details. +> +> 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. **3. Chat** @@ -435,20 +441,20 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use } ``` -For full provider configuration details, see [Providers & Models](docs/providers.md). +For full provider configuration details, see [Providers & Models](docs/guides/providers.md).
## 💬 Channels (Chat Apps) -Talk to your PicoClaw through 17+ messaging platforms: +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/chat-apps.md#whatsapp) | -| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) | +| **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) | @@ -456,7 +462,8 @@ Talk to your PicoClaw through 17+ messaging platforms: | **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) | -| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) | +| **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 | @@ -464,7 +471,9 @@ Talk to your PicoClaw through 17+ messaging platforms: > 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. -For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md). +> 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 @@ -484,7 +493,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too ### ⚙️ Other Tools -PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details. +PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details. ## 🎯 Skills @@ -497,7 +506,7 @@ picoclaw skills search "web scraping" picoclaw skills install ``` -**Configure ClawHub token** (optional, for higher rate limits): +**Configure skill registries**: Add to your `config.json`: ```json @@ -507,6 +516,11 @@ Add to your `config.json`: "registries": { "clawhub": { "auth_token": "your-clawhub-token" + }, + "github": { + "base_url": "https://github.com", + "auth_token": "your-github-token", + "proxy": "" } } } @@ -514,7 +528,9 @@ Add to your `config.json`: } ``` -For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool). +`tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead. + +For more details, see [Tools Configuration - Skills](docs/reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -537,7 +553,20 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a } ``` -For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/tools_configuration.md#mcp-tool). +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 @@ -557,6 +586,11 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message | `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 | @@ -574,23 +608,27 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too * **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours * **Cron expressions**: "Remind me at 9am daily" -> uses cron expression +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/docker.md) | Docker Compose setup, Launcher/Agent modes | -| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides | -| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox | -| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration | -| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | -| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | -| [Steering](docs/steering.md) | Inject messages into a running agent loop between tool calls | -| [SubTurn](docs/subturn.md) | Subagent coordination, concurrency control, lifecycle | -| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | -| [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills | -| [Hardware Compatibility](docs/hardware-compatibility.md) | Tested boards, minimum requirements | +| [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 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/launcher-tui.jpg b/assets/launcher-tui.jpg deleted file mode 100644 index 659c97794..000000000 Binary files a/assets/launcher-tui.jpg and /dev/null differ diff --git a/assets/wechat.png b/assets/wechat.png index 9a6130ce0..18247ff82 100644 Binary files a/assets/wechat.png and b/assets/wechat.png 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/README.md b/cmd/picoclaw-launcher-tui/README.md deleted file mode 100644 index a942045a5..000000000 --- a/cmd/picoclaw-launcher-tui/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Picoclaw Launcher TUI - -This directory contains the terminal-based TUI launcher for `picoclaw`. -It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment. - -## Architecture - -The TUI launcher is implemented purely in Go with no external runtime dependencies: -* **`main.go`**: Application entry point, handles initialization and main event loop -* **`ui/`**: TUI interface components built on tview + tcell framework: - - `home.go`: Main dashboard with navigation menu - - `schemes.go`: AI model scheme management - - `users.go`: User and API key management for model providers - - `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor - - `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status) - - `app.go`: Core TUI application framework and navigation logic - - `models.go`: Data structures and state management -* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system - -## Getting Started - -### Prerequisites - -* Go 1.25+ -* Terminal with 256-color support (most modern terminals are compatible) - -### Development - -Run the TUI launcher directly in development mode: - -```bash -# From project root -go run ./cmd/picoclaw-launcher-tui - -# Or from this directory -go run . -``` - -### Build - -Build the standalone TUI launcher binary: - -```bash -# From project root (recommended) -make build-launcher-tui - -# Output will be at: -# build/picoclaw-launcher-tui-- -# with symlink build/picoclaw-launcher-tui - -# Or build directly from this directory -go build -o picoclaw-launcher-tui . -``` - -### Key Features - -* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments -* ⚙️ AI model scheme and API key management -* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.) -* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring) -* 💬 One-click launch of interactive AI chat session -* 🎯 Keyboard-first design with intuitive shortcuts - -### Other Commands - -```bash -# Run with custom config file path -go run . /path/to/custom/config.json -``` diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go deleted file mode 100644 index 227b9fa3d..000000000 --- a/cmd/picoclaw-launcher-tui/config/config.go +++ /dev/null @@ -1,236 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -// Package config provides types and I/O for ~/.picoclaw/tui.toml. -package config - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/BurntSushi/toml" - - "github.com/sipeed/picoclaw/pkg/fileutil" -) - -// DefaultConfigPath returns the default path to the tui.toml config file. -func DefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - return filepath.Join(home, ".picoclaw", "tui.toml") -} - -// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml. -type TUIConfig struct { - Version string `toml:"version"` - Model Model `toml:"model"` - Provider Provider `toml:"provider"` -} - -type Model struct { - Type string `toml:"type"` // "provider" (default) | "manual" -} - -type Provider struct { - Schemes []Scheme `toml:"schemes"` - Users []User `toml:"users"` - Current ProviderCurrent `toml:"current"` -} - -type Scheme struct { - Name string `toml:"name"` // unique key - BaseURL string `toml:"baseURL"` // required - Type string `toml:"type"` // "openai-compatible" (default) | "anthropic" -} - -type User struct { - Name string `toml:"name"` - Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique - Type string `toml:"type"` // "key" (default) | "OAuth" - Key string `toml:"key"` -} - -type ProviderCurrent struct { - Scheme string `toml:"scheme"` // references Scheme.Name - User string `toml:"user"` // references User.Name where User.Scheme == Scheme - Model string `toml:"model"` // from GET /models -} - -// DefaultConfig returns a minimal valid TUIConfig. -func DefaultConfig() *TUIConfig { - return &TUIConfig{ - Version: "1.0", - Model: Model{Type: "provider"}, - Provider: Provider{ - Schemes: []Scheme{}, - Users: []User{}, - Current: ProviderCurrent{}, - }, - } -} - -// Load reads the TUI config from path. Returns a default config if the file does not exist. -func Load(path string) (*TUIConfig, error) { - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return DefaultConfig(), nil - } - if err != nil { - return nil, fmt.Errorf("failed to read config file %q: %w", path, err) - } - - cfg := DefaultConfig() - if _, err := toml.Decode(string(data), cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file %q: %w", path, err) - } - - applyDefaults(cfg) - return cfg, nil -} - -// Save writes cfg to path atomically (safe for flash / SD storage). -func Save(path string, cfg *TUIConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - var buf bytes.Buffer - enc := toml.NewEncoder(&buf) - if err := enc.Encode(cfg); err != nil { - return fmt.Errorf("failed to encode config: %w", err) - } - if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil { - return fmt.Errorf("failed to write config file %q: %w", path, err) - } - return nil -} - -func applyDefaults(cfg *TUIConfig) { - if cfg.Version == "" { - cfg.Version = "1.0" - } - if cfg.Model.Type == "" { - cfg.Model.Type = "provider" - } - for i := range cfg.Provider.Schemes { - if cfg.Provider.Schemes[i].Type == "" { - cfg.Provider.Schemes[i].Type = "openai-compatible" - } - } - for i := range cfg.Provider.Users { - if cfg.Provider.Users[i].Type == "" { - cfg.Provider.Users[i].Type = "key" - } - } -} - -// SchemeByName returns the first Scheme whose Name matches, or nil. -func (p *Provider) SchemeByName(name string) *Scheme { - for i := range p.Schemes { - if p.Schemes[i].Name == name { - return &p.Schemes[i] - } - } - return nil -} - -// UsersForScheme returns all users whose Scheme field matches schemeName. -func (p *Provider) UsersForScheme(schemeName string) []User { - var out []User - for _, u := range p.Users { - if u.Scheme == schemeName { - out = append(out, u) - } - } - return out -} - -// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json -// Adds/replaces a "tui-prefer" model entry and sets it as the default model. -// Preserves all other existing fields in the config file unchanged. -func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - mainConfigPath := filepath.Join(home, ".picoclaw", "config.json") - - var cfg map[string]any - if data, readErr := os.ReadFile(mainConfigPath); readErr == nil { - if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil { - cfg = make(map[string]any) - } - } else { - cfg = make(map[string]any) - } - - if _, ok := cfg["agents"]; !ok { - cfg["agents"] = make(map[string]any) - } - agents, ok := cfg["agents"].(map[string]any) - if ok { - if _, ok := agents["defaults"]; !ok { - agents["defaults"] = make(map[string]any) - } - defaults, ok := agents["defaults"].(map[string]any) - if ok { - defaults["model"] = "tui-prefer" - } - } - - tuiModel := map[string]any{ - "model_name": "tui-prefer", - "model": modelID, - "api_key": user.Key, - "api_base": scheme.BaseURL, - } - - modelList := []any{} - if ml, ok := cfg["model_list"].([]any); ok { - modelList = ml - } - - found := false - for i, m := range modelList { - if entry, ok := m.(map[string]any); ok { - if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" { - modelList[i] = tuiModel - found = true - break - } - } - } - if !found { - modelList = append(modelList, tuiModel) - } - cfg["model_list"] = modelList - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil { - return err - } - - return os.WriteFile(mainConfigPath, data, 0o600) -} - -func (cfg *TUIConfig) CurrentModelLabel() string { - cur := cfg.Provider.Current - if cur.Model == "" { - return "(not configured)" - } - label := cur.Scheme - if label != "" { - label += " / " - } - return label + cur.Model -} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go deleted file mode 100644 index 3cb7110c1..000000000 --- a/cmd/picoclaw-launcher-tui/main.go +++ /dev/null @@ -1,48 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" -) - -func main() { - configPath := tuicfg.DefaultConfigPath() - if len(os.Args) > 1 { - configPath = os.Args[1] - } - - configDir := filepath.Dir(configPath) - if _, err := os.Stat(configDir); os.IsNotExist(err) { - cmd := exec.Command("picoclaw", "onboard") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - } - - cfg, err := tuicfg.Load(configPath) - if err != nil { - fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) - os.Exit(1) - } - - app := ui.New(cfg, configPath) - // Bind model selection hook to sync to main config - app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) { - _ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID) - } - if err := app.Run(); err != nil { - fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go deleted file mode 100644 index a65693b01..000000000 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ /dev/null @@ -1,325 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - "sync" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -// App is the root TUI application. -type App struct { - tapp *tview.Application - pages *tview.Pages - pageStack []string - cfg *tuicfg.TUIConfig - configPath string - pageRefreshFns map[string]func() - headerModelTV *tview.TextView - modalOpen map[string]bool - - // OnModelSelected is called when a model is selected in the UI. - // Can be nil to disable. - OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) - - modelCache map[string][]modelEntry - modelCacheMu sync.RWMutex - refreshMu sync.Mutex -} - -// cacheKey returns the map key for a (scheme, user) pair. -func cacheKey(schemeName, userName string) string { - return fmt.Sprintf("%s/%s", schemeName, userName) -} - -// cachedModels returns a defensive copy of the cached model list for a user (may be nil). -func (a *App) cachedModels(schemeName, userName string) []modelEntry { - a.modelCacheMu.RLock() - defer a.modelCacheMu.RUnlock() - entries := a.modelCache[cacheKey(schemeName, userName)] - return append([]modelEntry(nil), entries...) -} - -// refreshModelCache fetches models for every user in the config concurrently. -// Serialized by refreshMu so concurrent calls don't race on the cache map. -// When all fetches complete it calls onDone via QueueUpdateDraw. -func (a *App) refreshModelCache(onDone func()) { - go func() { - a.refreshMu.Lock() - defer a.refreshMu.Unlock() - - users := a.cfg.Provider.Users - schemes := a.cfg.Provider.Schemes - - schemeURL := make(map[string]string, len(schemes)) - for _, s := range schemes { - schemeURL[s.Name] = s.BaseURL - } - - var wg sync.WaitGroup - for _, u := range users { - baseURL, ok := schemeURL[u.Scheme] - if !ok || baseURL == "" { - continue - } - if u.Key == "" { - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - a.modelCache[cacheKey(u.Scheme, u.Name)] = nil - a.modelCacheMu.Unlock() - continue - } - wg.Add(1) - bURL := baseURL - go func() { - defer wg.Done() - entries, err := fetchModels(bURL, u.Key) - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - if err != nil || len(entries) == 0 { - a.modelCache[cacheKey(u.Scheme, u.Name)] = nil - } else { - a.modelCache[cacheKey(u.Scheme, u.Name)] = entries - } - a.modelCacheMu.Unlock() - }() - } - wg.Wait() - - if onDone != nil { - a.tapp.QueueUpdateDraw(onDone) - } - }() -} - -// New creates and wires up the TUI application. -func New(cfg *tuicfg.TUIConfig, configPath string) *App { - // Cyberpunk Theme Colors - // Dark background - tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void - tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo - tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40) - - // Borders and Titles - tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta - - // Text - tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white - tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime - tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black - tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta - - a := &App{ - tapp: tview.NewApplication(), - pages: tview.NewPages(), - pageStack: []string{}, - cfg: cfg, - configPath: configPath, - pageRefreshFns: make(map[string]func()), - modalOpen: make(map[string]bool), - } - - a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - if len(a.modalOpen) > 0 { - return event - } - return a.goBack() - } - return event - }) - - a.buildPages() - return a -} - -// Run starts the TUI event loop. -func (a *App) Run() error { - return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run() -} - -func (a *App) buildPages() { - a.pages.AddPage("home", a.newHomePage(), true, true) - a.pageStack = []string{"home"} -} - -func (a *App) navigateTo(name string, page tview.Primitive) { - a.pages.RemovePage(name) - a.pages.AddPage(name, page, true, false) - a.pageStack = append(a.pageStack, name) - a.pages.SwitchToPage(name) -} - -func (a *App) goBack() *tcell.EventKey { - if len(a.pageStack) <= 1 { - return nil - } - popped := a.pageStack[len(a.pageStack)-1] - a.pageStack = a.pageStack[:len(a.pageStack)-1] - a.pages.RemovePage(popped) - prev := a.pageStack[len(a.pageStack)-1] - if fn, ok := a.pageRefreshFns[prev]; ok { - fn() - } - if prev == "home" && a.headerModelTV != nil { - a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ") - } - a.pages.SwitchToPage(prev) - return nil -} - -func (a *App) showModal(name string, primitive tview.Primitive) { - a.modalOpen[name] = true - a.pages.AddPage(name, primitive, true, true) -} - -func (a *App) hideModal(name string) { - delete(a.modalOpen, name) - a.pages.HidePage(name) - a.pages.RemovePage(name) -} - -func (a *App) save() { - if err := tuicfg.Save(a.configPath, a.cfg); err != nil { - a.showError("save failed: " + err.Error()) - } -} - -func (a *App) showError(msg string) { - modal := tview.NewModal(). - SetText(" [red::b]ERROR[-::-]\n\n" + msg). - AddButtons([]string{"OK"}). - SetDoneFunc(func(_ int, _ string) { - a.hideModal("error") - }) - // Cyberpunk Modal Style - modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White - modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red - modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White - a.showModal("error", modal) -} - -func (a *App) confirmDelete(label string, onConfirm func()) { - modal := tview.NewModal(). - SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]"). - AddButtons([]string{"Delete", "Cancel"}). - SetDoneFunc(func(_ int, buttonLabel string) { - a.hideModal("confirm-delete") - if buttonLabel == "Delete" { - onConfirm() - } - }) - // Cyberpunk Modal Style - modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White - modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger - modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White - a.showModal("confirm-delete", modal) -} - -func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive { - return tview.NewFlex(). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(form, height, 1, true). - AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true). - AddItem(tview.NewBox(), 0, 1, false) -} - -func hintBar(text string) *tview.TextView { - tv := tview.NewTextView(). - SetText(text). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan - tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo - return tv -} - -func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive { - var modelTV *tview.TextView - if pageID == "home" { - if a.headerModelTV == nil { - a.headerModelTV = tview.NewTextView() - a.headerModelTV.SetTextAlign(tview.AlignRight). - SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime - SetDynamicColors(true). - SetBackgroundColor(tcell.NewHexColor(0x050510)) - } - modelTV = a.headerModelTV - modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ") - } else { - modelTV = tview.NewTextView() - modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) - } - - headerLeft := tview.NewTextView(). - SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///"). - SetDynamicColors(true). - SetBackgroundColor(tcell.NewHexColor(0x050510)) - - header := tview.NewFlex(). - AddItem(headerLeft, 0, 1, false). - AddItem(modelTV, 0, 1, false) - - sidebar := tview.NewTextView(). - SetDynamicColors(true). - SetWrap(false) - sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - - // Cyberpunk Sidebar Styling - activePrefix := "[#39ff14::b]>> " // Neon Lime arrow - activeSuffix := "[-]" - inactivePrefix := "[#808080] " - inactiveSuffix := "[-]" - - sbText := "\n\n" // Top padding - - menuItem := func(id, label string) string { - if pageID == id { - return activePrefix + label + activeSuffix + "\n\n" - } - return inactivePrefix + label + inactiveSuffix + "\n\n" - } - - sbText += menuItem("home", "HOME") - sbText += menuItem("schemes", "SCHEMES") - sbText += menuItem("users", "USERS") - sbText += menuItem("models", "MODELS") - sbText += menuItem("channels", "CHANNELS") - sbText += menuItem("gateway", "GATEWAY") - - sidebar.SetText(sbText) - - footer := hintBar(hint) - - grid := tview.NewGrid(). - SetRows(1, 0, 1). - SetColumns(20, 0). // Slightly wider sidebar - AddItem(header, 0, 0, 1, 2, 0, 0, false). - AddItem(sidebar, 1, 0, 1, 1, 0, 0, false). - AddItem(content, 1, 1, 1, 1, 0, 0, true). - AddItem(footer, 2, 0, 1, 2, 0, 0, false) - - // Add a border around the content area if possible, or ensure content has its own border - // grid.SetBorders(false) // Grid borders usually look bad, handled by components - - return grid -} diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go deleted file mode 100644 index c976f1fcd..000000000 --- a/cmd/picoclaw-launcher-tui/ui/channels.go +++ /dev/null @@ -1,202 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "strconv" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func (a *App) newChannelsPage() tview.Primitive { - list := tview.NewList() - list.SetBorder(true). - SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) - list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)), - ) - list.SetHighlightFullLine(true) - list.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rebuild := func() { - sel := list.GetCurrentItem() - list.Clear() - - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - configPath := filepath.Join(home, ".picoclaw", "config.json") - - var cfg map[string]any - if data, err := os.ReadFile(configPath); err == nil { - _ = json.Unmarshal(data, &cfg) - } - - if chRaw, ok := cfg["channels"].(map[string]any); ok { - for name, ch := range chRaw { - chMap, ok := ch.(map[string]any) - enabled := "disabled" - if ok { - if e, ok := chMap["enabled"].(bool); ok && e { - enabled = "enabled" - } - } - list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() { - a.showChannelEditForm(configPath, name, chMap) - }) - } - } - - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) - } - } - rebuild() - - a.pageRefreshFns["channels"] = rebuild - - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - return a.goBack() - } - return event - }) - - return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ") -} - -func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) { - form := tview.NewForm() - form.SetBorder(true). - SetTitle(" [::b]EDIT CHANNEL "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - - fields := make(map[string]*tview.InputField) - var nameField *tview.InputField - - if channelName == "" { - nameField = tview.NewInputField(). - SetLabel("Channel Name"). - SetText(""). - SetFieldWidth(28) - form.AddFormItem(nameField) - } - - for k, v := range existing { - if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice { - continue - } - valStr := fmt.Sprintf("%v", v) - field := tview.NewInputField(). - SetLabel(k). - SetText(valStr). - SetFieldWidth(28) - form.AddFormItem(field) - fields[k] = field - } - - form.AddButton("SAVE", func() { - var cfg map[string]any - if data, err := os.ReadFile(configPath); err == nil { - if err := json.Unmarshal(data, &cfg); err != nil { - cfg = make(map[string]any) - } - } else { - cfg = make(map[string]any) - } - - if _, ok := cfg["channels"]; !ok { - cfg["channels"] = make(map[string]any) - } - channels, ok := cfg["channels"].(map[string]any) - if !ok { - channels = make(map[string]any) - cfg["channels"] = channels - } - - finalName := channelName - if channelName == "" { - if nameField == nil || nameField.GetText() == "" { - a.showError("Channel name is required") - return - } - finalName = nameField.GetText() - } - - updated := make(map[string]any) - if existing != nil { - for k, v := range existing { - updated[k] = v - } - } - for k, field := range fields { - val := field.GetText() - if val == "true" { - updated[k] = true - } else if val == "false" { - updated[k] = false - } else if num, err := strconv.Atoi(val); err == nil { - updated[k] = num - } else { - updated[k] = val - } - } - - if channelName != "" && finalName != channelName { - delete(channels, channelName) - } - channels[finalName] = updated - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - a.showError(fmt.Sprintf("Failed to save config: %v", err)) - return - } - if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { - a.showError(fmt.Sprintf("Failed to create config directory: %v", err)) - return - } - if err := os.WriteFile(configPath, data, 0o600); err != nil { - a.showError(fmt.Sprintf("Failed to write config: %v", err)) - return - } - - a.hideModal("channel-edit") - a.goBack() - }) - - form.AddButton("CANCEL", func() { - a.hideModal("channel-edit") - }) - - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("channel-edit") - return nil - } - return event - }) - - a.showModal("channel-edit", centeredForm(form, 4, 20)) -} diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go deleted file mode 100644 index 1138c12db..000000000 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ /dev/null @@ -1,261 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -const pidFileName = "gateway.pid" - -type gatewayStatus struct { - running bool - pid int -} - -func getPidPath() string { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - return filepath.Join(home, ".picoclaw", pidFileName) -} - -func isProcessRunning(pid int) bool { - if runtime.GOOS == "windows" { - cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)) - output, err := cmd.Output() - if err != nil { - return false - } - return strings.Contains(string(output), strconv.Itoa(pid)) - } else if runtime.GOOS == "darwin" { - cmd := exec.Command("ps", "aux") - output, err := cmd.Output() - if err != nil { - return false - } - return strings.Contains(string(output), fmt.Sprintf(" %d ", pid)) - } - // Linux - _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) - return err == nil -} - -func getGatewayStatus() gatewayStatus { - pidPath := getPidPath() - data, err := os.ReadFile(pidPath) - if err != nil { - return gatewayStatus{running: false} - } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil { - return gatewayStatus{running: false} - } - if !isProcessRunning(pid) { - os.Remove(pidPath) - return gatewayStatus{running: false} - } - return gatewayStatus{ - running: true, - pid: pid, - } -} - -func startGateway() error { - status := getGatewayStatus() - if status.running { - return fmt.Errorf("gateway is already running (PID: %d)", status.pid) - } - - pidPath := getPidPath() - var cmd *exec.Cmd - - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1") - } else { - cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath) - } - - err := cmd.Start() - if err != nil { - return err - } - - time.Sleep(1 * time.Second) - - if runtime.GOOS == "windows" { - cmd := exec.Command( - "wmic", - "process", - "where", - "name='picoclaw.exe' and commandline like '%gateway%'", - "get", - "processid", - ) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to get gateway PID: %w", err) - } - lines := strings.Split(string(output), "\n") - for _, line := range lines[1:] { - line = strings.TrimSpace(line) - if line == "" { - continue - } - pid, err := strconv.Atoi(line) - if err == nil { - os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600) - break - } - } - } - - status = getGatewayStatus() - if !status.running { - return fmt.Errorf("failed to start gateway") - } - return nil -} - -func stopGateway() error { - status := getGatewayStatus() - if !status.running { - return fmt.Errorf("gateway is not running") - } - - var err error - if runtime.GOOS == "windows" { - err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run() - } else { - err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run() - } - if err != nil { - return err - } - - // 多次尝试确认进程已停止 - for i := 0; i < 5; i++ { - if !isProcessRunning(status.pid) { - break - } - time.Sleep(200 * time.Millisecond) - } - - os.Remove(getPidPath()) - return nil -} - -func (a *App) newGatewayPage() tview.Primitive { - flex := tview.NewFlex().SetDirection(tview.FlexRow) - flex.SetBorder(true). - SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - flex.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - statusTV := tview.NewTextView(). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetText("Checking status...") - statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - var updateStatus func() - - // 使用List作为按钮,保证显示和交互正常 - buttons := tview.NewList() - buttons.SetBackgroundColor(tcell.NewHexColor(0x050510)) - buttons.SetMainTextColor(tcell.ColorWhite) - buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff)) - buttons.SetSelectedTextColor(tcell.ColorBlack) - - buttons.AddItem(" [lime]START[white] ", "", 0, func() { - if !getGatewayStatus().running { - err := startGateway() - if err != nil { - a.showError(err.Error()) - } - updateStatus() - } - }) - buttons.AddItem(" [red]STOP[white] ", "", 0, func() { - if getGatewayStatus().running { - err := stopGateway() - if err != nil { - a.showError(err.Error()) - } - updateStatus() - } - }) - - buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn) - buttonFlex. - AddItem(tview.NewBox(), 0, 1, false). - AddItem(buttons, 20, 1, true). - AddItem(tview.NewBox(), 0, 1, false) - - flex. - AddItem(tview.NewBox(), 0, 1, false). - AddItem(statusTV, 3, 1, false). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(buttonFlex, 4, 1, true). - AddItem(tview.NewBox(), 0, 1, false) - - updateStatus = func() { - status := getGatewayStatus() - if status.running { - statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid)) - buttons.SetItemText(0, " [gray]START[white] ", "") - buttons.SetItemText(1, " [red]STOP[white] ", "") - } else { - statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A") - buttons.SetItemText(0, " [lime]START[white] ", "") - buttons.SetItemText(1, " [gray]STOP[white] ", "") - } - } - - updateStatus() - - done := make(chan struct{}) - go func() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - a.tapp.QueueUpdateDraw(updateStatus) - case <-done: - return - } - } - }() - - originalInputCapture := flex.GetInputCapture() - flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - close(done) - return a.goBack() - } - if originalInputCapture != nil { - return originalInputCapture(event) - } - return event - }) - - a.pageRefreshFns["gateway"] = updateStatus - - return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ") -} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go deleted file mode 100644 index 74a7769cf..000000000 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ /dev/null @@ -1,70 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "os" - "os/exec" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func (a *App) newHomePage() tview.Primitive { - list := tview.NewList() - list.SetBorder(true). - SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) - list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)), - ) - list.SetHighlightFullLine(true) - list.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rebuildList := func() { - sel := list.GetCurrentItem() - list.Clear() - list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() { - a.navigateTo("schemes", a.newSchemesPage()) - }) - list.AddItem( - "CHANNELS: Configure communication channels", - "Manage Telegram/Discord/WeChat channels", - 'n', - func() { - a.navigateTo("channels", a.newChannelsPage()) - }, - ) - list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() { - a.navigateTo("gateway", a.newGatewayPage()) - }) - list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() { - a.tapp.Suspend(func() { - cmd := exec.Command("picoclaw", "agent") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - }) - }) - list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) - } - } - rebuildList() - - a.pageRefreshFns["home"] = rebuildList - - return a.buildShell( - "home", - list, - " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ", - ) -} diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go deleted file mode 100644 index 20e5f0182..000000000 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ /dev/null @@ -1,200 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -type modelsAPIResponse struct { - Data []modelEntry `json:"data"` -} - -type modelEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` -} - -func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false). - SetFixed(0, 0) - table.SetBorder(true). - SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - var modelIDs []string - - status := tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetDynamicColors(true). - SetText("[#ffff00]FETCHING MODELS...[-]") - status.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - flex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(status, 1, 0, false). - AddItem(table, 0, 1, false) - - apiKey := a.resolveKey(schemeName, userName) - - go func() { - var entries []modelEntry - var err error - if apiKey == "" { - err = fmt.Errorf("key is required") - } else { - entries, err = fetchModels(baseURL, apiKey) - } - - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - if err == nil && len(entries) > 0 { - a.modelCache[cacheKey(schemeName, userName)] = entries - } else { - a.modelCache[cacheKey(schemeName, userName)] = nil - } - a.modelCacheMu.Unlock() - - a.tapp.QueueUpdateDraw(func() { - if err != nil { - status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error())) - table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)")) - a.tapp.SetFocus(table) - return - } - if len(entries) == 0 { - status.SetText("[#ff2a2a]NO MODELS RETURNED[-]") - table.SetCell(0, 0, tview.NewTableCell(" (no models available)")) - a.tapp.SetFocus(table) - return - } - - status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries))) - for i, m := range entries { - modelIDs = append(modelIDs, m.ID) - table.SetCell(i, 0, - tview.NewTableCell(fmt.Sprintf("%3d", i+1)). - SetAlign(tview.AlignRight). - SetTextColor(tcell.NewHexColor(0x808080)). - SetSelectable(false), - ) - table.SetCell(i, 1, - tview.NewTableCell(" "+m.ID). - SetAlign(tview.AlignLeft). - SetExpansion(1). - SetTextColor(tcell.NewHexColor(0xe0e0e0)), - ) - } - a.tapp.SetFocus(table) - }) - }() - - table.SetSelectedFunc(func(row, _ int) { - if row < 0 || row >= len(modelIDs) { - return - } - a.cfg.Provider.Current = tuicfg.ProviderCurrent{ - Scheme: schemeName, - User: userName, - Model: modelIDs[row], - } - a.save() - - // Trigger model selected callback if set - if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" { - scheme := a.cfg.Provider.SchemeByName(schemeName) - if scheme == nil { - a.goBack() - return - } - var user tuicfg.User - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - user = u - break - } - } - a.OnModelSelected(*scheme, user, modelIDs[row]) - } - - a.goBack() - }) - - return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ") -} - -func (a *App) resolveKey(schemeName, userName string) string { - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - return u.Key - } - } - return "" -} - -func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { - 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) - } - if apiKey != "" { - 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) - } - - var result modelsAPIResponse - if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 { - return result.Data, nil - } - - var arr []modelEntry - if err := json.Unmarshal(body, &arr); err == nil { - return arr, nil - } - - return nil, fmt.Errorf( - "decode response: unrecognized shape: %s", - strings.TrimSpace(string(body[:min(len(body), 256)])), - ) -} diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go deleted file mode 100644 index e38d7fa86..000000000 --- a/cmd/picoclaw-launcher-tui/ui/schemes.go +++ /dev/null @@ -1,252 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -func (a *App) newSchemesPage() tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false) - table.SetBorder(true). - SetTitle(" [#00f0ff::b] PROVIDER SCHEMES "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rowToIdx := func(row int) int { return row / 2 } - - selectedSchemeName := func() string { - row, _ := table.GetSelection() - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - if idx >= 0 && idx < len(schemes) { - return schemes[idx].Name - } - return "" - } - - rebuild := func() { - selName := selectedSchemeName() - table.Clear() - schemes := a.cfg.Provider.Schemes - for i, s := range schemes { - nameRow := i * 2 - detailRow := nameRow + 1 - - table.SetCell(nameRow, 0, - tview.NewTableCell(" "+s.Name). - SetTextColor(tcell.NewHexColor(0xe0e0e0)). - SetExpansion(1). - SetSelectable(true), - ) - - users := a.cfg.Provider.UsersForScheme(s.Name) - n := len(users) - m := 0 - for _, u := range users { - if models := a.cachedModels(s.Name, u.Name); len(models) > 0 { - m++ - } - } - table.SetCell(detailRow, 0, - tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)). - SetTextColor(tcell.NewHexColor(0x808080)). - SetExpansion(1). - SetSelectable(false), - ) - table.SetCell(detailRow, 1, - tview.NewTableCell("[#00f0ff]"+s.Type+" "). - SetAlign(tview.AlignRight). - SetSelectable(false), - ) - } - if selName != "" { - for i, s := range schemes { - if s.Name == selName { - table.Select(i*2, 0) - return - } - } - } - if table.GetRowCount() > 0 { - table.Select(0, 0) - } - } - rebuild() - - a.refreshModelCache(rebuild) - a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) } - - table.SetSelectedFunc(func(row, _ int) { - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - if idx < 0 || idx >= len(schemes) { - return - } - name := schemes[idx].Name - a.navigateTo("users", a.newUsersPage(name)) - }) - - table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - row, _ := table.GetSelection() - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - switch event.Rune() { - case 'a': - a.showSchemeForm(nil, func(s tuicfg.Scheme) { - a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - case 'e': - if idx < 0 || idx >= len(schemes) { - return nil - } - origName := schemes[idx].Name - orig := schemes[idx] - a.showSchemeForm(&orig, func(s tuicfg.Scheme) { - current := a.cfg.Provider.Schemes - for i, sc := range current { - if sc.Name == origName { - a.cfg.Provider.Schemes[i] = s - break - } - } - a.save() - a.refreshModelCache(func() { - rebuild() - for i, sc := range a.cfg.Provider.Schemes { - if sc.Name == s.Name { - table.Select(i*2, 0) - break - } - } - }) - }) - return nil - case 'd': - if idx < 0 || idx >= len(schemes) { - return nil - } - name := schemes[idx].Name - a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { - current := a.cfg.Provider.Schemes - newSchemes := make([]tuicfg.Scheme, 0, len(current)) - for _, sc := range current { - if sc.Name != name { - newSchemes = append(newSchemes, sc) - } - } - a.cfg.Provider.Schemes = newSchemes - - existing := a.cfg.Provider.Users - filtered := make([]tuicfg.User, 0, len(existing)) - for _, u := range existing { - if u.Scheme != name { - filtered = append(filtered, u) - } - } - a.cfg.Provider.Users = filtered - - a.save() - a.refreshModelCache(rebuild) - }) - return nil - } - return event - }) - - return a.buildShell( - "schemes", - table, - " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ", - ) -} - -func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { - name := "" - baseURL := "" - schemeType := "openai-compatible" - title := " ADD SCHEME " - - if existing != nil { - name = existing.Name - baseURL = existing.BaseURL - schemeType = existing.Type - title = " EDIT SCHEME " - } - - typeOptions := []string{"openai-compatible", "anthropic"} - typeIdx := 0 - for i, t := range typeOptions { - if t == schemeType { - typeIdx = i - break - } - } - - form := tview.NewForm() - - form. - AddInputField("Name", name, 20, nil, func(text string) { name = text }). - AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }). - AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). - AddButton("SAVE", func() { - if name == "" { - a.showError("Name is required") - return - } - if baseURL == "" { - a.showError("Base URL is required") - return - } - if existing == nil { - for _, s := range a.cfg.Provider.Schemes { - if s.Name == name { - a.showError(fmt.Sprintf("Scheme name %q already exists", name)) - return - } - } - } - a.hideModal("scheme-form") - onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType}) - }). - AddButton("CANCEL", func() { - a.hideModal("scheme-form") - }) - - form.SetBorder(true). - SetTitle(" [::b]" + title + " "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("scheme-form") - return nil - } - return event - }) - - a.showModal("scheme-form", centeredForm(form, 4, 12)) -} diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go deleted file mode 100644 index b00fc8982..000000000 --- a/cmd/picoclaw-launcher-tui/ui/users.go +++ /dev/null @@ -1,261 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -func (a *App) newUsersPage(schemeName string) tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false) - table.SetBorder(true). - SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - visibleUsers := func() []tuicfg.User { - var out []tuicfg.User - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName { - out = append(out, u) - } - } - return out - } - - findUserGlobalIdx := func(userName string) int { - for i, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - return i - } - } - return -1 - } - - rowToVisIdx := func(row int) int { return row / 2 } - - selectedUserName := func() string { - row, _ := table.GetSelection() - users := visibleUsers() - visIdx := rowToVisIdx(row) - if visIdx >= 0 && visIdx < len(users) { - return users[visIdx].Name - } - return "" - } - - rebuild := func() { - selName := selectedUserName() - table.Clear() - users := visibleUsers() - for i, u := range users { - nameRow := i * 2 - detailRow := nameRow + 1 - - table.SetCell(nameRow, 0, - tview.NewTableCell(" "+u.Name). - SetTextColor(tcell.NewHexColor(0xe0e0e0)). - SetExpansion(1). - SetSelectable(true), - ) - table.SetCell(nameRow, 1, - tview.NewTableCell(""). - SetSelectable(false), - ) - - models := a.cachedModels(schemeName, u.Name) - var detailText string - if len(models) > 0 { - detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models)) - } else { - detailText = " [#ff2a2a]Inactive / No Access[-]" - } - table.SetCell(detailRow, 0, - tview.NewTableCell(detailText). - SetTextColor(tcell.NewHexColor(0x808080)). - SetExpansion(1). - SetSelectable(false), - ) - table.SetCell(detailRow, 1, - tview.NewTableCell("[#00f0ff]"+u.Type+" "). - SetAlign(tview.AlignRight). - SetSelectable(false), - ) - } - if selName != "" { - for i, u := range users { - if u.Name == selName { - table.Select(i*2, 0) - return - } - } - } - if table.GetRowCount() > 0 { - table.Select(0, 0) - } - } - rebuild() - - a.refreshModelCache(rebuild) - a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) } - - table.SetSelectedFunc(func(row, _ int) { - visIdx := rowToVisIdx(row) - users := visibleUsers() - if visIdx < 0 || visIdx >= len(users) { - return - } - uName := users[visIdx].Name - scheme := a.cfg.Provider.SchemeByName(schemeName) - if scheme == nil { - a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) - return - } - a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) - }) - - table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - row, _ := table.GetSelection() - visIdx := rowToVisIdx(row) - users := visibleUsers() - switch event.Rune() { - case 'a': - a.showUserForm(schemeName, nil, func(u tuicfg.User) { - a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - case 'e': - if visIdx < 0 || visIdx >= len(users) { - return nil - } - origName := users[visIdx].Name - orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)] - a.showUserForm(schemeName, &orig, func(u tuicfg.User) { - cfgIdx := findUserGlobalIdx(origName) - if cfgIdx < 0 { - a.showError(fmt.Sprintf("User %q no longer exists", origName)) - return - } - a.cfg.Provider.Users[cfgIdx] = u - a.save() - a.refreshModelCache(func() { - rebuild() - for i, usr := range visibleUsers() { - if usr.Name == u.Name { - table.Select(i*2, 0) - break - } - } - }) - }) - return nil - case 'd': - if visIdx < 0 || visIdx >= len(users) { - return nil - } - uName := users[visIdx].Name - a.confirmDelete(fmt.Sprintf("user %q", uName), func() { - cfgIdx := findUserGlobalIdx(uName) - if cfgIdx < 0 { - return - } - all := a.cfg.Provider.Users - a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - } - return event - }) - - return a.buildShell( - "users", - table, - " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ", - ) -} - -func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { - name := "" - userType := "key" - key := "" - title := " ADD USER " - - if existing != nil { - name = existing.Name - userType = existing.Type - key = existing.Key - title = " EDIT USER " - } - - typeOptions := []string{"key", "OAuth"} - typeIdx := 0 - for i, t := range typeOptions { - if t == userType { - typeIdx = i - break - } - } - - form := tview.NewForm() - form. - AddInputField("Name", name, 20, nil, func(text string) { name = text }). - AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). - AddPasswordField("Key", key, 28, '*', func(text string) { key = text }). - AddButton("SAVE", func() { - if name == "" { - a.showError("Name is required") - return - } - if existing == nil { - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == name { - a.showError(fmt.Sprintf("User name %q already exists for this scheme", name)) - return - } - } - } - a.hideModal("user-form") - onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key}) - }). - AddButton("CANCEL", func() { - a.hideModal("user-form") - }) - - form.SetBorder(true). - SetTitle(" [::b]" + title + " "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("user-form") - return nil - } - return event - }) - - a.showModal("user-form", centeredForm(form, 4, 13)) -} diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 531cb76aa..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 { @@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool) error { // 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 @@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error { 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,7 +130,7 @@ func authLoginGoogleAntigravity() error { // 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 @@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error { if err == nil { 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 @@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error { // 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 @@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error { // 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 @@ -342,15 +342,15 @@ 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 = "" } } @@ -484,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 index 8261f5f80..4b335f8cb 100644 --- a/cmd/picoclaw/internal/auth/wecom.go +++ b/cmd/picoclaw/internal/auth/wecom.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) const ( @@ -155,11 +156,31 @@ func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions { } func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) { - cfg.Channels.WeCom.Enabled = true - cfg.Channels.WeCom.BotID = botInfo.BotID - cfg.Channels.WeCom.SetSecret(botInfo.Secret) - if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" { - cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL + 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 } } diff --git a/cmd/picoclaw/internal/auth/wecom_test.go b/cmd/picoclaw/internal/auth/wecom_test.go index 95969d9b3..aafd39e69 100644 --- a/cmd/picoclaw/internal/auth/wecom_test.go +++ b/cmd/picoclaw/internal/auth/wecom_test.go @@ -3,6 +3,7 @@ package auth import ( "bytes" "context" + "net" "net/http" "net/http/httptest" "net/url" @@ -19,6 +20,19 @@ import ( "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() @@ -53,7 +67,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) { } func TestFetchWeComQRCode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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")) @@ -61,7 +75,6 @@ func TestFetchWeComQRCode(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`)) })) - defer server.Close() opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ HTTPClient: server.Client(), @@ -78,7 +91,7 @@ func TestFetchWeComQRCode(t *testing.T) { func TestPollWeComQRCodeResult(t *testing.T) { var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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")) @@ -92,7 +105,6 @@ func TestPollWeComQRCodeResult(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`)) } })) - defer server.Close() var output bytes.Buffer opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ @@ -112,17 +124,23 @@ func TestPollWeComQRCodeResult(t *testing.T) { func TestApplyWeComAuthResult(t *testing.T) { cfg := config.DefaultConfig() - cfg.Channels.WeCom.WebSocketURL = "" + 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, cfg.Channels.WeCom.Enabled) - assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID) - assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String()) - assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL) + 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) { @@ -149,9 +167,13 @@ func TestAuthWeComCmdWithScanner(t *testing.T) { cfg, err := config.LoadConfig(internal.GetConfigPath()) require.NoError(t, err) - assert.True(t, cfg.Channels.WeCom.Enabled) - assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID) - assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String()) - assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL) + 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 index 948a81495..0d060a5fe 100644 --- a/cmd/picoclaw/internal/auth/weixin.go +++ b/cmd/picoclaw/internal/auth/weixin.go @@ -95,14 +95,24 @@ func saveWeixinConfig(token, baseURL, proxy string) error { return fmt.Errorf("failed to load config: %w", err) } - cfg.Channels.Weixin.Enabled = true - cfg.Channels.Weixin.SetToken(token) - const defaultBase = "https://ilinkai.weixin.qq.com/" - if baseURL != "" && baseURL != defaultBase { - cfg.Channels.Weixin.BaseURL = baseURL + bc := cfg.Channels.GetByType(config.ChannelWeixin) + if bc == nil { + bc = &config.Channel{Type: config.ChannelWeixin} + cfg.Channels[config.ChannelWeixin] = bc } - if proxy != "" { - cfg.Channels.Weixin.Proxy = proxy + 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) 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 7fa588c5c..7dd03b495 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -2,19 +2,34 @@ 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", @@ -33,7 +48,25 @@ func NewGatewayCommand() *cobra.Command { return nil }, - RunE: func(_ *cobra.Command, _ []string) error { + 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) }, } @@ -47,6 +80,12 @@ func NewGatewayCommand() *cobra.Command { 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 839a7315a..825369abb 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -29,4 +29,38 @@ 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/helpers.go b/cmd/picoclaw/internal/helpers.go index 17de88ccb..afe5074a7 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -14,11 +14,7 @@ const Logo = pkg.Logo // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, _ := os.UserHomeDir() - return filepath.Join(home, pkg.DefaultPicoClawHome) + return config.GetHome() } func GetConfigPath() string { 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 index 314259d0f..c412993a0 100644 --- a/cmd/picoclaw/internal/model/command.go +++ b/cmd/picoclaw/internal/model/command.go @@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command { 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.`, @@ -51,6 +57,8 @@ Note: 'local-model' is a special value for using a local VLLM server }, } + cmd.AddCommand(newAddCommand()) + return cmd } @@ -66,6 +74,9 @@ func showCurrentModel(cfg *config.Config) { 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) { @@ -81,7 +92,7 @@ func listAvailableModels(cfg *config.Config) { if model.ModelName == defaultModel { marker = "> " } - if model.APIKey() == "" { + if !model.Enabled { continue } fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model) @@ -92,7 +103,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er // Validate that the model exists in model_list modelFound := false for _, model := range cfg.ModelList { - if model.APIKey() != "" && model.ModelName == modelName { + if model.Enabled && model.ModelName == modelName { modelFound = true break } diff --git a/cmd/picoclaw/internal/model/command_test.go b/cmd/picoclaw/internal/model/command_test.go index 8be29ba95..9e2a7bbae 100644 --- a/cmd/picoclaw/internal/model/command_test.go +++ b/cmd/picoclaw/internal/model/command_test.go @@ -65,11 +65,17 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) { }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + 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, }, }, } @@ -92,7 +98,12 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) { }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + ModelName: "gpt-4", + Model: "openai/gpt-4", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, }, } @@ -124,11 +135,17 @@ func TestListAvailableModels_WithModels(t *testing.T) { }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + 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"}, }, @@ -158,11 +175,13 @@ func TestSetDefaultModel_ValidModel(t *testing.T) { 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, }, }, } @@ -194,6 +213,7 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) { ModelName: "existing-model", Model: "openai/existing", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -215,6 +235,7 @@ func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) { ModelName: "existing-model", Model: "openai/existing", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, {ModelName: "no-key-model", Model: "openai/nokey"}, }, @@ -238,6 +259,7 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) { ModelName: "new-model", Model: "openai/new-model", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -283,6 +305,7 @@ func TestModelCommandExecution_Show(t *testing.T) { ModelName: "test-model", Model: "openai/test", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -314,11 +337,13 @@ func TestModelCommandExecution_Set(t *testing.T) { 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, }, }, } @@ -356,16 +381,19 @@ func TestListAvailableModels_MarkerLogic(t *testing.T) { 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, }, }, } 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 4be19b2a5..bf8f4104f 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -6,7 +6,7 @@ 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 diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 6f1d4bdd7..ecc699d4b 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -79,25 +80,7 @@ func onboard(encrypt bool) { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("\n%s picoclaw is ready!\n", internal.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("") - fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") + cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath) } // promptPassphrase reads the encryption passphrase twice from the terminal @@ -189,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/skills/command.go b/cmd/picoclaw/internal/skills/command.go index e8b884977..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,15 +28,6 @@ func NewSkillsCommand() *cobra.Command { } d.workspace = cfg.WorkspacePath() - installer, err := skills.NewSkillInstaller( - d.workspace, - cfg.Tools.Skills.Github.Token.String(), - cfg.Tools.Skills.Github.Proxy, - ) - if err != nil { - return fmt.Errorf("error creating skills installer: %w", err) - } - d.installer = installer // get global config directory and builtin skills directory globalDir := filepath.Dir(internal.GetConfigPath()) @@ -52,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") @@ -75,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 eec2dbb94..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,61 +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) - - clawHubConfig := cfg.Tools.Skills.Registries.ClawHub - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig{ - Enabled: clawHubConfig.Enabled, - BaseURL: clawHubConfig.BaseURL, - AuthToken: clawHubConfig.AuthToken.String(), - SearchPath: clawHubConfig.SearchPath, - SkillsPath: clawHubConfig.SkillsPath, - DownloadPath: clawHubConfig.DownloadPath, - Timeout: clawHubConfig.Timeout, - MaxZipSize: clawHubConfig.MaxZipSize, - MaxResponseSize: clawHubConfig.MaxResponseSize, - }, - }) + 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) @@ -99,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 { @@ -114,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) } @@ -129,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) { @@ -237,21 +276,7 @@ func skillsSearchCmd(query string) { return } - clawHubConfig := cfg.Tools.Skills.Registries.ClawHub - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig{ - Enabled: clawHubConfig.Enabled, - BaseURL: clawHubConfig.BaseURL, - AuthToken: clawHubConfig.AuthToken.String(), - SearchPath: clawHubConfig.SearchPath, - SkillsPath: clawHubConfig.SkillsPath, - DownloadPath: clawHubConfig.DownloadPath, - Timeout: clawHubConfig.Timeout, - MaxZipSize: clawHubConfig.MaxZipSize, - MaxResponseSize: clawHubConfig.MaxResponseSize, - }, - }) + 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 43c5786a8..f80b1f9c7 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -5,8 +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() { @@ -17,43 +19,127 @@ func statusCmd() { } configPath := internal.GetConfigPath() - - fmt.Printf("%s picoclaw Status\n", internal.Logo) - fmt.Printf("Version: %s\n", config.FormatVersion()) build, _ := config.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 := 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") + + // 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" + } + + 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 71c7dd2f8..81da4b878 100644 --- a/cmd/picoclaw/internal/version/command.go +++ b/cmd/picoclaw/internal/version/command.go @@ -1,11 +1,10 @@ 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" ) @@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command { } func printVersion() { - fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion()) build, goVer := config.FormatBuildInfo() - if build != "" { - fmt.Printf(" Build: %s\n", build) - } - if goVer != "" { - fmt.Printf(" Go: %s\n", goVer) - } + cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer) } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index bf9c0389f..abcf03a34 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -9,14 +9,17 @@ 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" @@ -24,17 +27,60 @@ import ( "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, config.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 version", + 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(), @@ -42,9 +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(), ) @@ -62,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 ad18cb330..037c7c2e6 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -3,6 +3,7 @@ package main import ( "fmt" "slices" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -17,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, config.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{ @@ -38,11 +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 ff2969dcb..910c4fbd3 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -11,9 +11,12 @@ "summarize_message_threshold": 20, "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 + "max_args_length": 300, + "separate_messages": false } } }, @@ -40,6 +43,7 @@ }, { "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" }, @@ -48,6 +52,15 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "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", @@ -260,10 +273,15 @@ "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": "pplx-xxx", @@ -373,9 +391,16 @@ "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": "" }, @@ -412,7 +437,14 @@ "enabled": true }, "read_file": { - "enabled": true + "enabled": true, + "mode": "bytes" + }, + "serial": { + "enabled": false + }, + "send_tts": { + "enabled": false }, "spawn": { "enabled": true @@ -450,9 +482,18 @@ "approval_timeout_ms": 60000 } }, + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "exclude": [], + "min_severity": "info", + "include_payload": false + } + }, "gateway": { "_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.", - "host": "127.0.0.1", + "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 index 5d65576f7..97944afc1 100644 --- a/docker/Dockerfile.goreleaser.launcher +++ b/docker/Dockerfile.goreleaser.launcher @@ -6,7 +6,6 @@ RUN apk add --no-cache ca-certificates tzdata COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher -COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui ENTRYPOINT ["picoclaw-launcher"] -CMD ["-public", "-no-browser"] +CMD ["-console", "-public", "-no-browser"] diff --git a/docker/Dockerfile.heavy b/docker/Dockerfile.heavy index cbc243e39..81f6976a2 100644 --- a/docker/Dockerfile.heavy +++ b/docker/Dockerfile.heavy @@ -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 @@ -48,20 +48,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw -# Reuse existing node user (UID/GID 1000) — rename to picoclaw -RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \ - addgroup -g 1000 picoclaw 2>/dev/null; \ - adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true - -USER picoclaw - # Run onboard to create initial directories and config RUN /usr/local/bin/picoclaw onboard # Copy default workspace -COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/ +COPY workspace/ /root/.picoclaw/workspace/ -VOLUME /home/picoclaw/.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 b26cf4199..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: @@ -22,9 +25,12 @@ services: # 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. @@ -38,15 +44,21 @@ services: # 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: on-failure + 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: - - "127.0.0.1:18800:18800" - - "127.0.0.1:18790:18790" + - "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/agent-refactor/context.md b/docs/architecture/agent-refactor/context.md similarity index 100% rename from docs/agent-refactor/context.md rename to docs/architecture/agent-refactor/context.md 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/hooks/README.md b/docs/architecture/hooks/README.md similarity index 84% rename from docs/hooks/README.md rename to docs/architecture/hooks/README.md index ec3bbc46a..06f1a2c07 100644 --- a/docs/hooks/README.md +++ b/docs/architecture/hooks/README.md @@ -13,7 +13,7 @@ The repository no longer ships standalone example source files. The Go and Pytho | Type | Interface | Stage | Can modify data | | --- | --- | --- | --- | -| Observer | `EventObserver` | EventBus broadcast | No | +| 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 | @@ -28,6 +28,69 @@ The currently exposed synchronous hook points are: 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: @@ -73,9 +136,9 @@ Example: "/tmp/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -111,7 +174,7 @@ Both examples are intentionally safe: they only log, never rewrite, and never de The following is a minimal logging hook for in-process use. It implements: -1. `EventObserver` +1. `RuntimeEventObserver` 2. `LLMInterceptor` 3. `ToolInterceptor` 4. `ToolApprover` @@ -133,6 +196,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -154,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook { } } -func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error { +func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { _ = ctx if h == nil || !h.logEvents { return nil } - h.record("event", evt.Meta, map[string]any{ + h.record("event", evt.Scope, map[string]any{ "event": evt.Kind.String(), "payload": evt.Payload, }, nil) @@ -212,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool( return decision, nil } -func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) { +func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) { logger.InfoCF("hooks", "Example hook observed", map[string]any{ "stage": stage, }) @@ -223,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a entry := map[string]any{ "ts": time.Now().UTC(), "stage": stage, - "meta": meta, + "refs": refs, "payload": payload, "decision": decision, } @@ -365,7 +429,7 @@ If you only see `before_llm` and `after_llm`, that usually means the request did The following script is a minimal process-hook example. It uses only the Python standard library and supports: 1. `hook.hello` -2. `hook.event` +2. `hook.runtime_event` 3. `hook.before_tool` 4. `hook.approve_tool` @@ -501,8 +565,8 @@ def main() -> int: }) if not message_id: - if method == "hook.event" and LOG_EVENTS: - log_stderr(f"observed event: {params.get('Kind')}") + if method == "hook.runtime_event" and LOG_EVENTS: + log_stderr(f"observed event: {params.get('kind')}") continue try: @@ -543,9 +607,9 @@ if __name__ == "__main__": "/abs/path/to/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -563,7 +627,7 @@ if __name__ == "__main__": ### Environment Variables - `PICOCLAW_HOOK_LOG_EVENTS` - Whether to write `hook.event` summaries to `stderr`, enabled by default + 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 @@ -582,7 +646,7 @@ Typical interpretation: - Only `hook.hello` The process started and completed the handshake, but no business hook request has arrived yet -- `hook.event` +- `hook.runtime_event` The `observe` configuration is working - `hook.before_tool` The `intercept: ["before_tool", ...]` configuration is working @@ -601,7 +665,7 @@ 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.event","params":{"Kind":"tool_exec_start"},"notification":true} +{"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} ``` @@ -609,7 +673,7 @@ A complete sample: Additional notes: - Timestamps are UTC -- `notification=true` means it was a notification such as `hook.event`, which does not expect a response +- `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 @@ -618,7 +682,7 @@ 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.event` is a notification and does not need a response +- `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. diff --git a/docs/hooks/README.zh.md b/docs/architecture/hooks/README.zh.md similarity index 85% rename from docs/hooks/README.zh.md rename to docs/architecture/hooks/README.zh.md index 46c7c9392..1fff40832 100644 --- a/docs/hooks/README.zh.md +++ b/docs/architecture/hooks/README.zh.md @@ -13,7 +13,7 @@ | 类型 | 接口 | 作用阶段 | 能否改写 | | --- | --- | --- | --- | -| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 | +| 观察型 | `RuntimeEventObserver` | runtime event bus 广播事件时 | 否 | | LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 | | Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 | | Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 | @@ -28,6 +28,69 @@ 其余 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 的排序规则是: @@ -73,9 +136,9 @@ HookManager 的排序规则是: "/tmp/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -111,7 +174,7 @@ tail -f /tmp/picoclaw-hook-review-gate.log 下面这段代码是一个最小的“记录型” in-process hook。它实现了: -1. `EventObserver` +1. `RuntimeEventObserver` 2. `LLMInterceptor` 3. `ToolInterceptor` 4. `ToolApprover` @@ -133,6 +196,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -154,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook { } } -func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error { +func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { _ = ctx if h == nil || !h.logEvents { return nil } - h.record("event", evt.Meta, map[string]any{ + h.record("event", evt.Scope, map[string]any{ "event": evt.Kind.String(), "payload": evt.Payload, }, nil) @@ -212,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool( return decision, nil } -func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) { +func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) { logger.InfoCF("hooks", "Example hook observed", map[string]any{ "stage": stage, }) @@ -223,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a entry := map[string]any{ "ts": time.Now().UTC(), "stage": stage, - "meta": meta, + "refs": refs, "payload": payload, "decision": decision, } @@ -365,7 +429,7 @@ func init() { 下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持: 1. `hook.hello` -2. `hook.event` +2. `hook.runtime_event` 3. `hook.before_tool` 4. `hook.approve_tool` @@ -501,8 +565,8 @@ def main() -> int: }) if not message_id: - if method == "hook.event" and LOG_EVENTS: - log_stderr(f"observed event: {params.get('Kind')}") + if method == "hook.runtime_event" and LOG_EVENTS: + log_stderr(f"observed event: {params.get('kind')}") continue try: @@ -543,9 +607,9 @@ if __name__ == "__main__": "/abs/path/to/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -563,7 +627,7 @@ if __name__ == "__main__": ### 环境变量 - `PICOCLAW_HOOK_LOG_EVENTS` - 是否把 `hook.event` 写到 `stderr`,默认开启 + 是否把 `hook.runtime_event` 写到 `stderr`,默认开启 - `PICOCLAW_HOOK_LOG_FILE` 外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件 @@ -582,7 +646,7 @@ if __name__ == "__main__": - 只看到 `hook.hello` 说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来 -- 看到 `hook.event` +- 看到 `hook.runtime_event` 说明 `observe` 配置生效了 - 看到 `hook.before_tool` 说明 `intercept: ["before_tool", ...]` 生效了 @@ -601,7 +665,7 @@ if __name__ == "__main__": ```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.event","params":{"Kind":"tool_exec_start"},"notification":true} +{"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} ``` @@ -609,7 +673,7 @@ if __name__ == "__main__": 补充说明: - 时间戳是 UTC,不是本地时区 -- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知 +- `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知 - `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始 ## Process Hook 协议约定 @@ -618,7 +682,7 @@ if __name__ == "__main__": - PicoClaw 启动外部进程 - 请求和响应都按“一行一个 JSON 消息”传输 -- `hook.event` 是 notification,不需要响应 +- `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 消息。 diff --git a/docs/architecture/hooks/hook-json-protocol.md b/docs/architecture/hooks/hook-json-protocol.md new file mode 100644 index 000000000..b04606777 --- /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..dac010a3e --- /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..b0436bc66 --- /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..0448ec1a8 --- /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/steering.md b/docs/architecture/steering.md similarity index 86% rename from docs/steering.md rename to docs/architecture/steering.md index 63294ac5f..1a993fdb3 100644 --- a/docs/steering.md +++ b/docs/architecture/steering.md @@ -170,13 +170,19 @@ This is saved to the session via `AddFullMessage` and sent to the model, so it i ## Automatic bus drain -When the agent loop (`Run()`) starts processing a message, it spawns a background goroutine that keeps consuming new inbound messages from the bus. These messages are automatically redirected into the steering queue via `Steer()`. This means: +When the agent loop (`Run()`) starts, it reads inbound messages from a shared message bus. The routing logic determines how each message is handled: -- Users on any channel (Telegram, Discord, etc.) don't need to do anything special — their messages are automatically captured as steering when the agent is busy -- Audio messages are transcribed before being steered, so the agent receives text. If transcription fails, the original (non-transcribed) message is steered as-is -- Only messages that resolve to the **same steering scope** as the active turn are redirected. Messages for other chats/sessions are requeued onto the inbound bus so they can be processed normally -- `system` inbound messages are not treated as steering input -- When `processMessage` finishes, the drain goroutine is canceled and normal message consumption resumes +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 diff --git a/docs/subturn.md b/docs/architecture/subturn.md similarity index 80% rename from docs/subturn.md rename to docs/architecture/subturn.md index b84c06627..31a56902c 100644 --- a/docs/subturn.md +++ b/docs/architecture/subturn.md @@ -112,13 +112,17 @@ When the parent task is forcefully aborted (e.g., user interrupts with `/stop`): ## Agent Loop Integration -### Bus Draining During Processing +### Message Routing and Steering -When a message enters the `Run()` loop, the agent starts a `drainBusToSteering` goroutine before calling `processMessage`. This goroutine runs concurrently with the entire processing lifecycle and continuously consumes any new inbound messages from the bus, redirecting them into the **steering queue** instead of dropping them. +When a message enters the `Run()` loop, the agent determines whether to start a new worker or enqueue to steering: -This ensures that if a user sends a follow-up message while the agent is processing (including during SubTurn execution), the message is not lost — it will be picked up between tool call iterations via `dequeueSteeringMessages`. +- 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. -The drain goroutine stops automatically when `processMessage` returns (via a cancellable context). +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 @@ -129,18 +133,18 @@ The agent loop polls for async SubTurn results at two points per iteration: ### Turn State Tracking -All active root turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns. +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. -## Event Bus Integration +## Runtime Event Integration -SubTurns emit specific events to the PicoClaw `EventBus` for observability and debugging: +SubTurns emit runtime events through `pkg/events` for observability and debugging: | Event Kind | When Emitted | Payload | |:------|:-------------|:--------| -| `subturn_spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` | -| `subturn_end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` | -| `subturn_result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` | -| `subturn_orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` | +| `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 @@ -181,10 +185,10 @@ Creates a new spawner instance for the given AgentLoop. Pass the returned value ### Continue ```go -func (al *AgentLoop) Continue(ctx context.Context, sessionKey string) error +func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) ``` -Resumes an idle agent turn by injecting any queued steering messages as a new LLM iteration. Used when the agent is waiting and a deferred steering message needs to be processed without a new inbound message arriving. +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 @@ -236,13 +240,13 @@ An orphan result occurs when: 2. The `pendingResults` channel is full (buffer size: 16) When a result becomes orphan: -- `SubTurnOrphanResultEvent` is emitted to EventBus +- `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 `SubTurnOrphanResultEvent` for observability +- Monitor `agent.subturn.orphan` for observability - Consider the 16-buffer limit when spawning many async SubTurns ## Tool Inheritance diff --git a/docs/channels/dingtalk/README.fr.md b/docs/channels/dingtalk/README.fr.md index 969346d65..ea0d45194 100644 --- a/docs/channels/dingtalk/README.fr.md +++ b/docs/channels/dingtalk/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # DingTalk @@ -8,9 +8,10 @@ DingTalk est la plateforme de communication d'entreprise d'Alibaba, très popula ```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/dingtalk/README.ja.md b/docs/channels/dingtalk/README.ja.md index d44a87820..4796038f9 100644 --- a/docs/channels/dingtalk/README.ja.md +++ b/docs/channels/dingtalk/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # DingTalk @@ -8,9 +8,10 @@ DingTalkはアリババの企業向けコミュニケーションプラットフ ```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/dingtalk/README.md b/docs/channels/dingtalk/README.md index a3f23a1e6..ed220ac63 100644 --- a/docs/channels/dingtalk/README.md +++ b/docs/channels/dingtalk/README.md @@ -8,9 +8,10 @@ DingTalk is Alibaba's enterprise communication platform, widely used in Chinese ```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/dingtalk/README.pt-br.md b/docs/channels/dingtalk/README.pt-br.md index f9056217f..c4a3da804 100644 --- a/docs/channels/dingtalk/README.pt-br.md +++ b/docs/channels/dingtalk/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # DingTalk @@ -8,9 +8,10 @@ DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente uti ```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/dingtalk/README.vi.md b/docs/channels/dingtalk/README.vi.md index 8c060a382..83550a14e 100644 --- a/docs/channels/dingtalk/README.vi.md +++ b/docs/channels/dingtalk/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # DingTalk @@ -8,9 +8,10 @@ DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được s ```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/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md index bdaaa1ee1..7c672c383 100644 --- a/docs/channels/dingtalk/README.zh.md +++ b/docs/channels/dingtalk/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 钉钉 @@ -8,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 index 61c34abb9..951eb59be 100644 --- a/docs/channels/discord/README.fr.md +++ b/docs/channels/discord/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Discord @@ -8,9 +8,10 @@ Discord est une application gratuite de chat vocal, vidéo et textuel conçue po ```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/discord/README.ja.md b/docs/channels/discord/README.ja.md index ecce30059..212abc1a3 100644 --- a/docs/channels/discord/README.ja.md +++ b/docs/channels/discord/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Discord @@ -8,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/discord/README.md b/docs/channels/discord/README.md index e1ce7ab06..741bc64a1 100644 --- a/docs/channels/discord/README.md +++ b/docs/channels/discord/README.md @@ -8,25 +8,56 @@ Discord is a free voice, video, and text chat application designed for communiti ```json { - "channels": { + "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 | -| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| 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 diff --git a/docs/channels/discord/README.pt-br.md b/docs/channels/discord/README.pt-br.md index c9ed2809b..32d828b76 100644 --- a/docs/channels/discord/README.pt-br.md +++ b/docs/channels/discord/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Discord @@ -8,9 +8,10 @@ Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para ```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/discord/README.vi.md b/docs/channels/discord/README.vi.md index 7073b04f1..e9ad6f5cc 100644 --- a/docs/channels/discord/README.vi.md +++ b/docs/channels/discord/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Discord @@ -8,9 +8,10 @@ Discord là ứng dụng chat thoại, video và văn bản miễn phí được ```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/discord/README.zh.md b/docs/channels/discord/README.zh.md index 673af4854..d6785ac3b 100644 --- a/docs/channels/discord/README.zh.md +++ b/docs/channels/discord/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Discord @@ -8,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 index f1ff26480..0d82c9655 100644 --- a/docs/channels/feishu/README.fr.md +++ b/docs/channels/feishu/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Feishu @@ -8,9 +8,10 @@ Feishu (nom international : Lark) est une plateforme de collaboration d'entrepri ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", diff --git a/docs/channels/feishu/README.ja.md b/docs/channels/feishu/README.ja.md index 4bb75a734..c19e9fbec 100644 --- a/docs/channels/feishu/README.ja.md +++ b/docs/channels/feishu/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # 飛書(Feishu) @@ -8,9 +8,10 @@ ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", diff --git a/docs/channels/feishu/README.md b/docs/channels/feishu/README.md index 2aeaa31cb..fca71c94d 100644 --- a/docs/channels/feishu/README.md +++ b/docs/channels/feishu/README.md @@ -8,9 +8,10 @@ Feishu (international name: Lark) is an enterprise collaboration platform by Byt ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", diff --git a/docs/channels/feishu/README.pt-br.md b/docs/channels/feishu/README.pt-br.md index 5b5fcaf68..73ab981e0 100644 --- a/docs/channels/feishu/README.pt-br.md +++ b/docs/channels/feishu/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Feishu @@ -8,9 +8,10 @@ Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", diff --git a/docs/channels/feishu/README.vi.md b/docs/channels/feishu/README.vi.md index e704b7794..1db4c1146 100644 --- a/docs/channels/feishu/README.vi.md +++ b/docs/channels/feishu/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Feishu @@ -8,9 +8,10 @@ Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp củ ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index 6e2829547..afe117286 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 飞书 @@ -8,9 +8,10 @@ ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", diff --git a/docs/channels/line/README.fr.md b/docs/channels/line/README.fr.md index 10bdf3e58..c37e1c3a0 100644 --- a/docs/channels/line/README.fr.md +++ b/docs/channels/line/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Line @@ -8,9 +8,10 @@ PicoClaw prend en charge LINE via l'API LINE Messaging avec des callbacks webhoo ```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/line/README.ja.md b/docs/channels/line/README.ja.md index 0e559093a..ed374c5e3 100644 --- a/docs/channels/line/README.ja.md +++ b/docs/channels/line/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Line @@ -8,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/line/README.md b/docs/channels/line/README.md index 1aad18eee..12da74546 100644 --- a/docs/channels/line/README.md +++ b/docs/channels/line/README.md @@ -8,9 +8,10 @@ PicoClaw supports LINE through the LINE Messaging API with webhook callbacks. ```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/line/README.pt-br.md b/docs/channels/line/README.pt-br.md index b3334461f..5feea3153 100644 --- a/docs/channels/line/README.pt-br.md +++ b/docs/channels/line/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Line @@ -8,9 +8,10 @@ O PicoClaw suporta o LINE por meio da LINE Messaging API com callbacks de webhoo ```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/line/README.vi.md b/docs/channels/line/README.vi.md index 3e5511a84..e834610e8 100644 --- a/docs/channels/line/README.vi.md +++ b/docs/channels/line/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Line @@ -8,9 +8,10 @@ PicoClaw hỗ trợ LINE thông qua LINE Messaging API kết hợp với webhook ```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/line/README.zh.md b/docs/channels/line/README.zh.md index 0f7dd0cd8..5b353de1b 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Line @@ -8,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 index 8fddb203a..23f8c11cc 100644 --- a/docs/channels/maixcam/README.fr.md +++ b/docs/channels/maixcam/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # MaixCam @@ -8,9 +8,10 @@ MaixCam est un canal dédié à la connexion aux caméras AI Sipeed MaixCAM et M ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, + "type": "maixcam", "host": "0.0.0.0", "port": 18790, "allow_from": [] diff --git a/docs/channels/maixcam/README.ja.md b/docs/channels/maixcam/README.ja.md index 0a5f27baa..adec19445 100644 --- a/docs/channels/maixcam/README.ja.md +++ b/docs/channels/maixcam/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # MaixCam @@ -8,9 +8,10 @@ MaixCam は、Sipeed MaixCAM および MaixCAM2 AI カメラデバイスへの ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, + "type": "maixcam", "host": "0.0.0.0", "port": 18790, "allow_from": [] diff --git a/docs/channels/maixcam/README.md b/docs/channels/maixcam/README.md index c22c9236f..f5efe53a4 100644 --- a/docs/channels/maixcam/README.md +++ b/docs/channels/maixcam/README.md @@ -8,9 +8,10 @@ MaixCam is a dedicated channel for connecting to Sipeed MaixCAM and MaixCAM2 AI ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, + "type": "maixcam", "host": "0.0.0.0", "port": 18790, "allow_from": [] diff --git a/docs/channels/maixcam/README.pt-br.md b/docs/channels/maixcam/README.pt-br.md index 81a1f3f00..dd606ff53 100644 --- a/docs/channels/maixcam/README.pt-br.md +++ b/docs/channels/maixcam/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # MaixCam @@ -8,9 +8,10 @@ MaixCam é um canal dedicado para conectar dispositivos de câmera AI Sipeed Mai ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, + "type": "maixcam", "host": "0.0.0.0", "port": 18790, "allow_from": [] diff --git a/docs/channels/maixcam/README.vi.md b/docs/channels/maixcam/README.vi.md index 8955bae86..09aba3540 100644 --- a/docs/channels/maixcam/README.vi.md +++ b/docs/channels/maixcam/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # MaixCam @@ -8,9 +8,10 @@ MaixCam là kênh chuyên dụng để kết nối với các thiết bị camer ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, + "type": "maixcam", "host": "0.0.0.0", "port": 18790, "allow_from": [] diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md index b0d58e733..2b4fdb87a 100644 --- a/docs/channels/maixcam/README.zh.md +++ b/docs/channels/maixcam/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # MaixCam @@ -8,9 +8,10 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的 ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, + "type": "maixcam", "host": "0.0.0.0", "port": 18790, "allow_from": [] diff --git a/docs/channels/matrix/README.fr.md b/docs/channels/matrix/README.fr.md index ec762a8b8..5ff329a28 100644 --- a/docs/channels/matrix/README.fr.md +++ b/docs/channels/matrix/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Guide de configuration du canal Matrix @@ -8,9 +8,10 @@ Ajoutez ceci à `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", diff --git a/docs/channels/matrix/README.ja.md b/docs/channels/matrix/README.ja.md index e5a773d4d..adb14a1f9 100644 --- a/docs/channels/matrix/README.ja.md +++ b/docs/channels/matrix/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Matrix チャンネル設定ガイド @@ -8,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", diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md index baded984e..0239928bc 100644 --- a/docs/channels/matrix/README.md +++ b/docs/channels/matrix/README.md @@ -8,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", diff --git a/docs/channels/matrix/README.pt-br.md b/docs/channels/matrix/README.pt-br.md index 11a9aaa11..4f606f3ed 100644 --- a/docs/channels/matrix/README.pt-br.md +++ b/docs/channels/matrix/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Guia de Configuração do Canal Matrix @@ -8,9 +8,10 @@ Adicione isto ao `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", diff --git a/docs/channels/matrix/README.vi.md b/docs/channels/matrix/README.vi.md index f1272076f..27f2ce746 100644 --- a/docs/channels/matrix/README.vi.md +++ b/docs/channels/matrix/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Hướng dẫn Cấu hình Kênh Matrix @@ -8,9 +8,10 @@ Thêm vào `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", diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md index 81afa550b..97634e2e6 100644 --- a/docs/channels/matrix/README.zh.md +++ b/docs/channels/matrix/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Matrix 通道配置指南 @@ -8,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", diff --git a/docs/channels/onebot/README.fr.md b/docs/channels/onebot/README.fr.md index 7c9ffe1d3..8a2aec8d2 100644 --- a/docs/channels/onebot/README.fr.md +++ b/docs/channels/onebot/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # OneBot @@ -8,9 +8,10 @@ OneBot est un standard de protocole ouvert pour les bots QQ, fournissant une int ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://localhost:8080", "access_token": "", "allow_from": [] diff --git a/docs/channels/onebot/README.ja.md b/docs/channels/onebot/README.ja.md index ce628572b..d2616e582 100644 --- a/docs/channels/onebot/README.ja.md +++ b/docs/channels/onebot/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # OneBot @@ -8,9 +8,10 @@ OneBot は QQ ボット向けのオープンプロトコル標準で、複数の ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://localhost:8080", "access_token": "", "allow_from": [] diff --git a/docs/channels/onebot/README.md b/docs/channels/onebot/README.md index 42af39b4e..7dd1e3c88 100644 --- a/docs/channels/onebot/README.md +++ b/docs/channels/onebot/README.md @@ -8,9 +8,10 @@ OneBot is an open protocol standard for QQ bots, providing a unified interface f ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://localhost:8080", "access_token": "", "allow_from": [] diff --git a/docs/channels/onebot/README.pt-br.md b/docs/channels/onebot/README.pt-br.md index 5323163ee..2e037361f 100644 --- a/docs/channels/onebot/README.pt-br.md +++ b/docs/channels/onebot/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # OneBot @@ -8,9 +8,10 @@ OneBot é um padrão de protocolo aberto para bots QQ, fornecendo uma interface ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://localhost:8080", "access_token": "", "allow_from": [] diff --git a/docs/channels/onebot/README.vi.md b/docs/channels/onebot/README.vi.md index a572e7afa..3dfcf8161 100644 --- a/docs/channels/onebot/README.vi.md +++ b/docs/channels/onebot/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # OneBot @@ -8,9 +8,10 @@ OneBot là tiêu chuẩn giao thức mở dành cho bot QQ, cung cấp giao di ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://localhost:8080", "access_token": "", "allow_from": [] diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md index 8caba0b80..4e5210b82 100644 --- a/docs/channels/onebot/README.zh.md +++ b/docs/channels/onebot/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # OneBot @@ -8,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 index 38de1b751..2202fa09d 100644 --- a/docs/channels/qq/README.fr.md +++ b/docs/channels/qq/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # QQ @@ -8,9 +8,10 @@ PicoClaw prend en charge QQ via l'API Bot officielle de la plateforme ouverte QQ ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] diff --git a/docs/channels/qq/README.ja.md b/docs/channels/qq/README.ja.md index 2990f9622..d9e86a061 100644 --- a/docs/channels/qq/README.ja.md +++ b/docs/channels/qq/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # QQ @@ -8,9 +8,10 @@ PicoClaw は QQ オープンプラットフォームの公式 Bot API を通じ ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] diff --git a/docs/channels/qq/README.md b/docs/channels/qq/README.md index 35e4a769c..bc8ccf837 100644 --- a/docs/channels/qq/README.md +++ b/docs/channels/qq/README.md @@ -8,9 +8,10 @@ PicoClaw provides QQ support via the official Bot API from the QQ Open Platform. ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] diff --git a/docs/channels/qq/README.pt-br.md b/docs/channels/qq/README.pt-br.md index 507df7f7e..b0a7e5568 100644 --- a/docs/channels/qq/README.pt-br.md +++ b/docs/channels/qq/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # QQ @@ -8,9 +8,10 @@ O PicoClaw oferece suporte ao QQ via API Bot oficial da Plataforma Aberta QQ. ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] diff --git a/docs/channels/qq/README.vi.md b/docs/channels/qq/README.vi.md index 1f3eb89da..cf940d05d 100644 --- a/docs/channels/qq/README.vi.md +++ b/docs/channels/qq/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # QQ @@ -8,9 +8,10 @@ PicoClaw hỗ trợ QQ thông qua API Bot chính thức của Nền tảng Mở ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md index e7f6d2050..dc40f6225 100644 --- a/docs/channels/qq/README.zh.md +++ b/docs/channels/qq/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # QQ @@ -8,9 +8,10 @@ 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": [], diff --git a/docs/channels/slack/README.fr.md b/docs/channels/slack/README.fr.md index 81dcebdec..be533052a 100644 --- a/docs/channels/slack/README.fr.md +++ b/docs/channels/slack/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Slack @@ -8,9 +8,10 @@ Slack est l'une des principales plateformes de messagerie instantanée pour les ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-...", "app_token": "xapp-...", "allow_from": [] diff --git a/docs/channels/slack/README.ja.md b/docs/channels/slack/README.ja.md index c8d268b9c..38cfc0134 100644 --- a/docs/channels/slack/README.ja.md +++ b/docs/channels/slack/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Slack @@ -8,9 +8,10 @@ Slack は世界をリードする企業向けインスタントメッセージ ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-...", "app_token": "xapp-...", "allow_from": [] diff --git a/docs/channels/slack/README.md b/docs/channels/slack/README.md index 9d5aafab9..4f1014511 100644 --- a/docs/channels/slack/README.md +++ b/docs/channels/slack/README.md @@ -8,9 +8,10 @@ Slack is a leading enterprise instant messaging platform. PicoClaw uses Slack's ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-...", "app_token": "xapp-...", "allow_from": [] diff --git a/docs/channels/slack/README.pt-br.md b/docs/channels/slack/README.pt-br.md index ea8a6c0fc..d2676d44a 100644 --- a/docs/channels/slack/README.pt-br.md +++ b/docs/channels/slack/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Slack @@ -8,9 +8,10 @@ O Slack é uma das principais plataformas de mensagens instantâneas para empres ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-...", "app_token": "xapp-...", "allow_from": [] diff --git a/docs/channels/slack/README.vi.md b/docs/channels/slack/README.vi.md index dae84728c..3bbbe3132 100644 --- a/docs/channels/slack/README.vi.md +++ b/docs/channels/slack/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Slack @@ -8,9 +8,10 @@ Slack là nền tảng nhắn tin tức thì hàng đầu dành cho doanh nghi ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-...", "app_token": "xapp-...", "allow_from": [] diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md index 884039162..8ecfe88bf 100644 --- a/docs/channels/slack/README.zh.md +++ b/docs/channels/slack/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Slack @@ -8,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 index d9ab0644f..51db2082f 100644 --- a/docs/channels/telegram/README.fr.md +++ b/docs/channels/telegram/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Telegram @@ -8,23 +8,26 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "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) | +| 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 @@ -33,3 +36,21 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun 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 index 03c48cb64..03303f255 100644 --- a/docs/channels/telegram/README.ja.md +++ b/docs/channels/telegram/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Telegram @@ -8,23 +8,26 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ ```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 Bot API トークン | -| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | -| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | +| フィールド | 型 | 必須 | 説明 | +| --------------- | ------ | ---- | ----------------------------------------------------------------- | +| 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 フォーマットを有効にする | ## セットアップ手順 @@ -33,3 +36,21 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ 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 index 86c016a5d..a4138009e 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -2,29 +2,32 @@ # 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](../../providers.md#voice-transcription)), and built-in command handling. +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 { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "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) | +| 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 @@ -41,6 +44,8 @@ Telegram auto-registers PicoClaw's top-level bot commands at startup, including 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. @@ -49,7 +54,27 @@ 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 index 8d2c935b4..4af8d7a25 100644 --- a/docs/channels/telegram/README.pt-br.md +++ b/docs/channels/telegram/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Telegram @@ -8,23 +8,26 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "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) | +| 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 @@ -33,3 +36,21 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica 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 index 858a9fc41..c6a276754 100644 --- a/docs/channels/telegram/README.vi.md +++ b/docs/channels/telegram/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Telegram @@ -8,23 +8,26 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "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) | +| 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 @@ -33,3 +36,21 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d 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 1d9dcc46e..543e16e47 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -1,30 +1,33 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Telegram -Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../zh/providers.md#语音转录)),以及内置命令处理器。 +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 格式化 | ## 设置流程 @@ -50,6 +53,24 @@ Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/st ```text /list skills /use git explain how to squash the last 3 commits -/use italiapersonalfinance -dammi le ultime news +/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 index 8f6cfe285..843943bdf 100644 --- a/docs/channels/wecom/README.fr.md +++ b/docs/channels/wecom/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # WeCom @@ -56,9 +56,10 @@ Si vous disposez déjà d'un `bot_id` et d'un `secret` depuis la plateforme WeCo ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", diff --git a/docs/channels/wecom/README.ja.md b/docs/channels/wecom/README.ja.md index 34b785ba5..459a922a6 100644 --- a/docs/channels/wecom/README.ja.md +++ b/docs/channels/wecom/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # WeCom @@ -56,9 +56,10 @@ WeCom AI Bot プラットフォームから `bot_id` と `secret` を既にお ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", diff --git a/docs/channels/wecom/README.md b/docs/channels/wecom/README.md index e99f6540d..bb94d7431 100644 --- a/docs/channels/wecom/README.md +++ b/docs/channels/wecom/README.md @@ -56,9 +56,10 @@ If you already have a `bot_id` and `secret` from the WeCom AI Bot platform, conf ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", diff --git a/docs/channels/wecom/README.pt-br.md b/docs/channels/wecom/README.pt-br.md index 5d8cf10f0..07a5e23b9 100644 --- a/docs/channels/wecom/README.pt-br.md +++ b/docs/channels/wecom/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # WeCom @@ -56,9 +56,10 @@ Se você já possui um `bot_id` e `secret` da plataforma WeCom AI Bot, configure ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", diff --git a/docs/channels/wecom/README.vi.md b/docs/channels/wecom/README.vi.md index caffb3465..4769fd6d6 100644 --- a/docs/channels/wecom/README.vi.md +++ b/docs/channels/wecom/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # WeCom @@ -56,9 +56,10 @@ Nếu bạn đã có `bot_id` và `secret` từ nền tảng WeCom AI Bot, hãy ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", diff --git a/docs/channels/wecom/README.zh.md b/docs/channels/wecom/README.zh.md index 2134b94b5..8303a8f8a 100644 --- a/docs/channels/wecom/README.zh.md +++ b/docs/channels/wecom/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 企业微信(WeCom) @@ -56,9 +56,10 @@ picoclaw auth wecom --timeout 10m ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", diff --git a/docs/channels/weixin/README.md b/docs/channels/weixin/README.md index 0c51ff3c5..4e240d69b 100644 --- a/docs/channels/weixin/README.md +++ b/docs/channels/weixin/README.md @@ -29,9 +29,10 @@ You can also manually configure the filter rules in `config.json` under the `cha ```json { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_WEIXIN_TOKEN", "allow_from": [ "user_id_1", diff --git a/docs/channels/weixin/README.zh.md b/docs/channels/weixin/README.zh.md index 0f1181878..19a9f9fa2 100644 --- a/docs/channels/weixin/README.zh.md +++ b/docs/channels/weixin/README.zh.md @@ -29,9 +29,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_WEIXIN_TOKEN", "allow_from": [ "user_id_1", diff --git a/docs/config-versioning.md b/docs/config-versioning.md deleted file mode 100644 index 36d7fdd25..000000000 --- a/docs/config-versioning.md +++ /dev/null @@ -1,230 +0,0 @@ -# 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 - -## 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. The version number is updated automatically -5. 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`: Current version with versioning support - -```json -{ - "version": 1, - "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 CurrentConfigVersion = 2 // Increment this -``` - -### Step 3: Add a Loader Function - -```go -// loadConfigV2 loads a version 2 config -func loadConfigV2(data []byte) (*Config, error) { - cfg := DefaultConfig() - - // Parse to ConfigV2 struct - var v2 ConfigV2 - if err := json.Unmarshal(data, &v2); err != nil { - return nil, err - } - - // Convert to current Config - cfg.Version = v2.Version - cfg.Agents = v2.Agents - // ... map other fields - - return cfg, nil -} -``` - -### Step 4: Add Migration Logic - -```go -// applyMigration applies a single migration step from fromVersion to toVersion -func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) { - switch toVersion { - case 1: - // Migration from version 0 to 1 - return &Config{ - Version: 1, - Agents: cfg.Agents, - // ... copy all fields - }, nil - case 2: - // Migration from version 1 to 2 - // Example: Move or rename fields - migrated := *cfg - migrated.Version = 2 - // Apply structural changes - if cfg.SomeOldField != "" { - migrated.SomeNewField = cfg.SomeOldField - } - return &migrated, nil - default: - return nil, fmt.Errorf("unsupported migration target version: %d", toVersion) - } -} -``` - -### 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 = loadConfigV2(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 TestMigrateV1ToV2(t *testing.T) { - // Create a version 1 config - v1Config := Config{ - Version: 1, - // ... set up test data - } - - // Apply migration - migrated, err := applyMigration(&v1Config, 1, 2) - if err != nil { - t.Fatalf("Migration failed: %v", err) - } - - // Verify version is updated - if migrated.Version != 2 { - t.Errorf("Expected version 2, 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. **Test Thoroughly**: Test with real user config files -7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema - -## Example Migration - -### Scenario: Adding a new field with default value - -Old config (version 1): -```json -{ - "version": 1, - "agents": { - "defaults": { - "max_tokens": 32768 - } - } -} -``` - -Migration to version 2: -```go -case 2: - migrated := *cfg - migrated.Version = 2 - - // Add new field with default value if not set - if migrated.Agents.Defaults.NewFeatureEnabled == false { - // Use default value - } - - return &migrated, nil -``` - -New config (version 2): -```json -{ - "version": 2, - "agents": { - "defaults": { - "max_tokens": 32768, - "new_feature_enabled": false - } - } -} -``` - -## Troubleshooting - -### Config Not Upgrading -- Check that `CurrentConfigVersion` is incremented -- Verify migration logic in `applyMigration()` handles the target version -- Ensure `migrateConfig()` 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 - 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 index ab5566bec..090437c20 100644 --- a/docs/design/hook-system-design.zh.md +++ b/docs/design/hook-system-design.zh.md @@ -1,11 +1,15 @@ # PicoClaw Hook 系统设计(基于 `refactor/agent`) +> 当前状态:本文是 hook 系统的早期设计记录。事件系统升级后,观察型 hook 的主路径已经切到 +> `pkg/events.Event`、`RuntimeEventObserver` 和进程 hook 的 `hook.runtime_event`。 +> 旧 `agent.Event`、`EventKind`、`hook.event` 兼容层已经删除。 + ## 背景 本设计围绕两个议题展开: - `#1316`:把 agent loop 重构为事件驱动、可中断、可追加、可观测 -- `#1796`:在 EventBus 稳定后,把 hooks 设计为 EventBus 的 consumer,而不是重新发明一套事件模型 +- `#1796`:在 runtime event bus 稳定后,把 hooks 设计为事件 consumer,而不是重新发明一套事件模型 当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。 @@ -52,20 +56,18 @@ pi-mono 的核心思路更接近当前分支: 当前分支已经具备 hook 系统的地基: -- `pkg/agent/events.go` 定义了稳定的 `EventKind`、`EventMeta` 和 payload -- `pkg/agent/eventbus.go` 提供了非阻塞 fan-out 的 `EventBus` +- `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,只有 EventBus -- 没有 Before/After LLM、Before/After Tool 这种同步拦截点 -- 没有审批型 hook -- 子 agent 仍走 `pkg/tools/SubagentManager + RunToolLoop`,没有接入 `pkg/agent` 的 turn tree 和事件流 +早期设计时的缺口包括 HookManager、Before/After LLM、Before/After Tool、审批型 hook +以及 sub-turn 接入。当前实现已经覆盖主 turn 的 HookManager、LLM/Tool 拦截和审批; +sub-turn 事件已接入 runtime event bus。 ### 一个关键现实 @@ -73,19 +75,19 @@ pi-mono 的核心思路更接近当前分支: ## 设计原则 -- Hook 必须建立在 `pkg/agent` 的 EventBus 和 turn 上下文之上 -- EventBus 负责广播,HookManager 负责拦截,两者职责分离 +- Hook 必须建立在 `pkg/events` runtime event bus 和 turn 上下文之上 +- runtime event bus 负责广播,HookManager 负责拦截,两者职责分离 - 项目内挂载要简单,项目外挂载必须走 IPC - 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时 - 先覆盖主 turn,不把 sub-turn 一次做满 -- 不新增第二套用户事件命名系统,优先复用 `EventKind.String()` +- 不新增第二套用户事件命名系统,新观察点统一使用 `pkg/events.Kind` ## 总体架构 分成三层: -1. `EventBus` - 负责广播只读事件,现有实现直接复用 +1. `pkg/events` runtime event bus + 负责广播只读事件,覆盖 agent、channel、gateway、bus、MCP 等运行时组件 2. `HookManager` 负责管理 hook、排序、超时、错误隔离,并在 `runTurn()` 的明确检查点执行同步拦截 @@ -97,7 +99,7 @@ pi-mono 的核心思路更接近当前分支: 换句话说: -- EventBus 是“发生了什么” +- runtime event bus 是“发生了什么” - HookManager 是“谁能介入” - HookMount 是“这些 hook 从哪里来” @@ -113,11 +115,11 @@ pi-mono 的核心思路更接近当前分支: ```go type EventObserver interface { - OnEvent(ctx context.Context, evt agent.Event) error + OnRuntimeEvent(ctx context.Context, evt events.Event) error } ``` -这类 hook 直接订阅 EventBus 即可。 +这类 hook 直接订阅 runtime event bus 即可。 适用场景: @@ -156,7 +158,7 @@ type ToolApprover interface { ## 对外暴露的最小 hook 面 -V1 不需要把所有 EventKind 都变成可拦截点。 +V1 不需要把所有 runtime event kind 都变成可拦截点。 建议只开放这些同步 hook: @@ -168,19 +170,19 @@ V1 不需要把所有 EventKind 都变成可拦截点。 其余节点继续作为只读事件暴露: -- `turn_start` -- `turn_end` -- `llm_request` -- `llm_response` -- `tool_exec_start` -- `tool_exec_end` -- `tool_exec_skipped` -- `steering_injected` -- `follow_up_queued` -- `interrupt_received` -- `context_compress` -- `session_summarize` -- `error` +- `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 迁移完成。 @@ -369,7 +371,7 @@ PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。 ### 观察链路 ```text -runTurn() -> emitEvent() -> EventBus -> observers +runTurn() -> emitEvent() -> runtime event bus -> observers ``` ### 拦截链路 @@ -453,7 +455,7 @@ V1 不做复杂自动发现。 ### Phase 3 - 把 `SubagentManager` 迁移到 `runTurn/sub-turn` -- 接通 `subturn_spawn` / `subturn_end` / `subturn_result_delivered` +- 接通 `agent.subturn.spawn` / `agent.subturn.end` / `agent.subturn.result_delivered` ### Phase 4 @@ -464,13 +466,13 @@ V1 不做复杂自动发现。 最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是: -- 以现有 `EventBus` 为只读观察面 +- 以 `pkg/events` runtime event bus 为只读观察面 - 以新增 `HookManager` 为同步拦截面 - 项目内通过 Go 对象直接挂载 - 项目外通过 `stdio JSON-RPC` 进程通信挂载 这样做有三个好处: -- 和 `#1796` 一致,hooks 只是 EventBus 之上的消费层 +- 和 `#1796` 一致,hooks 只是 runtime event bus 之上的消费层 - 和当前 `refactor/agent` 实现一致,不需要推翻已有事件系统 - 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求 diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index 38f379c50..3c31f610f 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -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 | --- diff --git a/docs/design/steering-spec.md b/docs/design/steering-spec.md index 0951bf864..5fd8360b3 100644 --- a/docs/design/steering-spec.md +++ b/docs/design/steering-spec.md @@ -26,7 +26,8 @@ graph TD subgraph AgentLoop BUS[MessageBus] - DRAIN[drainBusToSteering goroutine] + ROUTE{Session Routing} + WP[Worker Pool] SQ[steeringQueue] RLI[runLLMIteration] TE[Tool Execution Loop] @@ -37,8 +38,11 @@ graph TD DC -->|PublishInbound| BUS SL -->|PublishInbound| BUS - BUS -->|ConsumeInbound while busy| DRAIN - DRAIN -->|Steer| SQ + 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 @@ -47,32 +51,34 @@ graph TD RLI -->|inject into context| LLM ``` -### Bus drain mechanism +### Message routing and worker pool -Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. Without additional wiring, these messages would sit in the bus buffer until the current `processMessage` finishes — meaning steering would never work for real users. +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**: -The solution: when `Run()` starts processing a message, it spawns a **drain goroutine** (`drainBusToSteering`) that keeps consuming from the bus and calling `Steer()`. When `processMessage` returns, the drain is canceled and normal consumption resumes. +- **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 Drain - participant AgentLoop + participant Worker + participant SQ Run->>Bus: ConsumeInbound() → msg - Run->>Drain: spawn drainBusToSteering(ctx) - Run->>Run: processMessage(msg) + Run->>Run: resolveSteeringTarget(msg) → sessionKey - Note over Drain: running concurrently - - Bus-->>Drain: ConsumeInbound() → newMsg - Drain->>AgentLoop: al.transcribeAudioInMessage(ctx, newMsg) - Drain->>AgentLoop: Steer(providers.Message{Content: newMsg.Content}) - - Run->>Run: processMessage returns - Run->>Drain: cancel context - Note over Drain: exits + 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 @@ -121,7 +127,7 @@ A new field was added to `processOptions`: | `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. Returns `""` if queue is empty. | +| `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 @@ -280,15 +286,17 @@ flowchart TD { "agents": { "defaults": { - "steering_mode": "one-at-a-time" + "steering_mode": "one-at-a-time", + "max_parallel_turns": 1 } } } ``` -| Field | Type | Default | Env var | -|-------|------|---------|---------| -| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` | +| 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 @@ -300,7 +308,8 @@ flowchart TD | `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 same steering queue since `processMessage` is sequential. | -| Bus drain goroutine in `Run()` | Channels (Telegram, Discord, etc.) publish to the bus via `PublishInbound`. Without the drain, messages would queue in the bus channel buffer and only be consumed after `processMessage` returns — defeating the purpose of steering. The drain goroutine bridges the gap by consuming new bus messages and calling `Steer()` while the agent is busy. | -| Audio transcription before steering | The drain goroutine calls `al.transcribeAudioInMessage(ctx, msg)` before steering, so voice messages are converted to text before the agent sees them. If transcription fails, the error is silently discarded and the original message is steered as-is. | +| 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/fr/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.fr.md similarity index 98% rename from docs/fr/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.fr.md index d6d0a2bd4..5672952d3 100644 --- a/docs/fr/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Utiliser le fournisseur Antigravity dans PicoClaw diff --git a/docs/ja/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.ja.md similarity index 98% rename from docs/ja/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.ja.md index c044c1970..bd221ed1c 100644 --- a/docs/ja/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # PicoClaw で Antigravity プロバイダーを使用する 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/pt-br/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md similarity index 98% rename from docs/pt-br/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.pt-br.md index d4b681ad0..e5108916a 100644 --- a/docs/pt-br/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Usando o provedor Antigravity no PicoClaw diff --git a/docs/vi/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.vi.md similarity index 98% rename from docs/vi/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.vi.md index 4a696f770..54b4a6add 100644 --- a/docs/vi/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Sử dụng nhà cung cấp Antigravity trong PicoClaw diff --git a/docs/zh/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.zh.md similarity index 98% rename from docs/zh/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.zh.md index 2218618a9..b4dde6ea3 100644 --- a/docs/zh/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 在 PicoClaw 中使用 Antigravity 提供商 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/fr/chat-apps.md b/docs/guides/chat-apps.fr.md similarity index 92% rename from docs/fr/chat-apps.md rename to docs/guides/chat-apps.fr.md index c36e002ff..d9112c595 100644 --- a/docs/fr/chat-apps.md +++ b/docs/guides/chat-apps.fr.md @@ -1,6 +1,6 @@ # 💬 Configuration des Applications de Chat -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## 💬 Applications de Chat @@ -19,7 +19,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din | **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 | [Bot](../channels/wecom/wecom_bot/README.fr.md) / [App](../channels/wecom/wecom_app/README.fr.md) / [AI Bot](../channels/wecom/wecom_aibot/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) | @@ -40,9 +40,10 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -60,11 +61,19 @@ 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`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés. +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 +
@@ -90,9 +99,10 @@ Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -113,7 +123,7 @@ Par défaut, le bot répond à tous les messages dans un canal de serveur. Pour ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "mention_only": true } } @@ -125,7 +135,7 @@ Vous pouvez également déclencher par préfixes de mots-clés (par ex. `!bot`) ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } @@ -154,9 +164,10 @@ PicoClaw peut se connecter à WhatsApp de deux manières : ```json { - "channels": { + "channel_list": { "whatsapp": { "enabled": true, + "type": "whatsapp", "use_native": true, "session_store_path": "", "allow_from": [] @@ -188,9 +199,10 @@ Scannez le QR code affiché avec votre application WeChat mobile. Une fois conne (Optionnel) Ajoutez votre identifiant utilisateur WeChat dans `allow_from` pour restreindre qui peut envoyer des messages au bot : ```json { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -219,9 +231,10 @@ QQ Open Platform propose une page de configuration en un clic pour les bots comp ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -261,9 +274,10 @@ Si vous préférez créer le bot manuellement : ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] @@ -294,9 +308,10 @@ picoclaw gateway ```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", @@ -330,9 +345,10 @@ Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeh ```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", @@ -375,7 +391,7 @@ PicoClaw prend en charge trois types d'intégration WeCom : **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 AI Bot](../channels/wecom/wecom_aibot/README.fr.md) pour les instructions détaillées. +Voir le [Guide de Configuration WeCom](../channels/wecom/README.fr.md) pour les instructions détaillées. **Configuration rapide - WeCom Bot :** @@ -388,9 +404,10 @@ Voir le [Guide de Configuration WeCom AI Bot](../channels/wecom/wecom_aibot/READ ```json { - "channels": { + "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", @@ -421,7 +438,7 @@ Voir le [Guide de Configuration WeCom AI Bot](../channels/wecom/wecom_aibot/READ ```json { - "channels": { + "channel_list": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", @@ -456,7 +473,7 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", @@ -497,9 +514,10 @@ PicoClaw se connecte à Feishu via le mode WebSocket/SDK — aucune URL webhook ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -537,9 +555,10 @@ Pour toutes les options, voir le [Guide de Configuration du Canal Feishu](../cha ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] @@ -564,9 +583,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "irc": { "enabled": true, + "type": "irc", "server": "irc.libera.chat:6697", "tls": true, "nick": "picoclaw-bot", @@ -604,9 +624,10 @@ Installez et exécutez un framework de bot QQ compatible OneBot v11. Activez son ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://127.0.0.1:8080", "access_token": "", "allow_from": [] @@ -641,9 +662,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, + "type": "maixcam", "allow_from": [] } } diff --git a/docs/ja/chat-apps.md b/docs/guides/chat-apps.ja.md similarity index 94% rename from docs/ja/chat-apps.md rename to docs/guides/chat-apps.ja.md index 341dc4aba..49c41a66e 100644 --- a/docs/ja/chat-apps.md +++ b/docs/guides/chat-apps.ja.md @@ -1,6 +1,6 @@ # 💬 チャットアプリ設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## 💬 チャットアプリ連携 @@ -21,7 +21,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして | **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 対応 | [Bot](../channels/wecom/wecom_bot/README.ja.md) / [App](../channels/wecom/wecom_app/README.ja.md) / [AI Bot](../channels/wecom/wecom_aibot/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) | @@ -44,9 +44,10 @@ PicoClaw は複数のチャットプラットフォームをサポートして ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -64,7 +65,7 @@ picoclaw gateway **4. Telegram コマンドメニュー(起動時に自動登録)** -PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。 +PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。 Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。 ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。 @@ -95,9 +96,10 @@ Telegram 側はコマンドメニュー登録機能を保持し、汎用コマ ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -118,7 +120,7 @@ Telegram 側はコマンドメニュー登録機能を保持し、汎用コマ ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "mention_only": true } } @@ -130,7 +132,7 @@ Telegram 側はコマンドメニュー登録機能を保持し、汎用コマ ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } @@ -159,9 +161,10 @@ PicoClaw は 2 つの WhatsApp 接続方式をサポートしています: ```json { - "channels": { + "channel_list": { "whatsapp": { "enabled": true, + "type": "whatsapp", "use_native": true, "session_store_path": "", "allow_from": [] @@ -193,9 +196,10 @@ WeChat モバイルアプリで表示された QR コードをスキャンして (オプション)ボットと会話できるユーザーを制限するために `allow_from` に WeChat ユーザー ID を追加します: ```json { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -223,9 +227,10 @@ picoclaw gateway ```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", @@ -259,9 +264,10 @@ QQ 開放プラットフォームでは、OpenClaw 互換ボットのワンク ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -302,9 +308,10 @@ QQ 開放プラットフォームでは、OpenClaw 互換ボットのワンク ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] @@ -329,9 +336,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "irc": { "enabled": true, + "type": "irc", "server": "irc.libera.chat:6697", "tls": true, "nick": "picoclaw-bot", @@ -369,9 +377,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] @@ -404,9 +413,10 @@ picoclaw gateway ```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", @@ -456,9 +466,10 @@ PicoClaw は WebSocket/SDK モードで飛書に接続します — 公開 Webho ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -491,7 +502,7 @@ PicoClaw は 3 種類の WeCom 統合をサポートしています: **方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ **方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応 -詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.ja.md) を参照してください。 +詳細なセットアップ手順は [WeCom 設定ガイド](../channels/wecom/README.ja.md) を参照してください。 **クイックセットアップ — グループ Bot:** @@ -504,9 +515,10 @@ PicoClaw は 3 種類の WeCom 統合をサポートしています: ```json { - "channels": { + "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", @@ -537,7 +549,7 @@ PicoClaw は 3 種類の WeCom 統合をサポートしています: ```json { - "channels": { + "channel_list": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", @@ -572,7 +584,7 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", @@ -610,9 +622,10 @@ OneBot v11 互換の QQ ボットフレームワークをインストールし ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://127.0.0.1:8080", "access_token": "", "allow_from": [] @@ -643,9 +656,10 @@ Sipeed AI カメラハードウェア向けの統合チャネルです。 ```json { - "channels": { + "channel_list": { "maixcam": { - "enabled": true + "enabled": true, + "type": "maixcam" } } } diff --git a/docs/chat-apps.md b/docs/guides/chat-apps.md similarity index 84% rename from docs/chat-apps.md rename to docs/guides/chat-apps.md index 3d01994ff..62418f91a 100644 --- a/docs/chat-apps.md +++ b/docs/guides/chat-apps.md @@ -10,20 +10,20 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, | 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) | +| **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) | +| **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) | +| **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 | | @@ -40,9 +40,10 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], "use_markdown_v2": false @@ -61,17 +62,20 @@ 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`) so command menu and runtime behavior stay in sync. +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 manage installed skills directly from Telegram: +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. @@ -101,9 +105,10 @@ You can set use_markdown_v2: true to enable enhanced formatting options. This al ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -124,7 +129,7 @@ By default the bot responds to all messages in a server channel. To restrict res ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "mention_only": true } } @@ -136,7 +141,7 @@ You can also trigger by keyword prefixes (e.g. `!bot`): ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } @@ -165,9 +170,10 @@ PicoClaw can connect to WhatsApp in two ways: ```json { - "channels": { + "channel_list": { "whatsapp": { "enabled": true, + "type": "whatsapp", "use_native": true, "session_store_path": "", "allow_from": [] @@ -199,9 +205,10 @@ Scan the printed QR code with your WeChat mobile app. On success, the token is s (Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot: ```json { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -230,9 +237,10 @@ QQ Open Platform provides a one-click setup page for OpenClaw-compatible bots: ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -272,9 +280,10 @@ If you prefer to create the bot manually: ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] @@ -305,9 +314,10 @@ picoclaw gateway ```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", @@ -323,7 +333,7 @@ picoclaw gateway 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). +For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](../channels/matrix/README.md). @@ -341,9 +351,10 @@ For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, ```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", @@ -383,7 +394,7 @@ picoclaw gateway 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. +See [WeCom Configuration Guide](../channels/wecom/README.md) for the full configuration reference and migration notes. **Quick Setup - Recommended** @@ -399,9 +410,10 @@ This command shows a QR code, waits for approval in WeCom, and writes `bot_id` + ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", @@ -440,9 +452,10 @@ PicoClaw connects to Feishu via WebSocket/SDK mode — no public webhook URL or ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -461,7 +474,7 @@ 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). +For full options, see [Feishu Channel Configuration Guide](../channels/feishu/README.md). @@ -480,9 +493,10 @@ For full options, see [Feishu Channel Configuration Guide](channels/feishu/READM ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] @@ -507,9 +521,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "irc": { "enabled": true, + "type": "irc", "server": "irc.libera.chat:6697", "tls": true, "nick": "picoclaw-bot", @@ -547,9 +562,10 @@ Install and run a OneBot v11 compatible QQ bot framework. Enable its WebSocket s ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://127.0.0.1:8080", "access_token": "", "allow_from": [] diff --git a/docs/my/chat-apps.md b/docs/guides/chat-apps.ms.md similarity index 90% rename from docs/my/chat-apps.md rename to docs/guides/chat-apps.ms.md index 35a35a7cc..6bfa7565e 100644 --- a/docs/my/chat-apps.md +++ b/docs/guides/chat-apps.ms.md @@ -1,6 +1,6 @@ # 💬 Konfigurasi Aplikasi Sembang -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## 💬 Aplikasi Sembang @@ -38,9 +38,10 @@ Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, Di ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], "use_markdown_v2": false, @@ -59,11 +60,19 @@ 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`) supaya menu arahan dan tingkah laku runtime sentiasa selari. +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. @@ -91,9 +100,10 @@ Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemform ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -114,7 +124,7 @@ Secara lalai bot membalas semua mesej dalam saluran pelayan. Untuk mengehadkan b ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "mention_only": true } } @@ -126,7 +136,7 @@ Anda juga boleh mencetuskan dengan awalan kata kunci (contohnya `!bot`): ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } @@ -154,9 +164,10 @@ PicoClaw boleh menyambung ke WhatsApp dalam dua cara: ```json { - "channels": { + "channel_list": { "whatsapp": { "enabled": true, + "type": "whatsapp", "use_native": true, "session_store_path": "", "allow_from": [] @@ -181,9 +192,10 @@ Jika `session_store_path` kosong, sesi akan disimpan dalam `/whatsapp ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -215,9 +227,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] @@ -247,9 +260,10 @@ picoclaw gateway ```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", @@ -265,7 +279,7 @@ picoclaw gateway picoclaw gateway ``` -Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](docs/channels/matrix/README.md). +Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](../channels/matrix/README.md). @@ -282,9 +296,10 @@ Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholde ```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", @@ -326,7 +341,7 @@ PicoClaw menyokong tiga jenis integrasi WeCom: **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 AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) untuk arahan penyediaan terperinci. +Lihat [Panduan Konfigurasi WeCom](../channels/wecom/README.zh.md) untuk arahan penyediaan terperinci. **Quick Setup - WeCom Bot:** @@ -339,9 +354,10 @@ Lihat [Panduan Konfigurasi WeCom AI Bot](docs/channels/wecom/wecom_aibot/README. ```json { - "channels": { + "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", @@ -372,7 +388,7 @@ Lihat [Panduan Konfigurasi WeCom AI Bot](docs/channels/wecom/wecom_aibot/README. ```json { - "channels": { + "channel_list": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", @@ -407,7 +423,7 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", diff --git a/docs/pt-br/chat-apps.md b/docs/guides/chat-apps.pt-br.md similarity index 92% rename from docs/pt-br/chat-apps.md rename to docs/guides/chat-apps.pt-br.md index 92fda329c..6d4fbdc23 100644 --- a/docs/pt-br/chat-apps.md +++ b/docs/guides/chat-apps.pt-br.md @@ -1,6 +1,6 @@ # 💬 Configuração de Aplicativos de Chat -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## 💬 Aplicativos de Chat @@ -19,7 +19,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D | **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 | [Bot](../channels/wecom/wecom_bot/README.pt-br.md) / [App](../channels/wecom/wecom_app/README.pt-br.md) / [AI Bot](../channels/wecom/wecom_aibot/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) | @@ -40,9 +40,10 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -60,11 +61,19 @@ 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`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados. +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 + @@ -90,9 +99,10 @@ Se o registro de comandos falhar (erros transitórios de rede/API), o canal aind ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -113,7 +123,7 @@ Por padrão, o bot responde a todas as mensagens em um canal do servidor. Para r ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "mention_only": true } } @@ -125,7 +135,7 @@ Você também pode ativar por prefixos de palavras-chave (ex.: `!bot`): ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } @@ -154,9 +164,10 @@ O PicoClaw pode se conectar ao WhatsApp de duas formas: ```json { - "channels": { + "channel_list": { "whatsapp": { "enabled": true, + "type": "whatsapp", "use_native": true, "session_store_path": "", "allow_from": [] @@ -188,9 +199,10 @@ Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-s (Opcional) Adicione seu ID de usuário WeChat em `allow_from` para restringir quem pode enviar mensagens ao bot: ```json { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -219,9 +231,10 @@ A QQ Open Platform oferece uma página de configuração com um clique para bots ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -261,9 +274,10 @@ Se preferir criar o bot manualmente: ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] @@ -290,9 +304,10 @@ Canal de integração projetado especificamente para hardware de câmera AI Sipe ```json { - "channels": { + "channel_list": { "maixcam": { - "enabled": true + "enabled": true, + "type": "maixcam" } } } @@ -318,9 +333,10 @@ picoclaw gateway ```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", @@ -354,9 +370,10 @@ Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeh ```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", @@ -399,7 +416,7 @@ O PicoClaw suporta três tipos de integração WeCom: **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 AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) para instruções detalhadas de configuração. +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:** @@ -412,9 +429,10 @@ Veja o [Guia de Configuração do WeCom AI Bot](../channels/wecom/wecom_aibot/RE ```json { - "channels": { + "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", @@ -445,7 +463,7 @@ Veja o [Guia de Configuração do WeCom AI Bot](../channels/wecom/wecom_aibot/RE ```json { - "channels": { + "channel_list": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", @@ -480,7 +498,7 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", @@ -520,9 +538,10 @@ O PicoClaw se conecta ao Feishu via modo WebSocket/SDK — não é necessário U ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -560,9 +579,10 @@ Para opções completas, veja o [Guia de Configuração do Canal Feishu](../chan ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] @@ -587,9 +607,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "irc": { "enabled": true, + "type": "irc", "server": "irc.libera.chat:6697", "tls": true, "nick": "picoclaw-bot", @@ -627,9 +648,10 @@ Instale e execute um framework de bot QQ compatível com OneBot v11. Habilite se ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://127.0.0.1:8080", "access_token": "", "allow_from": [] @@ -659,9 +681,10 @@ Canal de integração projetado especificamente para hardware de câmera AI Sipe ```json { - "channels": { + "channel_list": { "maixcam": { - "enabled": true + "enabled": true, + "type": "maixcam" } } } diff --git a/docs/vi/chat-apps.md b/docs/guides/chat-apps.vi.md similarity index 92% rename from docs/vi/chat-apps.md rename to docs/guides/chat-apps.vi.md index 5e2a81ccf..8d0b4ee32 100644 --- a/docs/vi/chat-apps.md +++ b/docs/guides/chat-apps.vi.md @@ -1,6 +1,6 @@ # 💬 Cấu Hình Ứng Dụng Chat -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## 💬 Ứng Dụng Chat @@ -19,7 +19,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix | **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 | [Bot](../channels/wecom/wecom_bot/README.vi.md) / [App](../channels/wecom/wecom_app/README.vi.md) / [AI Bot](../channels/wecom/wecom_aibot/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) | @@ -40,9 +40,10 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -60,11 +61,19 @@ 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`) để menu lệnh và hành vi runtime luôn đồng bộ. +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 + @@ -90,9 +99,10 @@ Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫ ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -113,7 +123,7 @@ Mặc định bot phản hồi tất cả tin nhắn trong kênh server. Để g ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "mention_only": true } } @@ -125,7 +135,7 @@ Bạn cũng có thể kích hoạt bằng tiền tố từ khóa (ví dụ: `!bo ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } @@ -154,9 +164,10 @@ PicoClaw có thể kết nối WhatsApp theo hai cách: ```json { - "channels": { + "channel_list": { "whatsapp": { "enabled": true, + "type": "whatsapp", "use_native": true, "session_store_path": "", "allow_from": [] @@ -188,9 +199,10 @@ Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sa (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 { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -219,9 +231,10 @@ QQ Open Platform cung cấp trang thiết lập một chạm cho bot tương th ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -261,9 +274,10 @@ Nếu bạn muốn tạo bot thủ công: ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] @@ -290,9 +304,10 @@ Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera A ```json { - "channels": { + "channel_list": { "maixcam": { - "enabled": true + "enabled": true, + "type": "maixcam" } } } @@ -318,9 +333,10 @@ picoclaw gateway ```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", @@ -354,9 +370,10 @@ picoclaw gateway ```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", @@ -399,7 +416,7 @@ PicoClaw hỗ trợ ba loại tích hợp WeCom: **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 AI Bot](../channels/wecom/wecom_aibot/README.vi.md) để biết hướng dẫn thiết lập chi tiết. +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:** @@ -412,9 +429,10 @@ Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](../channels/wecom/wecom_aibot/READ ```json { - "channels": { + "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", @@ -445,7 +463,7 @@ Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](../channels/wecom/wecom_aibot/READ ```json { - "channels": { + "channel_list": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", @@ -480,7 +498,7 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", @@ -521,9 +539,10 @@ PicoClaw kết nối với Feishu qua chế độ WebSocket/SDK — không cần ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -561,9 +580,10 @@ Mở Feishu, tìm tên bot của bạn và bắt đầu trò chuyện. Bạn cũ ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] @@ -588,9 +608,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "irc": { "enabled": true, + "type": "irc", "server": "irc.libera.chat:6697", "tls": true, "nick": "picoclaw-bot", @@ -628,9 +649,10 @@ Cài đặt và chạy framework bot QQ tương thích OneBot v11. Bật máy ch ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://127.0.0.1:8080", "access_token": "", "allow_from": [] @@ -660,9 +682,10 @@ Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera A ```json { - "channels": { + "channel_list": { "maixcam": { - "enabled": true + "enabled": true, + "type": "maixcam" } } } diff --git a/docs/zh/chat-apps.md b/docs/guides/chat-apps.zh.md similarity index 93% rename from docs/zh/chat-apps.md rename to docs/guides/chat-apps.zh.md index 47add38ac..b5891dc69 100644 --- a/docs/zh/chat-apps.md +++ b/docs/guides/chat-apps.zh.md @@ -1,6 +1,6 @@ # 💬 聊天应用配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## 💬 聊天应用集成 (Chat Apps) @@ -44,9 +44,10 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -64,7 +65,7 @@ picoclaw gateway **4. Telegram 命令菜单(启动时自动注册)** -PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 +PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 @@ -75,6 +76,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行 - `/use ` - `/use `,然后在下一条消息里发送真正的请求 - `/use clear` +- `/btw `,用于发起一个不改动当前会话历史的即时旁支提问;`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程 @@ -102,9 +104,10 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行 ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -125,7 +128,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行 ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "mention_only": true } } @@ -137,7 +140,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行 ```json { - "channels": { + "channel_list": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } @@ -166,9 +169,10 @@ PicoClaw 支持两种 WhatsApp 连接方式: ```json { - "channels": { + "channel_list": { "whatsapp": { "enabled": true, + "type": "whatsapp", "use_native": true, "session_store_path": "", "allow_from": [] @@ -200,9 +204,10 @@ picoclaw auth weixin (可选)在 `allow_from` 中填入你的微信用户 ID,限制可以与机器人对话的用户: ```json { - "channels": { + "channel_list": { "weixin": { "enabled": true, + "type": "weixin", "token": "YOUR_TOKEN", "allow_from": ["YOUR_USER_ID"] } @@ -230,9 +235,10 @@ picoclaw gateway ```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", @@ -266,9 +272,10 @@ QQ 开放平台提供了一键创建 OpenClaw 兼容机器人的页面: ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -309,9 +316,10 @@ QQ 开放平台提供了一键创建 OpenClaw 兼容机器人的页面: ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] @@ -336,9 +344,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "irc": { "enabled": true, + "type": "irc", "server": "irc.libera.chat:6697", "tls": true, "nick": "picoclaw-bot", @@ -376,9 +385,10 @@ Bot 将连接到 IRC 服务器并加入指定的频道。 ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] @@ -411,9 +421,10 @@ picoclaw gateway ```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", @@ -463,9 +474,10 @@ PicoClaw 通过 WebSocket/SDK 模式连接飞书 — 无需公网 Webhook URL ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "YOUR_APP_SECRET", "allow_from": [] @@ -511,9 +523,10 @@ picoclaw auth wecom ```json { - "channels": { + "channel_list": { "wecom": { "enabled": true, + "type": "wecom", "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "websocket_url": "wss://openws.work.weixin.qq.com", @@ -549,9 +562,10 @@ OneBot 是 QQ 机器人的开放协议。PicoClaw 通过 WebSocket 连接任何 ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://127.0.0.1:8080", "access_token": "", "allow_from": [] @@ -582,9 +596,10 @@ picoclaw gateway ```json { - "channels": { + "channel_list": { "maixcam": { - "enabled": true + "enabled": true, + "type": "maixcam" } } } diff --git a/docs/fr/configuration.md b/docs/guides/configuration.fr.md similarity index 86% rename from docs/fr/configuration.md rename to docs/guides/configuration.fr.md index 8d94620ba..786a0c28f 100644 --- a/docs/fr/configuration.md +++ b/docs/guides/configuration.fr.md @@ -1,6 +1,6 @@ # ⚙️ Guide de Configuration -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## ⚙️ Configuration @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent 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`) : @@ -64,10 +80,30 @@ Pour les configurations avancées/de test, vous pouvez remplacer la racine des c 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 les commandes prises en charge au démarrage. +- 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. @@ -303,7 +339,7 @@ Répond HEARTBEAT_OK Utilisateur reçoit le résultat | **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` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) | +| **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é) | @@ -318,24 +354,27 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "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** mais toujours supportée. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md). +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. @@ -357,7 +396,7 @@ Les tâches planifiées persistent après redémarrage dans `~/.picoclaw/workspa | Sujet | Description | | ----- | ----------- | -| [Système de Hooks](../hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation | -| [Steering](../steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | -| [SubTurn](../subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | -| [Gestion du Contexte](../agent-refactor/context.md) | Détection des limites de contexte, compression | +| [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/it/configuration.md b/docs/guides/configuration.it.md similarity index 100% rename from docs/it/configuration.md rename to docs/guides/configuration.it.md diff --git a/docs/ja/configuration.md b/docs/guides/configuration.ja.md similarity index 86% rename from docs/ja/configuration.md rename to docs/guides/configuration.ja.md index 35676809e..0234edbd7 100644 --- a/docs/ja/configuration.md +++ b/docs/guides/configuration.ja.md @@ -1,6 +1,6 @@ # ⚙️ 設定ガイド -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## ⚙️ 設定詳細 @@ -31,6 +31,22 @@ 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`)にデータを保存します: @@ -65,10 +81,30 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw 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 は起動時にサポートするコマンドメニューを自動登録します。 +- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時に `/start`、`/help`、`/show`、`/list`、`/use`、`/btw` などのサポート済みコマンドを自動登録します。 - 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。 - 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。 @@ -304,7 +340,7 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 | **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) | +| **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 | ローカル(キー不要) | @@ -319,24 +355,27 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "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` 設定は**非推奨**ですが後方互換性のためサポートされています。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。 +旧 `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 スタイルのスケジュールタスクをサポートします。 @@ -358,7 +397,7 @@ PicoClaw は `cron` ツールを通じて cron スタイルのスケジュール | トピック | 説明 | | -------- | ---- | -| [Hook システム](../hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | -| [Steering](../steering.md) | 実行中の Agent ループにメッセージを注入 | -| [SubTurn](../subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | -| [コンテキスト管理](../agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 | +| [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/configuration.md b/docs/guides/configuration.md similarity index 67% rename from docs/configuration.md rename to docs/guides/configuration.md index e0ad00367..ce0db46fe 100644 --- a/docs/configuration.md +++ b/docs/guides/configuration.md @@ -6,6 +6,8 @@ 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. @@ -38,12 +40,12 @@ PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gat ```json { "gateway": { - "log_level": "fatal" + "log_level": "warn" } } ``` -When omitted, the default is `fatal`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`. +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`. @@ -69,15 +71,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ### Web launcher dashboard -**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used). - -**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**. +**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`. -- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header. +- **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 **7 days** by default; sign in again with the token after it expires. +- **Session lifetime**: The HttpOnly session cookie lasts about **31 days** by default, but sessions are invalidated when the launcher process restarts. ### Skill Sources @@ -95,18 +98,24 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ### Using Skills From Chat Channels -Once skills are installed, you can inspect and force them directly from a chat channel: +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 ``` @@ -114,137 +123,93 @@ 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 at startup. +- 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. -### Agent Bindings (Route messages to specific agents) +### Session Isolation -Use `bindings` in `config.json` to route incoming messages to different agents by channel/account/context. +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": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model_name": "gpt-4o-mini" - }, "list": [ - { "id": "main", "default": true, "name": "Main Assistant" }, - { "id": "support", "name": "Support Assistant" }, - { "id": "sales", "name": "Sales Assistant" } - ] - }, - "bindings": [ - { - "agent_id": "support", - "match": { - "channel": "telegram", - "account_id": "*", - "peer": { "kind": "direct", "id": "user123" } - } - }, - { - "agent_id": "sales", - "match": { - "channel": "discord", - "account_id": "my-discord-bot", - "guild_id": "987654321" - } + { "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"] + } + ] } - ] -} -``` - -#### `bindings` fields - -| Field | Required | Description | -|-------|----------|-------------| -| `agent_id` | Yes | Target agent id in `agents.list` | -| `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) | -| `match.account_id` | No | Channel account filter. Use `"*"` for all accounts of that channel. If omitted, only default account is matched | -| `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) | -| `match.guild_id` | No | Guild/server-level match | -| `match.team_id` | No | Team/workspace-level match | - -#### Matching priority - -When multiple bindings exist, PicoClaw resolves in this order: - -1. `peer` -2. `parent_peer` (for thread/topic parent contexts) -3. `guild_id` -4. `team_id` -5. `account_id` (non-wildcard) -6. channel wildcard (`account_id: "*"`) -7. default agent - -If a binding points to a missing `agent_id`, PicoClaw falls back to the default agent. - -#### How matching works (step-by-step) - -1. PicoClaw first filters bindings by `match.channel` (must equal current channel). -2. It then filters by `match.account_id`: - - omitted: match only the channel's default account - - `"*"`: match all accounts on this channel - - explicit value: exact account id match (case-insensitive) -3. From the remaining candidates, it applies the priority chain above and stops at the first hit. - -In other words: **channel + account form the candidate set; peer/guild/team then decide final winner**. - -#### Common recipes - -**1) Route one specific DM user to a specialist agent** - -```json -{ - "agent_id": "support", - "match": { - "channel": "telegram", - "account_id": "*", - "peer": { "kind": "direct", "id": "user123" } + }, + "session": { + "dimensions": ["chat"] } } ``` -**2) Route one Discord server (guild) to a dedicated agent** +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. -```json -{ - "agent_id": "sales", - "match": { - "channel": "discord", - "account_id": "my-discord-bot", - "guild_id": "987654321" - } -} -``` - -**3) Route all remaining traffic of a channel to a fallback agent** - -```json -{ - "agent_id": "main", - "match": { - "channel": "discord", - "account_id": "*" - } -} -``` - -#### Authoring guidelines (important) - -- Keep exactly one clear default agent in `agents.list` (`"default": true`). -- Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: "*"` only) together safely; priority already guarantees specific rules win. -- Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins. -- Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default. - -#### Troubleshooting checklist - -- **Rule not taking effect?** Check `match.channel` spelling first (must be exact). -- **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id. -- **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths. -- **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled. +For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md). ### Agent Tool Allowlist @@ -366,6 +331,66 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous | `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 | @@ -540,14 +565,15 @@ The subagent has access to tools (message, web_search, etc.) and can communicate ### 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!** +> **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 +- **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) @@ -592,13 +618,15 @@ chmod 600 ~/.picoclaw/.security.yml "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4" + "provider": "openai", + "model": "gpt-5.4" // api_key loaded from .security.yml } ], - "channels": { + "channel_list": { "telegram": { - "enabled": true" + "enabled": true, + "type": "telegram", // token loaded from .security.yml } } @@ -611,34 +639,35 @@ chmod 600 ~/.picoclaw/.security.yml - 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_configuration.md`](security_configuration.md). +For complete documentation, see [`../security/security_configuration.md`](../security/security_configuration.md). #### All Supported Vendors -| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| 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` | 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 (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 | — | +| **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 @@ -647,23 +676,27 @@ For complete documentation, see [`security_configuration.md`](security_configura "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] }, { "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": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -674,7 +707,16 @@ For complete documentation, see [`security_configuration.md`](security_configura } ``` -> **Security Note**: You can remove `api_key` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details. +> **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 @@ -686,7 +728,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4" + "provider": "openai", + "model": "gpt-5.4" // api_key: set in .security.yml } ``` @@ -699,7 +742,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest" + "provider": "volcengine", + "model": "ark-code-latest" // api_key: set in .security.yml } ``` @@ -712,7 +756,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7" + "provider": "zhipu", + "model": "glm-4.7" // api_key: set in .security.yml } ``` @@ -725,7 +770,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat" + "provider": "deepseek", + "model": "deepseek-chat" // api_key: set in .security.yml } ``` @@ -738,7 +784,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6" + "provider": "anthropic", + "model": "claude-sonnet-4.6" // api_key: set in .security.yml } ``` @@ -750,8 +797,9 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -766,25 +814,43 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "llama3", - "model": "ollama/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", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1" // api_key: set in .security.yml } ``` -PicoClaw strips only the outer `litellm/` prefix before sending the request, so `litellm/lite-gpt4` sends `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`. +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.
@@ -809,7 +875,8 @@ model_list: "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api.openai.com/v1" // api_keys loaded from .security.yml } @@ -824,15 +891,17 @@ model_list: "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -840,17 +909,18 @@ model_list: #### Migration from Legacy `providers` Config -The old `providers` configuration is **deprecated** but still supported for backward compatibility. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide. +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_key`). +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
Zhipu (legacy providers format) @@ -863,7 +933,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, - "max_tool_iterations": 20 + "max_tool_iterations": 20, + "max_parallel_turns": 1 } }, "providers": { @@ -876,6 +947,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m ``` > **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.
@@ -886,16 +959,17 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { "dm_scope": "per-channel-peer", "backlog_limit": 20 }, - "channels": { + "channel_list": { "telegram": { - "enabled": true" + "enabled": true, + "type": "telegram", // token: set in .security.yml "allow_from": ["123456789"] } @@ -940,9 +1014,9 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace | Topic | Description | | ----- | ----------- | -| [Security Configuration](security_configuration.md) | Store API keys and secrets in separate `.security.yml` file | -| [Sensitive Data Filtering](sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | -| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | -| [Steering](steering.md) | Inject messages into a running agent loop between tool calls | -| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle | -| [Context Management](agent-refactor/context.md) | Context boundary detection, proactive budget check, compression | +| [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/my/configuration.md b/docs/guides/configuration.ms.md similarity index 90% rename from docs/my/configuration.md rename to docs/guides/configuration.ms.md index f798bd9bd..bcd17afa8 100644 --- a/docs/my/configuration.md +++ b/docs/guides/configuration.ms.md @@ -1,6 +1,6 @@ # ⚙️ Panduan Konfigurasi -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## ⚙️ Konfigurasi @@ -63,10 +63,30 @@ Untuk setup lanjutan/ujian, anda boleh menindih root builtin skills dengan: 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. +- 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. diff --git a/docs/pt-br/configuration.md b/docs/guides/configuration.pt-br.md similarity index 86% rename from docs/pt-br/configuration.md rename to docs/guides/configuration.pt-br.md index ff3ce2b34..e5d904e29 100644 --- a/docs/pt-br/configuration.md +++ b/docs/guides/configuration.pt-br.md @@ -1,6 +1,6 @@ # ⚙️ Guia de Configuração -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## ⚙️ Configuração @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent 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`): @@ -65,10 +81,30 @@ Para configurações avançadas/de teste, você pode substituir o diretório rai 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 os comandos suportados na inicialização. +- 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. @@ -304,7 +340,7 @@ Responde HEARTBEAT_OK Usuário recebe resultado diretamente | **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` | OpenAI | [Obter](https://aistudio.google.com/api-keys) | +| **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) | @@ -319,24 +355,27 @@ Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará ro ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "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** mas ainda é suportada. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md). +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`. @@ -358,7 +397,7 @@ As tarefas agendadas persistem após reinicializações em `~/.picoclaw/workspac | Tópico | Descrição | | ------ | --------- | -| [Sistema de Hooks](../hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação | -| [Steering](../steering.md) | Injetar mensagens em um loop de agente em execução | -| [SubTurn](../subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | -| [Gerenciamento de Contexto](../agent-refactor/context.md) | Detecção de limites de contexto, compressã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/vi/configuration.md b/docs/guides/configuration.vi.md similarity index 87% rename from docs/vi/configuration.md rename to docs/guides/configuration.vi.md index fecadc6ff..d905b6d2b 100644 --- a/docs/vi/configuration.md +++ b/docs/guides/configuration.vi.md @@ -1,6 +1,6 @@ # ⚙️ Hướng Dẫn Cấu Hình -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## ⚙️ Cấu Hình @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent 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`): @@ -65,10 +81,30 @@ Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skil 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ý lệnh được hỗ trợ khi khởi động. +- 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. @@ -304,7 +340,7 @@ Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp | **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` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) | +| **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) | @@ -319,24 +355,27 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "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** nhưng vẫn được hỗ trợ. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md). +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`. @@ -358,7 +397,7 @@ Tác vụ đã lên lịch được lưu trữ bền vững sau khi khởi độ | Chủ đề | Mô tả | | ------ | ----- | -| [Hệ Thống Hook](../hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook | -| [Steering](../steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | -| [SubTurn](../subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | -| [Quản Lý Ngữ Cảnh](../agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén | +| [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/zh/configuration.md b/docs/guides/configuration.zh.md similarity index 65% rename from docs/zh/configuration.md rename to docs/guides/configuration.zh.md index 3b0ac9a50..c41c3dae0 100644 --- a/docs/zh/configuration.md +++ b/docs/guides/configuration.zh.md @@ -1,6 +1,6 @@ # ⚙️ 配置指南 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## ⚙️ 配置详解 @@ -31,6 +31,22 @@ 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`): @@ -53,15 +69,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ### Web 启动器控制台 -用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。 - -**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。 +用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 - **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。 -- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。 +- **密码存储**:支持的平台会把 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 默认约 **7 天**有效,到期需重新用口令登录。 +- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效。 ### 技能来源 (Skill Sources) @@ -85,12 +102,14 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills - `/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 ``` @@ -98,10 +117,90 @@ dammi le ultime news ### 统一命令执行策略 - 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 -- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。 +- 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 只能访问配置的工作区内的文件和执行命令。 @@ -326,7 +425,7 @@ Agent 读取 HEARTBEAT.md ### 模型配置 (model_list) -> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!** +> **新特性:** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。 这一设计同时支持**多 Agent**场景,灵活选择提供商: @@ -337,30 +436,31 @@ Agent 读取 HEARTBEAT.md #### 所有支持的厂商 -| 厂商 | `model` 前缀 | 默认 API Base | 协议 | API Key | +| 厂商 | `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` | 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 | 本地(无需 Key) | -| **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 | — | +| **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 | — | #### 基础配置 @@ -369,23 +469,27 @@ Agent 读取 HEARTBEAT.md "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] }, { "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": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -396,6 +500,13 @@ Agent 读取 HEARTBEAT.md } ``` +解析规则: + +- 推荐显式写成 `"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。 + #### 各厂商配置示例
@@ -404,8 +515,9 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-..." + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-..."] } ``` @@ -417,8 +529,9 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-..."] } ``` @@ -430,8 +543,9 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] } ``` @@ -443,8 +557,9 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-..."] } ``` @@ -456,8 +571,9 @@ Agent 读取 HEARTBEAT.md ```json { "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"] } ``` @@ -468,8 +584,9 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -484,25 +601,43 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "llama3", - "model": "ollama/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", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` -PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litellm/lite-gpt4` 发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 发送 `openai/gpt-4o`。 +显式设置 `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` 时也会得到相同结果。
@@ -515,15 +650,17 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -531,17 +668,18 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell #### 从旧版 `providers` 配置迁移 -旧版 `providers` 配置**已废弃**,但仍向后兼容。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。 +旧版 `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_key`。 +这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。
智谱(旧版 providers 格式) @@ -575,7 +713,7 @@ PicoClaw 按协议族路由提供商: { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { @@ -590,9 +728,10 @@ PicoClaw 按协议族路由提供商: "api_key": "gsk_xxx" } }, - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "123456:ABC...", "allow_from": ["123456789"] } @@ -614,6 +753,42 @@ PicoClaw 按协议族路由提供商:
+### 事件日志 + +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 可以设置、列出和取消在指定时间触发的提醒或周期性任务。 @@ -635,8 +810,9 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设 | 主题 | 说明 | | ---- | ---- | -| [敏感数据过滤](../sensitive_data_filtering.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | -| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | -| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 | -| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | -| [上下文管理](../agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 | +| [敏感数据过滤](../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/fr/docker.md b/docs/guides/docker.fr.md similarity index 91% rename from docs/fr/docker.md rename to docs/guides/docker.fr.md index 432edb1b2..e174298ac 100644 --- a/docs/fr/docker.md +++ b/docs/guides/docker.fr.md @@ -1,6 +1,6 @@ # 🐳 Docker et Démarrage Rapide -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## 🐳 Docker Compose @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Mode Launcher (Console Web) -L'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat. +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 @@ -45,7 +45,7 @@ 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 ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public. +> 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) @@ -92,19 +92,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "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_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/ja/docker.md b/docs/guides/docker.ja.md similarity index 88% rename from docs/ja/docker.md rename to docs/guides/docker.ja.md index 31ed17ec5..19199aaac 100644 --- a/docs/ja/docker.md +++ b/docs/guides/docker.ja.md @@ -1,6 +1,6 @@ # 🐳 Docker とクイックスタート -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## 🐳 Docker Compose @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher モード (Web コンソール) -`launcher` イメージには 3 つのバイナリ(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 +`launcher` イメージには 2 つのバイナリ(`picoclaw`、`picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 > [!WARNING] -> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。 +> Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットには公開しないでください。 ### Agent モード (ワンショット) @@ -94,19 +94,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "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_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { @@ -143,7 +143,7 @@ picoclaw onboard } ``` -> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.md#モデル設定-model_list)を参照してください。 +> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.ja.md#モデル設定-model_list)を参照してください。 > `request_timeout` はオプションで、単位は秒です。省略または `<= 0` に設定した場合、PicoClaw はデフォルトのタイムアウト(120 秒)を使用します。 **3. API Key の取得** diff --git a/docs/docker.md b/docs/guides/docker.md similarity index 85% rename from docs/docker.md rename to docs/guides/docker.md index 18de1ee83..e2e472fbf 100644 --- a/docs/docker.md +++ b/docs/guides/docker.md @@ -27,7 +27,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d > **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/token` and a `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. +> 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 @@ -39,7 +39,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher Mode (Web Console) -The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. +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 @@ -48,7 +48,7 @@ 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 uses a dashboard token (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **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. +> 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) @@ -94,20 +94,23 @@ picoclaw onboard "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "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", - "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/my/docker.md b/docs/guides/docker.ms.md similarity index 91% rename from docs/my/docker.md rename to docs/guides/docker.ms.md index 8fe1aed8c..5a426cb99 100644 --- a/docs/my/docker.md +++ b/docs/guides/docker.ms.md @@ -1,6 +1,6 @@ # 🐳 Panduan Docker & Quick Start -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## 🐳 Docker Compose @@ -35,7 +35,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Mod Launcher (Konsol Web) -Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang. +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 @@ -44,7 +44,7 @@ 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 belum menyokong autentikasi. Elakkan mendedahkannya ke internet awam. +> Konsol web dilindungi oleh kata laluan log masuk dashboard. Jangan dedahkannya kepada rangkaian tidak dipercayai atau internet awam. ### Mod Agent (One-shot) @@ -91,19 +91,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "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_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/pt-br/docker.md b/docs/guides/docker.pt-br.md similarity index 91% rename from docs/pt-br/docker.md rename to docs/guides/docker.pt-br.md index bac48954b..ab71af8e6 100644 --- a/docs/pt-br/docker.md +++ b/docs/guides/docker.pt-br.md @@ -1,6 +1,6 @@ # 🐳 Docker e Início Rápido -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## 🐳 Docker Compose @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Modo Launcher (Console Web) -A imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat. +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 @@ -45,7 +45,7 @@ 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 ainda não suporta autenticação. Evite expô-lo na internet pública. +> 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) @@ -92,19 +92,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "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_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/vi/docker.md b/docs/guides/docker.vi.md similarity index 91% rename from docs/vi/docker.md rename to docs/guides/docker.vi.md index eddc20a75..e91450bb0 100644 --- a/docs/vi/docker.md +++ b/docs/guides/docker.vi.md @@ -1,6 +1,6 @@ # 🐳 Docker và Bắt Đầu Nhanh -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## 🐳 Docker Compose @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Chế Độ Launcher (Web Console) -Image `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat. +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 @@ -45,7 +45,7 @@ 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 chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng. +> 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) @@ -92,19 +92,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "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_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/zh/docker.md b/docs/guides/docker.zh.md similarity index 85% rename from docs/zh/docker.md rename to docs/guides/docker.zh.md index 8aed1e86b..855375d9c 100644 --- a/docs/zh/docker.md +++ b/docs/guides/docker.zh.md @@ -1,6 +1,6 @@ # 🐳 Docker 与快速开始 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## 🐳 Docker Compose @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher 模式 (Web 控制台) -`launcher` 镜像包含所有三个二进制文件(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 +`launcher` 镜像包含两个二进制文件(`picoclaw`、`picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d 在浏览器中打开 。Launcher 会自动管理 Gateway 进程。 > [!WARNING] -> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 +> Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 ### Agent 模式 (一次性运行) @@ -93,20 +93,23 @@ picoclaw onboard "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "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", - "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] } ], "tools": { @@ -143,7 +146,7 @@ picoclaw onboard } ``` -> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.md#模型配置-model_list)章节。 +> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.zh.md#模型配置-model_list)章节。 > `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 **3. 获取 API Key** diff --git a/docs/fr/hardware-compatibility.md b/docs/guides/hardware-compatibility.fr.md similarity index 98% rename from docs/fr/hardware-compatibility.md rename to docs/guides/hardware-compatibility.fr.md index c1f397e80..bb2d92d57 100644 --- a/docs/fr/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # 🖥️ PicoClaw Liste de compatibilité matérielle @@ -99,7 +99,7 @@ Produits grand public, routeurs et appareils industriels testés avec PicoClaw. 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](../../README.fr.md#-run-on-old-android-phones) pour les instructions de configuration. +> 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 diff --git a/docs/ja/hardware-compatibility.md b/docs/guides/hardware-compatibility.ja.md similarity index 98% rename from docs/ja/hardware-compatibility.md rename to docs/guides/hardware-compatibility.ja.md index 96ccd1cd1..c86684f84 100644 --- a/docs/ja/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # 🖥️ PicoClaw ハードウェア互換性リスト @@ -99,7 +99,7 @@ PicoClaw でテスト済みのコンシューマー製品、ルーター、産 1GB 以上の RAM を搭載した ARM64 Android スマートフォン(2015年以降)。[Termux](https://github.com/termux/termux-app) をインストールし、`proot` を使用して PicoClaw を実行します。 -> セットアップ手順は [README:古い Android スマートフォンで実行](../../README.ja.md#-run-on-old-android-phones) を参照してください。 +> セットアップ手順は [README:古い Android スマートフォンで実行](../project/README.ja.md#-run-on-old-android-phones) を参照してください。 ### デスクトップ / サーバー / クラウド diff --git a/docs/hardware-compatibility.md b/docs/guides/hardware-compatibility.md similarity index 98% rename from docs/hardware-compatibility.md rename to docs/guides/hardware-compatibility.md index c11849822..a07bb5116 100644 --- a/docs/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.md @@ -97,7 +97,7 @@ Consumer products, routers, and industrial devices that have been tested with Pi 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. +> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions. ### Desktop / Server / Cloud diff --git a/docs/pt-br/hardware-compatibility.md b/docs/guides/hardware-compatibility.pt-br.md similarity index 97% rename from docs/pt-br/hardware-compatibility.md rename to docs/guides/hardware-compatibility.pt-br.md index 771621014..1fc8ee25e 100644 --- a/docs/pt-br/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # 🖥️ PicoClaw Lista de compatibilidade de hardware @@ -99,7 +99,7 @@ Produtos de consumo, roteadores e dispositivos industriais testados com o PicoCl 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](../../README.pt-br.md#-run-on-old-android-phones) para instruções de configuração. +> 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 diff --git a/docs/vi/hardware-compatibility.md b/docs/guides/hardware-compatibility.vi.md similarity index 97% rename from docs/vi/hardware-compatibility.md rename to docs/guides/hardware-compatibility.vi.md index 8315c049e..5566a4248 100644 --- a/docs/vi/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # 🖥️ PicoClaw Danh sách tương thích phần cứng @@ -99,7 +99,7 @@ Sản phẩm tiêu dùng, router và thiết bị công nghiệp đã được k 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ũ](../../README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt. +> 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 diff --git a/docs/zh/hardware-compatibility.md b/docs/guides/hardware-compatibility.zh.md similarity index 97% rename from docs/zh/hardware-compatibility.md rename to docs/guides/hardware-compatibility.zh.md index 66bd08072..d563f3ebe 100644 --- a/docs/zh/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 🖥️ PicoClaw 硬件兼容性列表 @@ -99,7 +99,7 @@ PicoClaw 几乎可以在任何 Linux 设备上运行。本页面记录了已验 任何 ARM64 Android 手机(2015 年以后),1GB 以上内存。安装 [Termux](https://github.com/termux/termux-app),使用 `proot` 运行 PicoClaw。 -> 参见 [README:在旧 Android 手机上运行](../../README.zh.md#-run-on-old-android-phones) 获取设置说明。 +> 参见 [README:在旧 Android 手机上运行](../project/README.zh.md#-run-on-old-android-phones) 获取设置说明。 ### 桌面 / 服务器 / 云 diff --git a/docs/fr/providers.md b/docs/guides/providers.fr.md similarity index 84% rename from docs/fr/providers.md rename to docs/guides/providers.fr.md index 39f5cf36a..aff600351 100644 --- a/docs/fr/providers.md +++ b/docs/guides/providers.fr.md @@ -1,6 +1,6 @@ # 🔌 Fournisseurs et Configuration des Modèles -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ### Fournisseurs @@ -46,7 +46,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | **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) | +| **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) | @@ -73,22 +73,22 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -99,6 +99,24 @@ Cette conception permet également le **support multi-agents** avec une sélecti } ``` +#### 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** @@ -107,7 +125,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +135,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +145,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -137,7 +155,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +165,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +179,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +207,8 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` @@ -201,7 +220,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +237,13 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +251,7 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu #### Migration depuis l'Ancienne Configuration `providers` -L'ancienne configuration `providers` est **dépréciée** mais toujours prise en charge pour la compatibilité ascendante. +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) :** @@ -257,11 +276,12 @@ L'ancienne configuration `providers` est **dépréciée** mais toujours prise en ```json { + "version": 3, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { @@ -279,10 +299,11 @@ Pour un guide de migration détaillé, voir [migration/model-list-migration.md]( 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_key`). +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 @@ -342,19 +363,22 @@ picoclaw agent -m "Hello" "api_key": "gsk_xxx" } }, - "channels": { + "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": "", @@ -362,6 +386,7 @@ picoclaw agent -m "Hello" }, "feishu": { "enabled": false, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", @@ -370,6 +395,7 @@ picoclaw agent -m "Hello" }, "qq": { "enabled": false, + "type": "qq", "app_id": "", "app_secret": "", "allow_from": [] @@ -429,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/ja/providers.md b/docs/guides/providers.ja.md similarity index 83% rename from docs/ja/providers.md rename to docs/guides/providers.ja.md index 9a53a4b69..fecc74519 100644 --- a/docs/ja/providers.md +++ b/docs/guides/providers.ja.md @@ -1,6 +1,6 @@ # 🔌 プロバイダーとモデル設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ### プロバイダー @@ -27,6 +27,7 @@ | `longcat` | LLM (Longcat 直接接続) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope 直接接続) | [modelscope.cn](https://modelscope.cn) | + ### モデル設定 (model_list) > **新機能!** PicoClaw は**モデル中心**の設定方式を採用しました。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで新しい provider を追加できます——**コード変更は一切不要です!** @@ -46,7 +47,7 @@ | **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) | +| **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) | @@ -73,22 +74,22 @@ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -99,6 +100,24 @@ } ``` +#### `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** @@ -107,7 +126,7 @@ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +136,7 @@ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +146,18 @@ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] } ``` @@ -137,7 +167,7 @@ { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +177,7 @@ { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +191,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +219,8 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` @@ -201,7 +232,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +249,13 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +263,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック #### レガシー `providers` 設定からの移行 -旧 `providers` 設定形式は**非推奨**ですが、後方互換性のためまだサポートされています。 +旧 `providers` 設定形式は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。 **旧設定(非推奨):** @@ -257,11 +288,12 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック ```json { + "version": 3, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { @@ -279,10 +311,11 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック 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_key`)のみで実現しています。 +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現しています。
Zhipu 設定例 @@ -342,19 +375,22 @@ picoclaw agent -m "こんにちは" "api_key": "gsk_xxx" } }, - "channels": { + "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": "", @@ -362,6 +398,7 @@ picoclaw agent -m "こんにちは" }, "feishu": { "enabled": false, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", @@ -370,6 +407,7 @@ picoclaw agent -m "こんにちは" }, "qq": { "enabled": false, + "type": "qq", "app_id": "", "app_secret": "", "allow_from": [] @@ -429,5 +467,5 @@ picoclaw agent -m "こんにちは" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/providers.md b/docs/guides/providers.md similarity index 53% rename from docs/providers.md rename to docs/guides/providers.md index c6d442a3b..7b078373d 100644 --- a/docs/providers.md +++ b/docs/guides/providers.md @@ -16,6 +16,7 @@ | `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) | @@ -32,7 +33,9 @@ ### 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!** +> **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: @@ -43,33 +46,35 @@ This design also enables **multi-agent support** with flexible provider selectio #### 📋 All Supported Vendors -| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| 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) | -| **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` | 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 (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 | - | +| **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 @@ -78,23 +83,27 @@ This design also enables **multi-agent support** with flexible provider selectio "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] }, { "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": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -105,6 +114,66 @@ This design also enables **multi-agent support** with flexible provider selectio } ``` +#### `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. @@ -116,8 +185,9 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to "model_list": [ { "model_name": "voice-gemini", - "model": "gemini/gemini-2.5-flash", - "api_key": "your-gemini-key" + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["your-gemini-key"] } ], "voice": { @@ -139,8 +209,9 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-..." + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-..."] } ``` @@ -149,8 +220,9 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-..."] } ``` @@ -159,8 +231,9 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] } ``` @@ -169,8 +242,9 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "glm-4.7", - "model": "openai/glm-4.7", - "api_key": "your-z.ai-key" + "provider": "openai", + "model": "glm-4.7", + "api_keys": ["your-z.ai-key"], "api_base": "https://api.z.ai/api/coding/paas/v4" } ``` @@ -180,8 +254,9 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-..."] } ``` @@ -190,8 +265,9 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "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"] } ``` @@ -204,8 +280,9 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -222,18 +299,34 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "llama3", - "model": "ollama/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", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` @@ -243,13 +336,14 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "lite-gpt4", - "model": "litellm/lite-gpt4", + "provider": "litellm", + "model": "lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["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`. +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** @@ -258,8 +352,9 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns ```json { "model_name": "glm-4.7", - "model": "openai/glm-4.7", - "api_key": "your-zhipu-api-key", + "provider": "openai", + "model": "glm-4.7", + "api_keys": ["your-zhipu-api-key"], "api_base": "https://api.z.ai/api/coding/paas/v4" } ``` @@ -275,15 +370,17 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -300,27 +397,28 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a "model_list": [ { "model_name": "qwen-main", - "model": "openai/qwen3.5:cloud", + "provider": "openai", + "model": "qwen3.5:cloud", "api_base": "https://api.example.com/v1", - "api_key": "sk-main" + "api_keys": ["sk-main"] }, { "model_name": "deepseek-backup", - "model": "deepseek/deepseek-chat", - "api_key": "sk-backup-1" + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-backup-1"] }, { "model_name": "gemini-backup", - "model": "gemini/gemini-2.5-flash", - "api_key": "sk-backup-2" + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["sk-backup-2"] } ], "agents": { "defaults": { - "model": { - "primary": "qwen-main", - "fallbacks": ["deepseek-backup", "gemini-backup"] - } + "model_name": "qwen-main", + "model_fallbacks": ["deepseek-backup", "gemini-backup"] } } } @@ -330,7 +428,7 @@ If you use key-level failover for the same model, PicoClaw can chain through add #### Migration from Legacy `providers` Config -The old `providers` configuration is **deprecated** but still supported for backward compatibility. +The old `providers` configuration is **deprecated** and has been removed in V2. Existing V0/V1 configs are auto-migrated. **Old Config (deprecated):** @@ -355,11 +453,13 @@ The old `providers` configuration is **deprecated** but still supported for back ```json { + "version": 3, "model_list": [ { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] } ], "agents": { @@ -370,17 +470,18 @@ The old `providers` configuration is **deprecated** but still supported for back } ``` -For detailed migration guide, see [migration/model-list-migration.md](migration/model-list-migration.md). +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_key`). +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
Zhipu @@ -426,7 +527,7 @@ picoclaw agent -m "Hello" { "agents": { "defaults": { - "model_name": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { @@ -444,19 +545,22 @@ picoclaw agent -m "Hello" "model_name": "voice-gemini", "echo_transcription": false }, - "channels": { + "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": "", @@ -464,6 +568,7 @@ picoclaw agent -m "Hello" }, "feishu": { "enabled": false, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", @@ -472,6 +577,7 @@ picoclaw agent -m "Hello" }, "qq": { "enabled": false, + "type": "qq", "app_id": "", "app_secret": "", "allow_from": [] @@ -531,5 +637,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/pt-br/providers.md b/docs/guides/providers.pt-br.md similarity index 85% rename from docs/pt-br/providers.md rename to docs/guides/providers.pt-br.md index 0f7a4b5a1..0d45dc309 100644 --- a/docs/pt-br/providers.md +++ b/docs/guides/providers.pt-br.md @@ -1,6 +1,6 @@ # 🔌 Provedores e Configuração de Modelos -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ### Provedores @@ -46,7 +46,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | **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) | +| **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) | @@ -73,22 +73,22 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -99,6 +99,24 @@ Este design também permite **suporte multi-agente** com seleção flexível de } ``` +#### 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** @@ -107,7 +125,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +135,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +145,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -137,7 +155,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +165,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +179,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +207,8 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` @@ -201,7 +220,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +237,13 @@ Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +251,7 @@ Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará #### Migração da Configuração Legacy `providers` -A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade retroativa. +A configuração antiga `providers` está **descontinuada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. **Configuração Antiga (descontinuada):** @@ -257,11 +276,12 @@ A configuração antiga `providers` está **descontinuada** mas ainda é suporta ```json { + "version": 3, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { @@ -279,10 +299,11 @@ Para guia de migração detalhado, veja [migration/model-list-migration.md](../m 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_key`). +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 @@ -342,19 +363,22 @@ picoclaw agent -m "Hello" "api_key": "gsk_xxx" } }, - "channels": { + "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": "", @@ -362,6 +386,7 @@ picoclaw agent -m "Hello" }, "feishu": { "enabled": false, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", @@ -370,6 +395,7 @@ picoclaw agent -m "Hello" }, "qq": { "enabled": false, + "type": "qq", "app_id": "", "app_secret": "", "allow_from": [] @@ -429,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/vi/providers.md b/docs/guides/providers.vi.md similarity index 84% rename from docs/vi/providers.md rename to docs/guides/providers.vi.md index 09b51c56b..c354461cf 100644 --- a/docs/vi/providers.md +++ b/docs/guides/providers.vi.md @@ -1,6 +1,6 @@ # 🔌 Nhà Cung Cấp và Cấu Hình Mô Hình -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ### Nhà Cung Cấp @@ -46,7 +46,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | **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) | +| **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) | @@ -73,22 +73,22 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -99,6 +99,24 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr } ``` +#### 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** @@ -107,7 +125,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +135,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +145,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -137,7 +155,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +165,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +179,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +207,8 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` @@ -201,7 +220,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +237,13 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +251,7 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ #### Di Chuyển Từ Cấu Hình Legacy `providers` -Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được hỗ trợ để tương thích ngượ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. **Cấu hình cũ (ngừng hỗ trợ):** @@ -257,11 +276,12 @@ Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được h ```json { + "version": 3, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { @@ -279,10 +299,11 @@ Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được h 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_key`). +Đ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 @@ -342,19 +363,22 @@ picoclaw agent -m "Hello" "api_key": "gsk_xxx" } }, - "channels": { + "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": "", @@ -362,6 +386,7 @@ picoclaw agent -m "Hello" }, "feishu": { "enabled": false, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", @@ -370,6 +395,7 @@ picoclaw agent -m "Hello" }, "qq": { "enabled": false, + "type": "qq", "app_id": "", "app_secret": "", "allow_from": [] @@ -429,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/zh/providers.md b/docs/guides/providers.zh.md similarity index 62% rename from docs/zh/providers.md rename to docs/guides/providers.zh.md index 4bcf7087e..4bab65f6b 100644 --- a/docs/zh/providers.md +++ b/docs/guides/providers.zh.md @@ -1,6 +1,6 @@ # 🔌 提供商与模型配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ### 提供商 (Providers) @@ -15,6 +15,7 @@ | `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) | @@ -28,9 +29,12 @@ | `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) | | `mimo` | LLM (小米 MiMo 直连) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | + ### 模型配置 (model_list) -> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** +> **新功能!** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。 + +如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。 该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: @@ -41,31 +45,33 @@ #### 📋 所有支持的厂商 -| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | +| 厂商 | `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` | 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) | -| **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 | - | +| **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 | - | #### 基础配置示例 @@ -74,23 +80,27 @@ "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] }, { "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": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -101,6 +111,42 @@ } ``` +#### `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。 @@ -112,8 +158,9 @@ "model_list": [ { "model_name": "voice-gemini", - "model": "gemini/gemini-2.5-flash", - "api_key": "your-gemini-key" + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["your-gemini-key"] } ], "voice": { @@ -135,8 +182,9 @@ ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-..." + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-..."] } ``` @@ -145,8 +193,9 @@ ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-..."] } ``` @@ -155,8 +204,9 @@ ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] } ``` @@ -165,8 +215,9 @@ ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-..."] } ``` @@ -175,7 +226,8 @@ ```json { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "auth_method": "oauth" } ``` @@ -189,8 +241,9 @@ ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -207,18 +260,34 @@ ```json { "model_name": "llama3", - "model": "ollama/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", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` @@ -228,13 +297,14 @@ ```json { "model_name": "lite-gpt4", - "model": "litellm/lite-gpt4", + "provider": "litellm", + "model": "lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` -PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/lite-gpt4` 会发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 会发送 `openai/gpt-4o`。 +显式设置 `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` 时也会得到相同结果。 #### 负载均衡 @@ -245,15 +315,17 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -270,27 +342,28 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l "model_list": [ { "model_name": "qwen-main", - "model": "openai/qwen3.5:cloud", + "provider": "openai", + "model": "qwen3.5:cloud", "api_base": "https://api.example.com/v1", - "api_key": "sk-main" + "api_keys": ["sk-main"] }, { "model_name": "deepseek-backup", - "model": "deepseek/deepseek-chat", - "api_key": "sk-backup-1" + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-backup-1"] }, { "model_name": "gemini-backup", - "model": "gemini/gemini-2.5-flash", - "api_key": "sk-backup-2" + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["sk-backup-2"] } ], "agents": { "defaults": { - "model": { - "primary": "qwen-main", - "fallbacks": ["deepseek-backup", "gemini-backup"] - } + "model_name": "qwen-main", + "model_fallbacks": ["deepseek-backup", "gemini-backup"] } } } @@ -300,7 +373,7 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l #### 从旧的 `providers` 配置迁移 -旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 +旧的 `providers` 配置格式**已弃用**,V2 中已移除。现有 V0/V1 配置会自动迁移。 **旧配置(已弃用):** @@ -325,11 +398,13 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l ```json { + "version": 3, "model_list": [ { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] } ], "agents": { @@ -347,10 +422,11 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l 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_key`)。 +这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。
智谱 (Zhipu) 配置示例 @@ -396,7 +472,7 @@ picoclaw agent -m "你好" { "agents": { "defaults": { - "model_name": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { @@ -414,19 +490,22 @@ picoclaw agent -m "你好" "model_name": "voice-gemini", "echo_transcription": false }, - "channels": { + "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": "", @@ -434,6 +513,7 @@ picoclaw agent -m "你好" }, "feishu": { "enabled": false, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", @@ -442,6 +522,7 @@ picoclaw agent -m "你好" }, "qq": { "enabled": false, + "type": "qq", "app_id": "", "app_secret": "", "allow_from": [] 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/fr/spawn-tasks.md b/docs/guides/spawn-tasks.fr.md similarity index 97% rename from docs/fr/spawn-tasks.md rename to docs/guides/spawn-tasks.fr.md index 5635cd645..40a7a3ded 100644 --- a/docs/fr/spawn-tasks.md +++ b/docs/guides/spawn-tasks.fr.md @@ -1,6 +1,6 @@ # 🔄 Tâches Asynchrones et Spawn -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## Tâches Rapides (réponse directe) diff --git a/docs/ja/spawn-tasks.md b/docs/guides/spawn-tasks.ja.md similarity index 98% rename from docs/ja/spawn-tasks.md rename to docs/guides/spawn-tasks.ja.md index a13aab9eb..598654242 100644 --- a/docs/ja/spawn-tasks.md +++ b/docs/guides/spawn-tasks.ja.md @@ -1,6 +1,6 @@ # 🔄 非同期タスクと Spawn -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ### Spawn を使用した非同期タスク diff --git a/docs/spawn-tasks.md b/docs/guides/spawn-tasks.md similarity index 100% rename from docs/spawn-tasks.md rename to docs/guides/spawn-tasks.md diff --git a/docs/my/spawn-tasks.md b/docs/guides/spawn-tasks.ms.md similarity index 97% rename from docs/my/spawn-tasks.md rename to docs/guides/spawn-tasks.ms.md index c0c3e8f92..055ebf20d 100644 --- a/docs/my/spawn-tasks.md +++ b/docs/guides/spawn-tasks.ms.md @@ -1,6 +1,6 @@ # 🔄 Spawn & Tugasan Async -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## Tugasan Cepat (balas terus) diff --git a/docs/pt-br/spawn-tasks.md b/docs/guides/spawn-tasks.pt-br.md similarity index 97% rename from docs/pt-br/spawn-tasks.md rename to docs/guides/spawn-tasks.pt-br.md index d6b539cb1..0de929821 100644 --- a/docs/pt-br/spawn-tasks.md +++ b/docs/guides/spawn-tasks.pt-br.md @@ -1,6 +1,6 @@ # 🔄 Tarefas Assíncronas e Spawn -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## Tarefas Rápidas (resposta direta) diff --git a/docs/vi/spawn-tasks.md b/docs/guides/spawn-tasks.vi.md similarity index 97% rename from docs/vi/spawn-tasks.md rename to docs/guides/spawn-tasks.vi.md index 78f728040..e8533750b 100644 --- a/docs/vi/spawn-tasks.md +++ b/docs/guides/spawn-tasks.vi.md @@ -1,6 +1,6 @@ # 🔄 Tác Vụ Bất Đồng Bộ và Spawn -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## Tác Vụ Nhanh (phản hồi trực tiếp) diff --git a/docs/zh/spawn-tasks.md b/docs/guides/spawn-tasks.zh.md similarity index 98% rename from docs/zh/spawn-tasks.md rename to docs/guides/spawn-tasks.zh.md index 781462af2..ee5f1580e 100644 --- a/docs/zh/spawn-tasks.md +++ b/docs/guides/spawn-tasks.zh.md @@ -1,6 +1,6 @@ # 🔄 异步任务与 Spawn -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 通过 `spawn` 工具支持**异步任务执行**。主要由 **Heartbeat(心跳)** 系统使用,在不阻塞主 Agent 循环的情况下运行耗时任务。 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 9d05ac599..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 @@ -50,22 +50,26 @@ The new `model_list` configuration offers several advantages: ```json { + "version": 3, "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.4", - "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": { @@ -76,42 +80,51 @@ The new `model_list` configuration offers several advantages: } ``` -## 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.4` | -| `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.4`) | +| `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.4", - "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.4", - "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.4", - "api_key": "sk-key3", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-key3"], "api_base": "https://api3.example.com/v1" } ] @@ -161,29 +206,31 @@ 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 +- [ ] 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 @@ -201,10 +248,10 @@ model "xxx" not found in model_list or providers ### 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/fr/debug.md b/docs/operations/debug.fr.md similarity index 97% rename from docs/fr/debug.md rename to docs/operations/debug.fr.md index 5753ccf8c..331f7c4ba 100644 --- a/docs/fr/debug.md +++ b/docs/operations/debug.fr.md @@ -1,6 +1,6 @@ # Débogage de PicoClaw -> Retour au [README](../../README.fr.md) +> 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. diff --git a/docs/ja/debug.md b/docs/operations/debug.ja.md similarity index 97% rename from docs/ja/debug.md rename to docs/operations/debug.ja.md index ecc52f454..5b3365bf8 100644 --- a/docs/ja/debug.md +++ b/docs/operations/debug.ja.md @@ -1,6 +1,6 @@ # PicoClaw のデバッグ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る PicoClaw は、受信するすべてのリクエストに対して、メッセージのルーティングや複雑度の評価、ツールの実行、モデル障害への適応など、多くの複雑な処理をバックグラウンドで実行しています。何が起きているかを正確に把握できることは、潜在的な問題のトラブルシューティングだけでなく、エージェントの動作を真に理解するためにも非常に重要です。 diff --git a/docs/debug.md b/docs/operations/debug.md similarity index 95% rename from docs/debug.md rename to docs/operations/debug.md index b9e776f0f..eacd72380 100644 --- a/docs/debug.md +++ b/docs/operations/debug.md @@ -65,7 +65,8 @@ Debug logs are server-side only. If you want the agent to send a visible notific "defaults": { "tool_feedback": { "enabled": true, - "max_args_length": 300 + "max_args_length": 300, + "separate_messages": true } } } @@ -85,6 +86,7 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo | 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 diff --git a/docs/my/debug.md b/docs/operations/debug.ms.md similarity index 100% rename from docs/my/debug.md rename to docs/operations/debug.ms.md diff --git a/docs/pt-br/debug.md b/docs/operations/debug.pt-br.md similarity index 97% rename from docs/pt-br/debug.md rename to docs/operations/debug.pt-br.md index 8614cd5ed..655385840 100644 --- a/docs/pt-br/debug.md +++ b/docs/operations/debug.pt-br.md @@ -1,6 +1,6 @@ # Depuração do PicoClaw -> Voltar ao [README](../../README.pt-br.md) +> 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. diff --git a/docs/vi/debug.md b/docs/operations/debug.vi.md similarity index 97% rename from docs/vi/debug.md rename to docs/operations/debug.vi.md index 69583d486..76d555648 100644 --- a/docs/vi/debug.md +++ b/docs/operations/debug.vi.md @@ -1,6 +1,6 @@ # Gỡ lỗi PicoClaw -> Quay lại [README](../../README.vi.md) +> 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. diff --git a/docs/zh/debug.md b/docs/operations/debug.zh.md similarity index 97% rename from docs/zh/debug.md rename to docs/operations/debug.zh.md index e7f20d777..8e544c03b 100644 --- a/docs/zh/debug.md +++ b/docs/operations/debug.zh.md @@ -1,6 +1,6 @@ # 调试 PicoClaw -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 在处理每一个请求时,都会在后台执行多个复杂的交互操作——从消息路由和复杂度评估,到工具执行和模型故障适配。能够准确地看到正在发生什么至关重要,这不仅有助于排查潜在问题,也有助于真正理解代理的运作方式。 diff --git a/docs/fr/troubleshooting.md b/docs/operations/troubleshooting.fr.md similarity index 97% rename from docs/fr/troubleshooting.md rename to docs/operations/troubleshooting.fr.md index d2d099ad3..630f69627 100644 --- a/docs/fr/troubleshooting.md +++ b/docs/operations/troubleshooting.fr.md @@ -1,6 +1,6 @@ # 🐛 Dépannage -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" diff --git a/docs/ja/troubleshooting.md b/docs/operations/troubleshooting.ja.md similarity index 97% rename from docs/ja/troubleshooting.md rename to docs/operations/troubleshooting.ja.md index f18b456db..f1d244c92 100644 --- a/docs/ja/troubleshooting.md +++ b/docs/operations/troubleshooting.ja.md @@ -1,6 +1,6 @@ # 🐛 トラブルシューティング -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## "model ... not found in model_list" または OpenRouter "free is not a valid model ID" 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/my/troubleshooting.md b/docs/operations/troubleshooting.ms.md similarity index 100% rename from docs/my/troubleshooting.md rename to docs/operations/troubleshooting.ms.md diff --git a/docs/pt-br/troubleshooting.md b/docs/operations/troubleshooting.pt-br.md similarity index 96% rename from docs/pt-br/troubleshooting.md rename to docs/operations/troubleshooting.pt-br.md index 286ad2ac8..eec64d9d8 100644 --- a/docs/pt-br/troubleshooting.md +++ b/docs/operations/troubleshooting.pt-br.md @@ -1,6 +1,6 @@ # 🐛 Solução de Problemas -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" diff --git a/docs/vi/troubleshooting.md b/docs/operations/troubleshooting.vi.md similarity index 97% rename from docs/vi/troubleshooting.md rename to docs/operations/troubleshooting.vi.md index 961c932aa..8aa5e2ae4 100644 --- a/docs/vi/troubleshooting.md +++ b/docs/operations/troubleshooting.vi.md @@ -1,6 +1,6 @@ # 🐛 Khắc Phục Sự Cố -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## "model ... not found in model_list" hoặc OpenRouter "free is not a valid model ID" 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/README.fr.md b/docs/project/README.fr.md similarity index 79% rename from README.fr.md rename to docs/project/README.fr.md index 4fd326102..b02067d2a 100644 --- a/README.fr.md +++ b/docs/project/README.fr.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

PicoClaw : Assistant IA Ultra-Efficace en Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](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)
@@ -35,12 +35,12 @@

- +

- +

@@ -57,6 +57,8 @@ ## 📢 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** ! @@ -70,7 +72,7 @@ 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-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. @@ -108,14 +110,14 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de | **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 +PicoClaw -> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.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 ! +> **[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 +PicoClaw Hardware Compatibility

## 🦾 Démonstration @@ -129,9 +131,9 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de

Recherche Web & Apprentissage

-

-

-

+

+

+

Développer · Déployer · Mettre à l'échelle @@ -165,19 +167,27 @@ Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page ### 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 pour plusieurs plateformes +# 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) @@ -213,7 +223,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Pour commencer :** @@ -267,7 +277,7 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha **Étape 1 :** Double-cliquez sur `picoclaw-launcher`. Un avertissement de sécurité s'affiche :

-Avertissement macOS Gatekeeper +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é.* @@ -275,36 +285,37 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha **É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 +macOS Confidentialité et sécurité — Ouvrir quand même

Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des lancements suivants.
-### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH) - -Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Pour commencer :** - -Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez ! - -Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io). - + ### 📱 Android Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. -**Option 1 : Termux (disponible maintenant)** +**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 : @@ -319,14 +330,7 @@ termux-chroot ./picoclaw onboard # chroot fournit une arborescence Linux stand Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration. -PicoClaw on Termux - -**Option 2 : Installation APK (bientôt disponible)** - -Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute ! - -
-Terminal Launcher (pour les environnements à ressources limitées) +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. @@ -433,7 +437,7 @@ PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Ut } ``` -Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md). +Pour les détails complets de configuration des providers, voir [Providers & Models](../guides/providers.fr.md).
@@ -443,26 +447,28 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie : | Channel | Configuration | Protocole | Docs | |---------|---------------|-----------|------| -| **Telegram** | Facile (token bot) | Long polling | [Guide](docs/channels/telegram/README.fr.md) | -| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](docs/channels/discord/README.fr.md) | -| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](docs/fr/chat-apps.md#whatsapp) | -| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](docs/fr/chat-apps.md#weixin) | -| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.fr.md) | -| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](docs/channels/slack/README.fr.md) | -| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.fr.md) | -| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](docs/channels/dingtalk/README.fr.md) | -| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.fr.md) | -| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](docs/channels/line/README.fr.md) | -| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](docs/channels/wecom/README.md) | -| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](docs/fr/chat-apps.md#irc) | -| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](docs/channels/onebot/README.fr.md) | -| **MaixCam** | Facile (activer) | Socket TCP | [Guide](docs/channels/maixcam/README.fr.md) | +| **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é. -Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md). +> 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 @@ -482,7 +488,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations ### ⚙️ 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](docs/fr/tools_configuration.md) pour les détails. +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 @@ -512,7 +518,7 @@ Ajoutez à votre `config.json` : } ``` -Pour plus de détails, voir [Configuration des outils - Skills](docs/fr/tools_configuration.md#skills-tool). +Pour plus de détails, voir [Configuration des outils - Skills](../reference/tools_configuration.fr.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -535,9 +541,9 @@ PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connect } ``` -Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](docs/fr/tools_configuration.md#mcp-tool). +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 +## 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. @@ -578,23 +584,23 @@ Pour des guides détaillés au-delà de ce README : | Sujet | Description | |-------|-------------| -| [Docker & Démarrage rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent | -| [Applications de chat](docs/fr/chat-apps.md) | Guides de configuration pour les 17+ channels | -| [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sandbox de sécurité | -| [Providers & Modèles](docs/fr/providers.md) | 30+ providers LLM, routage de modèles, configuration model_list | -| [Spawn & Tâches asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones | -| [Hooks](docs/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation | -| [Steering](docs/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | -| [SubTurn](docs/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | -| [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions | -| [Configuration des outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills | -| [Compatibilité matérielle](docs/fr/hardware-compatibility.md) | Cartes testées, exigences minimales | +| [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. +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 ! @@ -603,8 +609,4 @@ Groupes d'utilisateurs : Discord : WeChat : -WeChat group QR code - - - - +WeChat group QR code diff --git a/README.id.md b/docs/project/README.id.md similarity index 79% rename from README.id.md rename to docs/project/README.id.md index d88f5eb32..49c64e74c 100644 --- a/README.id.md +++ b/docs/project/README.id.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Asisten AI Super Ringan berbasis Go

@@ -14,11 +14,11 @@ 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) | [Malay](README.my.md) | [English](README.md) | **Bahasa Indonesia** +[中文](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)
@@ -34,12 +34,12 @@

- +

- +

@@ -56,6 +56,8 @@ ## 📢 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**! @@ -69,7 +71,7 @@ 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-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. @@ -106,14 +108,14 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O | **Waktu Boot**
(core 0,8GHz) | >500d | >30d | **<1d** | | **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**
**mulai $10** | -PicoClaw +PicoClaw -> **[Daftar Kompatibilitas Hardware](docs/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! +> **[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 +PicoClaw Hardware Compatibility

## 🦾 Demonstrasi @@ -127,9 +129,9 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O

Pencarian Web & Pembelajaran

-

-

-

+

+

+

Develop · Deploy · Scale @@ -162,19 +164,27 @@ Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://gi ### 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 untuk berbagai platform +# 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) @@ -210,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Memulai:** @@ -264,7 +274,7 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena **Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat peringatan keamanan:

-Peringatan macOS Gatekeeper +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.* @@ -272,36 +282,36 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena **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 +macOS Privasi & Keamanan — Tetap Buka

Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pada peluncuran berikutnya.
-### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH) - -TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Memulai:** - -Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat! - -Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. -**Opsi 1: Termux (tersedia sekarang)** +**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: @@ -316,14 +326,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Li Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi. -PicoClaw on Termux - -**Opsi 2: Instal APK (segera hadir)** - -APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus! - -
-Terminal Launcher (untuk lingkungan dengan sumber daya terbatas) +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. @@ -429,7 +432,7 @@ PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan fo } ``` -Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md). +Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](../guides/providers.md).
@@ -439,26 +442,28 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan: | Channel | Pengaturan | Protocol | Dokumentasi | |---------|------------|----------|-------------| -| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) | -| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) | -| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) | -| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) | -| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) | -| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) | -| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) | -| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) | -| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) | -| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) | -| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) | -| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) | -| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) | +| **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. -Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md). +> 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 @@ -478,7 +483,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t ### ⚙️ Tools Lainnya -PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail. +PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](../reference/tools_configuration.md) untuk detail. ## 🎯 Skills @@ -508,7 +513,7 @@ Tambahkan ke `config.json` Anda: } ``` -Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool). +Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -531,9 +536,9 @@ PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hub } ``` -Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool). +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 +## 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. @@ -574,23 +579,23 @@ Untuk panduan lengkap di luar README ini: | Topik | Deskripsi | |-------|-----------| -| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent | -| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel | -| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan | -| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list | -| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | -| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan | -| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup | -| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya | -| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills | -| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum | +| [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. +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! @@ -599,5 +604,4 @@ Grup Pengguna: Discord: WeChat: -Kode QR grup WeChat - +Kode QR grup WeChat diff --git a/README.it.md b/docs/project/README.it.md similarity index 75% rename from README.it.md rename to docs/project/README.it.md index 5874ca27a..0cf6cf8db 100644 --- a/README.it.md +++ b/docs/project/README.it.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Assistente IA Ultra-Efficiente in Go

@@ -14,11 +14,11 @@ 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** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](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)
@@ -34,12 +34,12 @@

- +

- +

@@ -56,6 +56,8 @@ ## 📢 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**! @@ -69,7 +71,7 @@ 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-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. @@ -106,14 +108,14 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. | **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 +PicoClaw -> **[Lista di Compatibilità Hardware](docs/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! +> **[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 +PicoClaw Hardware Compatibility

## 🦾 Dimostrazione @@ -127,9 +129,9 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR.

Ricerca Web & Apprendimento

-

-

-

+

+

+

Sviluppa · Distribuisci · Scala @@ -162,19 +164,27 @@ In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [Gi ### 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 per più piattaforme +# 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) @@ -210,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Per iniziare:** @@ -264,7 +274,7 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar **Passo 1:** Fai doppio clic su `picoclaw-launcher`. Verrà visualizzato un avviso di sicurezza:

-Avviso macOS Gatekeeper +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.* @@ -272,36 +282,36 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar **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 +macOS Privacy e sicurezza — Apri comunque

Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai lanci successivi.
-### 💻 TUI Launcher (Consigliato per Headless / SSH) - -Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Per iniziare:** - -Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare! - -Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. -**Opzione 1: Termux (disponibile ora)** +**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: @@ -316,14 +326,7 @@ termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del file Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione. -PicoClaw on Termux - -**Opzione 2: APK Install (prossimamente)** - -Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato! - -
-Terminal Launcher (per ambienti con risorse limitate) +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. @@ -429,7 +432,7 @@ PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa i } ``` -Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md). +Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](../guides/providers.md).
@@ -439,26 +442,28 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica: | Channel | Configurazione | Protocollo | Docs | |---------|----------------|------------|------| -| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) | -| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) | -| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) | -| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) | -| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) | -| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) | -| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) | -| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) | -| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) | -| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](docs/channels/wecom/README.md) | -| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) | -| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) | -| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) | +| **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. -Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md). +> 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 @@ -478,7 +483,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in ### ⚙️ Altri Strumenti -PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli. +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 @@ -508,7 +513,7 @@ Aggiungi al tuo `config.json`: } ``` -Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool). +Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -531,9 +536,22 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet } ``` -Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool). +Puoi gestire i casi MCP più comuni direttamente dalla CLI senza modificare a mano il JSON: -## ClawdChat Unisciti al Social Network degli Agent +```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. @@ -551,6 +569,11 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol | `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 | @@ -574,23 +597,24 @@ Per guide dettagliate oltre questo README: | Argomento | Descrizione | |-----------|-------------| -| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | -| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel | -| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza | -| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list | -| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | -| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione | -| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita | -| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni | -| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill | -| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi | +| [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. +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! @@ -599,4 +623,4 @@ Gruppi utenti: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.ja.md b/docs/project/README.ja.md similarity index 80% rename from README.ja.md rename to docs/project/README.ja.md index 2f17516af..6e3060688 100644 --- a/README.ja.md +++ b/docs/project/README.ja.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](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)
@@ -34,12 +34,12 @@

- +

- +

@@ -56,6 +56,8 @@ ## 📢 ニュース +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 ⭐** 達成! @@ -69,7 +71,7 @@ 2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。 -2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](ROADMAP.md)が正式に公開されました。 +2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](../../ROADMAP.md)が正式に公開されました。 2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。 @@ -106,14 +108,14 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな | **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | | **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード**
**最安 $10** | -PicoClaw +PicoClaw -> **[ハードウェア互換性リスト](docs/ja/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください! +> **[ハードウェア互換性リスト](../guides/hardware-compatibility.ja.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 デモンストレーション @@ -127,9 +129,9 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな

Web 検索&学習

-

-

-

+

+

+

開発 · デプロイ · スケール @@ -162,19 +164,27 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます! ### ソースからビルド(開発用) +前提条件: + +- 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) @@ -210,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**始め方:** @@ -264,7 +274,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d **ステップ 1:** `picoclaw-launcher` をダブルクリックすると、セキュリティ警告が表示されます:

-macOS Gatekeeper 警告 +macOS Gatekeeper 警告

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

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

この操作を一度行うと、以降の起動では警告が表示されなくなります。
-### 💻 TUI Launcher(ヘッドレス / SSH 向け推奨) - -TUI(Terminal UI)Launcher は設定と管理のためのフル機能ターミナルインターフェースを提供します。サーバー、Raspberry Pi、その他のヘッドレス環境に最適です。 - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**始め方:** - -TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット! - -TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。 - + ### 📱 Android 10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。 -**オプション 1: Termux(現在利用可能)** +**オプション 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. 以下のコマンドを実行: @@ -316,14 +327,7 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。 -PicoClaw on Termux - -**オプション 2: APK インストール(近日公開)** - -内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに! - -
-Terminal Launcher(リソース制約環境向け) +PicoClaw on Termux `picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。 @@ -429,7 +433,7 @@ PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポ } ``` -Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.md) を参照してください。 +Provider の完全な設定詳細は [Provider とモデル](../guides/providers.ja.md) を参照してください。
@@ -439,26 +443,28 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m | Channel | セットアップ | Protocol | ドキュメント | |---------|------------|----------|------------| -| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](docs/channels/telegram/README.ja.md) | -| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](docs/channels/discord/README.ja.md) | -| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](docs/ja/chat-apps.md#whatsapp) | -| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](docs/ja/chat-apps.md#weixin) | -| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](docs/channels/qq/README.ja.md) | -| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](docs/channels/slack/README.ja.md) | -| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](docs/channels/matrix/README.ja.md) | -| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](docs/channels/dingtalk/README.ja.md) | -| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](docs/channels/feishu/README.ja.md) | -| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](docs/channels/line/README.ja.md) | -| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](docs/channels/wecom/README.md) | -| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](docs/ja/chat-apps.md#irc) | -| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](docs/channels/onebot/README.ja.md) | -| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](docs/channels/maixcam/README.ja.md) | +| **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 サーバーを使用しません。 -Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。 +> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](../guides/configuration.ja.md#gateway-ログレベル)を参照してください。 + +Channel の詳細なセットアップ手順は [チャットアプリ設定](../guides/chat-apps.ja.md) を参照してください。 ## 🔧 ツール @@ -478,7 +484,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to ### ⚙️ その他のツール -PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](docs/ja/tools_configuration.md) を参照してください。 +PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](../reference/tools_configuration.ja.md) を参照してください。 ## 🎯 Skill @@ -508,7 +514,7 @@ picoclaw skills install } ``` -詳細は [ツール設定 - Skill](docs/ja/tools_configuration.md#skills-tool) を参照してください。 +詳細は [ツール設定 - Skill](../reference/tools_configuration.ja.md#skills-tool) を参照してください。 ## 🔗 MCP(Model Context Protocol) @@ -531,9 +537,9 @@ PicoClaw は [MCP](https://modelcontextprotocol.io/) をネイティブサポー } ``` -MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](docs/ja/tools_configuration.md#mcp-tool) を参照してください。 +MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](../reference/tools_configuration.ja.md#mcp-tool) を参照してください。 -## ClawdChat エージェントソーシャルネットワークに参加 +## ClawdChat エージェントソーシャルネットワークに参加 CLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。 @@ -574,23 +580,23 @@ PicoClaw は `cron` ツールによるスケジュールリマインダーと定 | トピック | 説明 | |---------|------| -| [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード | -| [チャットアプリ](docs/ja/chat-apps.md) | 17 以上の Channel セットアップガイド | -| [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス | -| [Provider とモデル](docs/ja/providers.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 | -| [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | -| [Hook システム](docs/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | -| [Steering](docs/steering.md) | 実行中の Agent ループにメッセージを注入 | -| [SubTurn](docs/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | -| [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 | -| [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill | -| [ハードウェア互換性](docs/ja/hardware-compatibility.md) | テスト済みボード、最小要件 | +| [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)をご覧ください。 +[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](../../CONTRIBUTING.md)をご覧ください。 開発者グループ構築中、最初の PR がマージされたら参加できます! @@ -599,4 +605,4 @@ PR 歓迎!コードベースは意図的に小さく読みやすくしてい Discord: WeChat: -WeChat group QR code +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/README.my.md b/docs/project/README.ms.md similarity index 81% rename from README.my.md rename to docs/project/README.ms.md index 2f1a38942..73c428f11 100644 --- a/README.my.md +++ b/docs/project/README.ms.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Pembantu AI Ultra-Cekap dalam Go

@@ -14,11 +14,11 @@ 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** | [English](README.md) +[中文](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)
@@ -34,12 +34,12 @@

- +

- +

@@ -56,6 +56,8 @@ ## 📢 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**! @@ -69,7 +71,7 @@ 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-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. @@ -106,14 +108,14 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes | **Masa Boot** (teras 0.8GHz) | >500s | >30s | **<1s** | | **Kos** | Mac Mini $599 | Kebanyakan papan Linux ~$50 | **Mana-mana papan Linux dari $10** | -PicoClaw +PicoClaw -> **[Senarai Keserasian Perkakasan](docs/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android. +> **[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 +Keserasian Perkakasan PicoClaw

## 🦾 Demonstrasi @@ -127,9 +129,9 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes

Carian Web & Pembelajaran

-

-

-

+

+

+

Bangun · Deploy · Skala @@ -163,18 +165,26 @@ Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://git ### 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 untuk pelbagai platform +# 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) @@ -210,7 +220,7 @@ picoclaw-launcher > ```

-Pelancar WebUI +Pelancar WebUI

**Memulakan:** Buka WebUI, kemudian: **1)** Konfigurasikan Penyedia (tambah kunci API LLM) -> **2)** Konfigurasikan Saluran (cth. Telegram) -> **3)** Mulakan Gateway -> **4)** Sembang! @@ -261,7 +271,7 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim **Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat amaran keselamatan:

-Amaran macOS Gatekeeper +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.* @@ -269,36 +279,36 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim **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 +macOS Privasi & Keselamatan — Buka Juga

Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada pelancaran seterusnya.
-### 💻 Pelancar TUI (Disyorkan untuk Headless / SSH) - -Pelancar TUI menyediakan antara muka terminal lengkap untuk konfigurasi dan pengurusan. Sesuai untuk pelayan, Raspberry Pi, dan persekitaran tanpa kepala lain. - -```bash -picoclaw-launcher-tui -``` - -

-Pelancar TUI -

- -**Memulakan:** - -Gunakan menu TUI untuk: **1)** Konfigurasikan Penyedia -> **2)** Konfigurasikan Saluran -> **3)** Mulakan Gateway -> **4)** Sembang! - -Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw. -**Pilihan 1: Termux (tersedia sekarang)** +**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: @@ -313,14 +323,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan susun atur sistem fail L Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfigurasi. -PicoClaw pada Termux - -**Pilihan 2: APK (akan datang)** - -APK Android bebas dengan WebUI terbina dalam sedang dalam pembangunan. Nantikan! - -
-Pelancar Terminal (untuk persekitaran terhad sumber) +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. @@ -428,7 +431,7 @@ PicoClaw menyokong 30+ penyedia LLM melalui konfigurasi `model_list`. Gunakan fo } ``` -Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](docs/providers.md). +Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](../guides/providers.md).
@@ -439,26 +442,28 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan: | Saluran | Persediaan | Protokol | Dok | |---------|-----------|----------|-----| -| **Telegram** | Mudah (token bot) | Long polling | [Panduan](docs/channels/telegram/README.md) | -| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) | -| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](docs/chat-apps.md#whatsapp) | -| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](docs/chat-apps.md#weixin) | -| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) | -| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](docs/channels/slack/README.md) | -| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) | -| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) | -| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](docs/channels/line/README.md) | -| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) | -| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](docs/chat-apps.md#irc) | -| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) | -| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) | +| **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. -Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md). +> 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 @@ -478,7 +483,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da ### ⚙️ Alat Lain -PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](docs/tools_configuration.md) untuk butiran. +PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](../reference/tools_configuration.md) untuk butiran. ## 🎯 Kemahiran @@ -508,7 +513,7 @@ Tambah ke `config.json` anda: } ``` -Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](docs/tools_configuration.md#skills-tool). +Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Protokol Konteks Model) @@ -531,9 +536,9 @@ PicoClaw menyokong [MCP](https://modelcontextprotocol.io/) secara natif — samb } ``` -Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](docs/tools_configuration.md#mcp-tool). +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 +## ClawdChat Sertai Rangkaian Sosial Agent Sambungkan PicoClaw ke Rangkaian Sosial Agent dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan. @@ -574,20 +579,20 @@ Untuk panduan terperinci melebihi README ini: | Topik | Penerangan | |-------|------------| -| [Docker & Permulaan Pantas](docs/my/docker.md) | Persediaan Docker Compose, mod Launcher/Agent | -| [Aplikasi Sembang](docs/my/chat-apps.md) | Panduan persediaan 17+ saluran | -| [Konfigurasi](docs/my/configuration.md) | Pemboleh ubah persekitaran, susun atur ruang kerja | -| [Penyedia & Model](docs/providers.md) | 30+ penyedia LLM, penghalaan model | -| [Spawn & Tugasan Async](docs/my/spawn-tasks.md) | Tugasan pantas, tugasan panjang dengan spawn | -| [Penyelesaian Masalah](docs/my/troubleshooting.md) | Isu biasa dan penyelesaian | -| [Konfigurasi Alat](docs/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran | -| [Keserasian Perkakasan](docs/hardware-compatibility.md) | Papan yang diuji, keperluan minimum | +| [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. +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! @@ -596,4 +601,4 @@ Kumpulan Pengguna: Discord: WeChat: -Kod QR kumpulan WeChat +Kod QR kumpulan WeChat diff --git a/README.pt-br.md b/docs/project/README.pt-br.md similarity index 78% rename from README.pt-br.md rename to docs/project/README.pt-br.md index 42ce5cc95..74cb967de 100644 --- a/README.pt-br.md +++ b/docs/project/README.pt-br.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Assistente de IA Ultra-Eficiente em Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](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)
@@ -34,12 +34,12 @@

- +

- +

@@ -56,6 +56,8 @@ ## 📢 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**! @@ -69,7 +71,7 @@ 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-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. @@ -106,14 +108,14 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç | **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 +PicoClaw -> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.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! +> **[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 +PicoClaw Hardware Compatibility

## 🦾 Demonstração @@ -127,9 +129,9 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç

Busca na Web e Aprendizado

-

-

-

+

+

+

Desenvolver · Implantar · Escalar @@ -162,19 +164,27 @@ Alternativamente, baixe o binário para sua plataforma na página de [GitHub Rel ### 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 para múltiplas plataformas +# 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) @@ -210,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Primeiros passos:** @@ -264,7 +274,7 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f **Passo 1:** Dê um duplo clique em `picoclaw-launcher`. Você verá um aviso de segurança:

-Aviso do macOS Gatekeeper +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.* @@ -272,36 +282,37 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f **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 +macOS Privacidade e Segurança — Abrir Mesmo Assim

Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamentos seguintes.
-### 💻 TUI Launcher (Recomendado para Headless / SSH) - -O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Primeiros passos:** - -Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar! - -Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io). - + ### 📱 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: Termux (disponível agora)** +**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: @@ -316,14 +327,7 @@ termux-chroot ./picoclaw onboard # chroot fornece um layout padrão de sistema Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração. -PicoClaw on Termux - -**Opção 2: Instalação via APK (em breve)** - -Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado! - -
-Terminal Launcher (para ambientes com recursos limitados) +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. @@ -429,7 +433,7 @@ O PicoClaw suporta mais de 30 providers de LLM através da configuração `model } ``` -Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md). +Para detalhes completos de configuração de providers, veja [Providers & Models](../guides/providers.pt-br.md).
@@ -439,26 +443,28 @@ 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](docs/channels/telegram/README.pt-br.md) | -| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) | -| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) | -| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) | -| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) | -| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) | -| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) | -| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) | -| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) | -| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) | -| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](docs/channels/wecom/README.md) | -| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) | -| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) | -| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) | +| **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. -Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md). +> 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 @@ -478,7 +484,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config ### ⚙️ 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](docs/pt-br/tools_configuration.md) para detalhes. +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 @@ -508,7 +514,7 @@ Adicione ao seu `config.json`: } ``` -Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool). +Para mais detalhes, veja [Configuração de Ferramentas - Skills](../reference/tools_configuration.pt-br.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -531,9 +537,9 @@ O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — con } ``` -Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool). +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 +## 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. @@ -574,23 +580,23 @@ Para guias detalhados além deste README: | Tópico | Descrição | |--------|-----------| -| [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração do Docker Compose, modos Launcher/Agent | -| [Apps de Chat](docs/pt-br/chat-apps.md) | Guias de configuração para todos os 17+ channels | -| [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança | -| [Providers & Models](docs/pt-br/providers.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list | -| [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents | -| [Hooks](docs/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação | -| [Steering](docs/steering.md) | Injetar mensagens em um loop de agente em execução | -| [SubTurn](docs/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | -| [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções | -| [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills | -| [Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md) | Placas testadas, requisitos mínimos | +| [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. +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! @@ -599,4 +605,4 @@ Grupos de Usuários: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.vi.md b/docs/project/README.vi.md similarity index 80% rename from README.vi.md rename to docs/project/README.vi.md index 5ca39d99d..743069021 100644 --- a/README.vi.md +++ b/docs/project/README.vi.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](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)
@@ -34,12 +34,12 @@

- +

- +

@@ -56,6 +56,8 @@ ## 📢 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**! @@ -69,7 +71,7 @@ 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-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. @@ -106,14 +108,14 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối | **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 +PicoClaw -> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.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! +> **[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 +PicoClaw Hardware Compatibility

## 🦾 Minh họa @@ -127,9 +129,9 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối

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

-

-

-

+

+

+

Phát triển · Triển khai · Mở rộng @@ -162,19 +164,27 @@ Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub 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 -# Build core binary +# Cài đặt dependencies frontend +(cd web/frontend && pnpm install --frozen-lockfile) + +# Build binary lõi make build -# Build Web UI Launcher (required for WebUI mode) +# Build Web UI Launcher (cần cho chế độ WebUI) make build-launcher -# Build for multiple platforms +# 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) @@ -210,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Bắt đầu:** @@ -264,7 +274,7 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì n **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 +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.* @@ -272,36 +282,37 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì 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ở +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.
-### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH) - -TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Bắt đầu:** - -Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện! - -Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io). - + ### 📱 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: Termux (có sẵn ngay)** +**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: @@ -316,14 +327,7 @@ termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình. -PicoClaw on Termux - -**Tùy chọn 2: Cài đặt APK (sắp ra mắt)** - -Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ! - -
-Terminal Launcher (cho môi trường hạn chế tài nguyên) +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. @@ -429,7 +433,7 @@ PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử d } ``` -Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md). +Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](../guides/providers.vi.md).
@@ -439,26 +443,28 @@ 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](docs/channels/telegram/README.vi.md) | -| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) | -| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) | -| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) | -| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) | -| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) | -| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) | -| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) | -| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) | -| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) | -| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](docs/channels/wecom/README.md) | -| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) | -| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) | -| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) | +| **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. -Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md). +> 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 @@ -478,7 +484,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C ### ⚙️ 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](docs/vi/tools_configuration.md) để biết chi tiết. +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 @@ -508,7 +514,7 @@ Thêm vào `config.json` của bạn: } ``` -Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool). +Để 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) @@ -531,9 +537,9 @@ PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nố } ``` -Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool). +Để 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 +## 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. @@ -574,23 +580,23 @@ PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ th | Chủ đề | Mô tả | |--------|-------| -| [Docker & Khởi động Nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent | -| [Ứng dụng Chat](docs/vi/chat-apps.md) | Hướng dẫn thiết lập 17+ Channel | -| [Cấu hình](docs/vi/configuration.md) | Biến môi trường, bố cục workspace, sandbox bảo mật | -| [Providers & Models](docs/vi/providers.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list | -| [Spawn & Tác vụ Bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ | -| [Hooks](docs/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | -| [SubTurn](docs/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | -| [Khắc phục sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp | -| [Cấu hình Tools](docs/vi/tools_configuration.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills | -| [Tương thích Phần cứng](docs/vi/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu | +| [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. +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! @@ -599,4 +605,4 @@ Nhóm Người dùng: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.zh.md b/docs/project/README.zh.md similarity index 77% rename from README.zh.md rename to docs/project/README.zh.md index 7ca20b1ed..253bb84ed 100644 --- a/README.zh.md +++ b/docs/project/README.zh.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-**中文** | [日本語](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.my.md) | [English](README.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](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -56,6 +56,8 @@ ## 📢 新闻 +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 ⭐**! @@ -69,7 +71,7 @@ 2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。 -2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](ROADMAP.md) 正式发布。 +2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](../../ROADMAP.md) 正式发布。 2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。 @@ -106,14 +108,14 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入 | **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | | **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | -PicoClaw +PicoClaw -> 📋 **[硬件兼容列表](docs/zh/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR! +> 📋 **[硬件兼容列表](../guides/hardware-compatibility.zh.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 演示 @@ -127,9 +129,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入

🔎 网络搜索与学习

-

-

-

+

+

+

开发 • 部署 • 扩展 @@ -162,19 +164,27 @@ PicoClaw 几乎可以部署在任何 Linux 设备上! ### 从源码构建(开发用) +前置要求: + +- 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) @@ -210,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**开始使用:** @@ -264,7 +274,7 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联 **第一步:** 双击 `picoclaw-launcher`,会出现安全警告:

-macOS Gatekeeper 警告 +macOS Gatekeeper 警告

> *"picoclaw-launcher" 无法打开 — Apple 无法验证 "picoclaw-launcher" 不含可能损害 Mac 或危及隐私的恶意软件。* @@ -272,36 +282,37 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联 **第二步:** 打开**系统设置** → **隐私与安全性** → 向下滚动找到**安全性**部分 → 点击**仍要打开** → 在弹窗中再次点击**打开**。

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

完成这一次操作后,后续启动 `picoclaw-launcher` 将不再弹出警告。
-### 💻 TUI Launcher(推荐无头环境 / SSH) - -TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。 - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**开始使用:** - -通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天! - -详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。 - + ### 📱 Android 让你十年前的旧手机焕发新生!将它变成你的 AI 助手。 -**方式一:Termux(现已可用)** +**方式一: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. 执行以下命令: @@ -316,14 +327,7 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布 然后跟随下面的"Terminal Launcher"章节继续配置。 -PicoClaw on Termux - -**方式二:APK 安装(即将推出)** - -内置 WebUI 的独立 Android APK 正在开发中,敬请期待! - -
-Terminal Launcher(适用于资源受限环境) +PicoClaw on Termux 对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。 @@ -429,36 +433,39 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模 } ``` -完整 Provider 配置详情请参阅 [Providers & Models](docs/zh/providers.md)。 +完整 Provider 配置详情请参阅 [Providers & Models](../guides/providers.zh.md)。
## 💬 Channels(聊天应用) -通过 17+ 消息平台与你的 PicoClaw 对话: +通过 18+ 消息平台与你的 PicoClaw 对话: | Channel | 配置难度 | 协议 | 文档 | |---------|----------|------|------| -| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) | -| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) | -| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) | -| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) | -| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) | -| **Slack** | 简单(bot + app token) | Socket Mode | [指南](docs/channels/slack/README.zh.md) | -| **Matrix** | 中等(homeserver + token) | Sync API | [指南](docs/channels/matrix/README.zh.md) | -| **钉钉** | 中等(client credentials) | Stream | [指南](docs/channels/dingtalk/README.zh.md) | -| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) | -| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) | -| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](docs/channels/wecom/README.zh.md) | -| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) | -| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) | -| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) | +| **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 服务器。 -详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。 +> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](../guides/configuration.zh.md#gateway-日志等级)。 + +详细 Channel 配置说明请参阅 [聊天应用配置](../guides/chat-apps.zh.md)。 ## 🔧 Tools @@ -478,7 +485,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置: ### ⚙️ 其他工具 -PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](docs/zh/tools_configuration.md)。 +PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](../reference/tools_configuration.zh.md)。 ## 🎯 Skills @@ -491,7 +498,7 @@ picoclaw skills search "web scraping" picoclaw skills install ``` -**配置 ClawHub token**(可选,用于提高速率限制): +**配置 Skills 仓库源**: 在 `config.json` 中添加: ```json @@ -501,6 +508,11 @@ picoclaw skills install "registries": { "clawhub": { "auth_token": "your-clawhub-token" + }, + "github": { + "base_url": "https://github.com", + "auth_token": "your-github-token", + "proxy": "" } } } @@ -508,7 +520,9 @@ picoclaw skills install } ``` -更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。 +`tools.skills.github.*` 已废弃,请改用 `tools.skills.registries.github.*`。 + +更多详情请参阅 [工具配置 - Skills](../reference/tools_configuration.zh.md#skills-tool)。 ## 🔗 MCP (Model Context Protocol) @@ -531,9 +545,9 @@ PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 M } ``` -完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](docs/zh/tools_configuration.md#mcp-tool)。 +完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](../reference/tools_configuration.zh.md#mcp-tool)。 -## ClawdChat 加入 Agent 社交网络 +## ClawdChat 加入 Agent 社交网络 通过 CLI 或任何已集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 @@ -574,23 +588,23 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: | 主题 | 说明 | |------|------| -| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | -| 💬 [聊天应用配置](docs/zh/chat-apps.md) | 全部 17+ Channel 配置指南 | -| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、安全沙箱 | -| 🔌 [提供商与模型配置](docs/zh/providers.md) | 30+ LLM Provider、模型路由、model_list 配置 | -| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | -| 🪝 [Hook 系统](docs/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | -| 🎯 [Steering](docs/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | -| 🔀 [SubTurn](docs/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | -| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 | -| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略、MCP、Skills | -| 📋 [硬件兼容列表](docs/zh/hardware-compatibility.md) | 已测试板卡、最低要求 | +| 🐳 [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)。 +查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](../../CONTRIBUTING.md)。 开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 @@ -599,9 +613,4 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: Discord: WeChat: -WeChat group QR code - - - - - +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..74a5bbd89 --- /dev/null +++ b/docs/reference/config-versioning.md @@ -0,0 +1,284 @@ +# 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/fr/tools_configuration.md b/docs/reference/tools_configuration.fr.md similarity index 99% rename from docs/fr/tools_configuration.md rename to docs/reference/tools_configuration.fr.md index 1324d49e5..109c9cd6f 100644 --- a/docs/fr/tools_configuration.md +++ b/docs/reference/tools_configuration.fr.md @@ -1,6 +1,6 @@ # 🔧 Configuration des Outils -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) La configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`. @@ -207,6 +207,7 @@ L'outil cron est utilisé pour planifier des tâches périodiques. |------------------------|------|------------|----------------------------------------------------| | `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. @@ -345,6 +346,7 @@ Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger }, "slack": { "enabled": true, + "type": "slack", "command": "npx", "args": [ "-y", @@ -361,6 +363,7 @@ Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger } ``` + ## Outil Skills L'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub. diff --git a/docs/ja/tools_configuration.md b/docs/reference/tools_configuration.ja.md similarity index 99% rename from docs/ja/tools_configuration.md rename to docs/reference/tools_configuration.ja.md index c946bf088..a331c869e 100644 --- a/docs/ja/tools_configuration.md +++ b/docs/reference/tools_configuration.ja.md @@ -1,6 +1,6 @@ # 🔧 ツール設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る PicoClaw のツール設定は `config.json` の `tools` フィールドにあります。 @@ -207,6 +207,7 @@ Cron ツールは定期タスクのスケジューリングに使用されます |------------------------|-----|------------|-----------------------------------------| | `exec_timeout_minutes` | int | 5 | 実行タイムアウト(分)、0 は無制限 | + ## MCP ツール MCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。 @@ -345,6 +346,7 @@ MCP ツールは外部の Model Context Protocol サーバーとの統合を可 }, "slack": { "enabled": true, + "type": "slack", "command": "npx", "args": [ "-y", @@ -361,6 +363,7 @@ MCP ツールは外部の Model Context Protocol サーバーとの統合を可 } ``` + ## Skills ツール Skills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。 diff --git a/docs/tools_configuration.md b/docs/reference/tools_configuration.md similarity index 86% rename from docs/tools_configuration.md rename to docs/reference/tools_configuration.md index 314cbd38f..810d91ef2 100644 --- a/docs/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -30,7 +30,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. 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](../sensitive_data_filtering.md) for full documentation. +See [Sensitive Data Filtering](../security/sensitive_data_filtering.md) for full documentation. | Config | Type | Default | Description | |--------|------|---------|-------------| @@ -248,13 +248,27 @@ 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 | -| `allow_command` | bool | false | Allow cron tasks to execute shell commands | + +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 @@ -394,6 +408,7 @@ dynamically only when requested by the user.* }, "slack": { "enabled": true, + "type": "slack", "command": "npx", "args": [ "-y", @@ -456,7 +471,7 @@ default (deferred). `aws` explicitly opts in to deferred mode even though it is ## Skills Tool -The skills tool configures skill discovery and installation via registries like ClawHub. +The skills tool configures skill discovery and installation via registries like ClawHub and GitHub. ### Registries @@ -471,13 +486,20 @@ The skills tool configures skill discovery and installation via registries like | `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 | -### GitHub Integration +### Legacy GitHub Config -| Config | Type | Default | Description | -|------------------|--------|---------|--------------------------------------| -| `github.proxy` | string | `""` | HTTP proxy for GitHub API requests | -| `github.token` | string | `""` | GitHub personal access token | +`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 @@ -497,10 +519,23 @@ The skills tool configures skill discovery and installation via registries like "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", - "auth_token": "" + "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": "" }, @@ -525,6 +560,9 @@ For example: - `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/pt-br/tools_configuration.md b/docs/reference/tools_configuration.pt-br.md similarity index 99% rename from docs/pt-br/tools_configuration.md rename to docs/reference/tools_configuration.pt-br.md index feec3c3d8..3dae0f908 100644 --- a/docs/pt-br/tools_configuration.md +++ b/docs/reference/tools_configuration.pt-br.md @@ -1,6 +1,6 @@ # 🔧 Configuração de Ferramentas -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) A configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`. @@ -207,6 +207,7 @@ A ferramenta cron é usada para agendar tarefas periódicas. |------------------------|------|--------|-----------------------------------------------------| | `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. @@ -345,6 +346,7 @@ Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa }, "slack": { "enabled": true, + "type": "slack", "command": "npx", "args": [ "-y", @@ -361,6 +363,7 @@ Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa } ``` + ## Ferramenta Skills A ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub. diff --git a/docs/vi/tools_configuration.md b/docs/reference/tools_configuration.vi.md similarity index 99% rename from docs/vi/tools_configuration.md rename to docs/reference/tools_configuration.vi.md index 55e7699eb..7d65ca377 100644 --- a/docs/vi/tools_configuration.md +++ b/docs/reference/tools_configuration.vi.md @@ -1,6 +1,6 @@ # 🔧 Cấu Hình Công Cụ -> Quay lại [README](../../README.vi.md) +> 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`. @@ -207,6 +207,7 @@ Công cụ cron được sử dụng để lên lịch các tác vụ định k |--------------------------|------|----------|-----------------------------------------------------| | `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. @@ -345,6 +346,7 @@ Thay vì tải tất cả các công cụ, LLM được cung cấp một công c }, "slack": { "enabled": true, + "type": "slack", "command": "npx", "args": [ "-y", @@ -361,6 +363,7 @@ Thay vì tải tất cả các công cụ, LLM được cung cấp một công c } ``` + ## 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. diff --git a/docs/zh/tools_configuration.md b/docs/reference/tools_configuration.zh.md similarity index 93% rename from docs/zh/tools_configuration.md rename to docs/reference/tools_configuration.zh.md index 63ac5000b..3937a6254 100644 --- a/docs/zh/tools_configuration.md +++ b/docs/reference/tools_configuration.zh.md @@ -1,6 +1,6 @@ # 🔧 工具配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 @@ -32,7 +32,7 @@ PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。 -详细说明请参阅[敏感数据过滤](../sensitive_data_filtering.md)。 +详细说明请参阅[敏感数据过滤](../security/sensitive_data_filtering.zh.md)。 | 配置项 | 类型 | 默认值 | 描述 | |--------|------|--------|------| @@ -234,6 +234,7 @@ Cron 工具用于调度周期性任务。 | `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 | | `allow_command` | bool | false | 允许 cron 任务执行 shell 命令 | + ## MCP 工具 MCP 工具支持与外部 Model Context Protocol 服务器集成。 @@ -372,6 +373,7 @@ LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用 }, "slack": { "enabled": true, + "type": "slack", "command": "npx", "args": [ "-y", @@ -388,6 +390,7 @@ LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用 } ``` + ## Skills 工具 Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 @@ -461,3 +464,29 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 - `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/fr/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.fr.md similarity index 99% rename from docs/fr/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.fr.md index 6cadf5238..8550c94e3 100644 --- a/docs/fr/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Guide d'authentification et d'intégration Antigravity diff --git a/docs/ja/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.ja.md similarity index 99% rename from docs/ja/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.ja.md index b55e4ab1b..e5ba91f8e 100644 --- a/docs/ja/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # Antigravity 認証・統合ガイド diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.md similarity index 100% rename from docs/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.md diff --git a/docs/pt-br/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.pt-br.md similarity index 99% rename from docs/pt-br/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.pt-br.md index d243783cb..626dc7433 100644 --- a/docs/pt-br/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Guia de Autenticação e Integração do Antigravity diff --git a/docs/vi/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.vi.md similarity index 99% rename from docs/vi/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.vi.md index 783dc5181..0800ce0f2 100644 --- a/docs/vi/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Hướng dẫn Xác thực và Tích hợp Antigravity diff --git a/docs/zh/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.zh.md similarity index 99% rename from docs/zh/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.zh.md index db7c81dea..5ae5c8afe 100644 --- a/docs/zh/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 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/fr/credential_encryption.md b/docs/security/credential_encryption.fr.md similarity index 99% rename from docs/fr/credential_encryption.md rename to docs/security/credential_encryption.fr.md index eec765039..67e2ed123 100644 --- a/docs/fr/credential_encryption.md +++ b/docs/security/credential_encryption.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Chiffrement des identifiants diff --git a/docs/ja/credential_encryption.md b/docs/security/credential_encryption.ja.md similarity index 99% rename from docs/ja/credential_encryption.md rename to docs/security/credential_encryption.ja.md index ea74b65d2..9eeba98b4 100644 --- a/docs/ja/credential_encryption.md +++ b/docs/security/credential_encryption.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # クレデンシャル暗号化 diff --git a/docs/credential_encryption.md b/docs/security/credential_encryption.md similarity index 95% rename from docs/credential_encryption.md rename to docs/security/credential_encryption.md index de3b70e09..54c2ee5f9 100644 --- a/docs/credential_encryption.md +++ b/docs/security/credential_encryption.md @@ -1,6 +1,6 @@ # Credential Encryption -PicoClaw supports encrypting `api_key` values in `model_list` configuration entries. +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. --- @@ -42,6 +42,8 @@ enc://AAAA...base64... ## 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 | diff --git a/docs/pt-br/credential_encryption.md b/docs/security/credential_encryption.pt-br.md similarity index 99% rename from docs/pt-br/credential_encryption.md rename to docs/security/credential_encryption.pt-br.md index 59a31e438..d4a84be8e 100644 --- a/docs/pt-br/credential_encryption.md +++ b/docs/security/credential_encryption.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Criptografia de Credenciais diff --git a/docs/vi/credential_encryption.md b/docs/security/credential_encryption.vi.md similarity index 99% rename from docs/vi/credential_encryption.md rename to docs/security/credential_encryption.vi.md index 9ba24588b..38d568b94 100644 --- a/docs/vi/credential_encryption.md +++ b/docs/security/credential_encryption.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Mã hóa Thông tin Xác thực diff --git a/docs/zh/credential_encryption.md b/docs/security/credential_encryption.zh.md similarity index 99% rename from docs/zh/credential_encryption.md rename to docs/security/credential_encryption.zh.md index 2105e4307..5083eee18 100644 --- a/docs/zh/credential_encryption.md +++ b/docs/security/credential_encryption.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 凭据加密 diff --git a/docs/security_configuration.md b/docs/security/security_configuration.md similarity index 95% rename from docs/security_configuration.md rename to docs/security/security_configuration.md index f4fe0e304..065eb1e76 100644 --- a/docs/security_configuration.md +++ b/docs/security/security_configuration.md @@ -148,9 +148,10 @@ You can now remove sensitive fields from `config.json` since they're loaded from "api_key": "sk-your-actual-api-key-here" } ], - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" } } @@ -168,9 +169,10 @@ You can now remove sensitive fields from `config.json` since they're loaded from // api_key is now loaded from .security.yml } ], - "channels": { + "channel_list": { "telegram": { - "enabled": true" + "enabled": true, + "type": "telegram" // token is now loaded from .security.yml } } @@ -401,7 +403,7 @@ The pattern is: `PICOCLAW_
__` with underscores separating p 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` +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 @@ -444,7 +446,7 @@ Returns the path to `.security.yml` relative to the config file. ```json { - "version": 1, + "version": 3, "agents": { "defaults": { "workspace": "~/picoclaw-workspace", @@ -463,9 +465,10 @@ Returns the path to `.security.yml` relative to the config file. "api_base": "https://api.anthropic.com/v1" } ], - "channels": { + "channel_list": { "telegram": { - "enabled": true + "enabled": true, + "type": "telegram" } }, "tools": { @@ -557,6 +560,8 @@ go test ./pkg/config -run TestSecurityConfig ### 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 ``` @@ -597,9 +602,11 @@ Test your models and channels to ensure everything works correctly. ### Step 8: Clean up (optional) -If everything works, you can delete the backup: +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 diff --git a/docs/sensitive_data_filtering.md b/docs/security/sensitive_data_filtering.md similarity index 98% rename from docs/sensitive_data_filtering.md rename to docs/security/sensitive_data_filtering.md index 0c10ff01d..e2d9de427 100644 --- a/docs/sensitive_data_filtering.md +++ b/docs/security/sensitive_data_filtering.md @@ -104,4 +104,4 @@ The model is using API key [FILTERED] and Telegram bot [FILTERED] ## Related - [Credential Encryption](./credential_encryption.md) — encrypting API keys in config -- [Tools Configuration](./tools_configuration.md) +- [Tools Configuration](../reference/tools_configuration.md) diff --git a/docs/zh/sensitive_data_filtering.md b/docs/security/sensitive_data_filtering.zh.md similarity index 95% rename from docs/zh/sensitive_data_filtering.md rename to docs/security/sensitive_data_filtering.zh.md index 4382706ed..6ff1acc20 100644 --- a/docs/zh/sensitive_data_filtering.md +++ b/docs/security/sensitive_data_filtering.zh.md @@ -103,5 +103,5 @@ The model is using API key [FILTERED] and Telegram bot [FILTERED] ## 相关文档 -- [凭据加密](../credential_encryption.md) — 配置中 API 密钥的加密 -- [工具配置](../tools_configuration.md) +- [凭据加密](./credential_encryption.zh.md) — 配置中 API 密钥的加密 +- [工具配置](../reference/tools_configuration.zh.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 096beec78..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_name** 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_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" - } - ] -} -``` - -Get your key at [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md deleted file mode 100644 index be4d4f5d7..000000000 --- a/docs/zh/troubleshooting.md +++ /dev/null @@ -1,45 +0,0 @@ -# 🐛 疑难解答 - -> 返回 [README](../../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"` - -**原因:** `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/go.mod b/go.mod index 202839c29..f52e328cf 100644 --- a/go.mod +++ b/go.mod @@ -1,90 +1,110 @@ module github.com/sipeed/picoclaw -go 1.25.8 +go 1.25.9 require ( fyne.io/systray v1.12.0 - github.com/BurntSushi/toml v1.6.0 + github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 - github.com/aws/aws-sdk-go-v2 v1.41.4 - github.com/aws/aws-sdk-go-v2/config v1.32.12 - github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 + 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.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/gdamore/tcell/v2 v2.13.8 - github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab + 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.4.1 - github.com/mymmrac/telego v1.7.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/rs/zerolog v1.34.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.7 + go.mau.fi/util v0.9.8 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.41.0 - golang.org/x/time v0.14.0 + golang.org/x/term v0.42.0 + golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.26.4 - modernc.org/sqlite v1.46.1 + maunium.net/go/mautrix v0.27.0 + modernc.org/sqlite v1.48.2 rsc.io/qr v0.2.0 ) require ( + aead.dev/minisign v0.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect - github.com/aws/smithy-go v1.24.2 // 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-sqlite3 v1.14.34 // 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-20260226131333-17d1149c6ac6 // 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/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/spf13/pflag v1.0.10 // 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 - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/text v0.35.0 // 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 ) @@ -95,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.32 + 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 @@ -112,8 +132,10 @@ require ( 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.49.0 - golang.org/x/net v0.52.0 - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 + 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 c64f3593d..d43e48f5b 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +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.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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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= @@ -17,44 +19,44 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= -github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= -github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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= @@ -65,11 +67,22 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG 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/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= @@ -90,12 +103,13 @@ github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4p 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.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo= -github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= +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= @@ -103,13 +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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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= @@ -119,8 +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-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= -github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +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= @@ -157,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.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= -github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= -github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= -github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= +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= @@ -196,24 +213,28 @@ 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-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/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= @@ -264,8 +285,16 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT 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= @@ -273,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.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= -go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= +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= @@ -285,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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +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= @@ -307,12 +345,13 @@ 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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -329,31 +368,32 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h 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-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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +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= @@ -361,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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -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= @@ -372,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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +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= @@ -403,22 +443,22 @@ 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.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= -maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= +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= @@ -427,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/loop_mcp.go b/pkg/agent/agent_mcp.go similarity index 85% rename from pkg/agent/loop_mcp.go rename to pkg/agent/agent_mcp.go index c9f3bc03d..1350ba1f2 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -24,6 +24,16 @@ type mcpRuntime struct { 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 @@ -57,6 +67,12 @@ func (r *mcpRuntime) hasManager() bool { 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 { @@ -91,7 +107,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } al.mcp.initOnce.Do(func() { - mcpManager := mcp.NewManager() + mcpManager := mcp.NewManager(mcp.WithRuntimeEvents(al.runtimeEvents)) defaultAgent := al.registry.GetDefaultAgent() workspacePath := al.cfg.WorkspacePath() @@ -100,6 +116,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } if err := mcpManager.LoadFromMCPConfig(ctx, mcpCfg, 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(), @@ -128,6 +145,25 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { serverCfg := mcpCfg.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) @@ -145,6 +181,9 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } 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) 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/loop_media.go b/pkg/agent/agent_media.go similarity index 50% rename from pkg/agent/loop_media.go rename to pkg/agent/agent_media.go index e8314c10d..c02c7392c 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/agent_media.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "io" "os" + "regexp" "strings" "github.com/h2non/filetype" @@ -20,24 +21,59 @@ import ( "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. -// Images are base64-encoded into the Media array for multimodal LLMs. -// Non-image files (documents, audio, video) have their local path injected -// into Content so the agent can access them via file tools like read_file. +// 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, len(messages)) - copy(result, 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 + } - for i, m := range result { 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 @@ -66,58 +102,36 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS } mime := detectMIME(localPath, meta) + pathTags = append(pathTags, buildPathTag(mime, localPath)) - if strings.HasPrefix(mime, "image/") { + if m.Role == "tool" && strings.HasPrefix(mime, "image/") { dataURL := encodeImageToDataURL(localPath, mime, info, maxSize) if dataURL != "" { - resolved = append(resolved, dataURL) + pendingToolImages = append(pendingToolImages, dataURL) } - continue } - - pathTags = append(pathTags, buildPathTag(mime, localPath)) } - result[i].Media = resolved + msg.Media = resolved if len(pathTags) > 0 { - result[i].Content = injectPathTags(result[i].Content, pathTags) + 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 } -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 -} - -// 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 -} - // 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 { @@ -159,10 +173,62 @@ func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) 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: [audio:/path], [video:/path], or [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/"): @@ -173,22 +239,41 @@ func buildPathTag(mime, localPath string) string { } // injectPathTags replaces generic media tags in content with path-bearing versions, -// or appends if no matching generic tag is found. +// 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 generic string + var pattern *regexp.Regexp switch { + case strings.HasPrefix(tag, "[image:"): + pattern = imagePlaceholderRegex case strings.HasPrefix(tag, "[audio:"): - generic = "[audio]" + pattern = audioPlaceholderRegex case strings.HasPrefix(tag, "[video:"): - generic = "[video]" + pattern = videoPlaceholderRegex case strings.HasPrefix(tag, "[file:"): - generic = "[file]" + pattern = filePlaceholderRegex } - if generic != "" && strings.Contains(content, generic) { - content = strings.Replace(content, generic, tag, 1) - } else if content == "" { + 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 @@ -196,3 +281,8 @@ func injectPathTags(content string, tags []string) string { } 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/loop_test.go b/pkg/agent/agent_test.go similarity index 50% rename from pkg/agent/loop_test.go rename to pkg/agent/agent_test.go index 25d20c689..a75919912 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/agent_test.go @@ -9,39 +9,52 @@ import ( "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) 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 (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 - sentMedia []bus.OutboundMediaMessage + sentMessages []bus.OutboundMessage + sentMedia []bus.OutboundMediaMessage } -func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +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 + return nil, nil } func newStartedTestChannelManager( @@ -71,6 +84,7 @@ func newStartedTestChannelManager( type recordingProvider struct { lastMessages []providers.Message + lastModel string } func (r *recordingProvider) Chat( @@ -81,6 +95,7 @@ func (r *recordingProvider) Chat( 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{}, @@ -91,6 +106,38 @@ 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()) { @@ -115,6 +162,58 @@ func newTestAgentLoop( 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 { @@ -137,7 +236,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { provider := &recordingProvider{} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ Channel: "discord", SenderID: "discord:123", Sender: bus.SenderInfo{ @@ -145,7 +244,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { }, ChatID: "group-1", Content: "hello", - }) + })) if err != nil { t.Fatalf("processMessage() error = %v", err) } @@ -196,12 +295,12 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { provider := &recordingProvider{} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -226,6 +325,330 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { } } +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{ @@ -286,12 +709,12 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { provider := &recordingProvider{} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -299,12 +722,12 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { t.Fatalf("arm response = %q, want armed confirmation", response) } - response, err = al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -531,6 +954,20 @@ func TestToolContext_Updates(t *testing.T) { 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 @@ -603,12 +1040,12 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. path: imagePath, }) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -645,16 +1082,21 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. if defaultAgent == nil { t.Fatal("expected default agent") } - route, _, err := al.resolveMessageRoute(bus.InboundMessage{ + 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(route, "") + 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") @@ -663,6 +1105,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. 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) { @@ -698,12 +1143,12 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes loop: al, }) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -718,6 +1163,263 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes } } +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() @@ -749,12 +1451,12 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { path: imagePath, }) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -959,6 +1661,98 @@ 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 } @@ -988,17 +1782,22 @@ func (m *artifactThenSendProvider) Chat( if messages[i].Role != "tool" { continue } - start := strings.Index(messages[i].Content, "[file:") - if start < 0 { - 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 } - rest := messages[i].Content[start+len("[file:"):] - end := strings.Index(rest, "]") - if end < 0 { - continue + if artifactPath != "" { + break } - artifactPath = rest[:end] - break } if artifactPath == "" { return nil, fmt.Errorf("provider did not receive artifact path in tool result") @@ -1053,6 +1852,279 @@ 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( @@ -1128,6 +2200,24 @@ func (m *handledMediaTool) Execute(ctx context.Context, args map[string]any) *to 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 } @@ -1341,13 +2431,39 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) defer cancel() - response, err := h.al.processMessage(timeoutCtx, msg) + 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) { @@ -1373,21 +2489,17 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { al := NewAgentLoop(cfg, msgBus, provider) msg := bus.InboundMessage{ - Channel: "telegram", - SenderID: "user1", - ChatID: "chat1", - Content: "hello", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", }, + Content: "hello", } - route := al.registry.ResolveRoute(routing.RouteInput{ - Channel: msg.Channel, - Peer: extractPeer(msg), - }) - sessionKey := route.SessionKey + route := al.registry.ResolveRoute(bus.NormalizeInboundMessage(msg).Context) + sessionKey := al.allocateRouteSession(route, msg).SessionKey defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { @@ -1423,7 +2535,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { }, }, Session: config.SessionConfig{ - DMScope: "per-channel-peer", + Dimensions: []string{"chat"}, }, } @@ -1433,21 +2545,22 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { helper := testHelper{al: al} baseMsg := bus.InboundMessage{ - Channel: "whatsapp", - SenderID: "user1", - ChatID: "chat1", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", + Context: bus.InboundContext{ + Channel: "whatsapp", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", }, } showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: baseMsg.Channel, - SenderID: baseMsg.SenderID, - ChatID: baseMsg.ChatID, - Content: "/show channel", - Peer: baseMsg.Peer, + 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) @@ -1457,11 +2570,13 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { } fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: baseMsg.Channel, - SenderID: baseMsg.SenderID, - ChatID: baseMsg.ChatID, - Content: "/foo", - Peer: baseMsg.Peer, + 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) @@ -1471,11 +2586,13 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { } newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: baseMsg.Channel, - SenderID: baseMsg.SenderID, - ChatID: baseMsg.ChatID, - Content: "/new", - Peer: baseMsg.Peer, + 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) @@ -1485,6 +2602,75 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { } } +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 { @@ -1528,10 +2714,6 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { SenderID: "user1", ChatID: "chat1", Content: "/switch model to deepseek", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if !strings.Contains(switchResp, "Switched model from local to deepseek") { t.Fatalf("unexpected /switch reply: %q", switchResp) @@ -1542,10 +2724,6 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { SenderID: "user1", ChatID: "chat1", Content: "/show model", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") { t.Fatalf("unexpected /show model reply after switch: %q", showResp) @@ -1593,10 +2771,6 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { SenderID: "user1", ChatID: "chat1", Content: "/switch model to missing", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if switchResp != `model "missing" not found in model_list or providers` { t.Fatalf("unexpected /switch error reply: %q", switchResp) @@ -1607,10 +2781,6 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { SenderID: "user1", ChatID: "chat1", Content: "/show model", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if !strings.Contains(showResp, "Current Model: local (Provider: openai)") { t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp) @@ -1677,10 +2847,6 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t SenderID: "user1", ChatID: "chat1", Content: "hello before switch", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if firstResp != "local reply" { t.Fatalf("unexpected response before switch: %q", firstResp) @@ -1700,10 +2866,6 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t SenderID: "user1", ChatID: "chat1", Content: "/switch model to deepseek", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if !strings.Contains(switchResp, "Switched model from local to deepseek") { t.Fatalf("unexpected /switch reply: %q", switchResp) @@ -1714,10 +2876,6 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t SenderID: "user1", ChatID: "chat1", Content: "hello after switch", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if secondResp != "remote reply" { t.Fatalf("unexpected response after switch: %q", secondResp) @@ -1807,10 +2965,6 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) { SenderID: "user1", ChatID: "chat1", Content: "hi", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, }) if resp != "light reply" { t.Fatalf("response = %q, want %q", resp, "light reply") @@ -1823,6 +2977,162 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) { } } +// 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-*") @@ -2017,6 +3327,136 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } +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 { @@ -2083,14 +3523,16 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { if defaultAgent == nil { t.Fatal("No default agent found") } - route := al.registry.ResolveRoute(routing.RouteInput{ - Channel: "test", - Peer: &routing.RoutePeer{ - Kind: "direct", - ID: "cron", - }, + route := al.registry.ResolveRoute(bus.InboundContext{ + Channel: "test", + ChatType: "direct", + SenderID: "cron", }) - history := defaultAgent.Sessions.GetHistory(route.SessionKey) + 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)) } @@ -2348,8 +3790,7 @@ func TestHandleReasoning(t *testing.T) { for i := 0; ; i++ { fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond) err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{ - Channel: "filler", - ChatID: "filler", + Context: bus.NewOutboundContext("filler", "filler", ""), Content: fmt.Sprintf("filler-%d", i), }) fillCancel() @@ -2423,12 +3864,12 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T chManager.RegisterChannel("telegram", &fakeChannel{id: "reason-chat"}) al.SetChannelManager(chManager) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -2444,6 +3885,9 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T 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") } @@ -2452,6 +3896,66 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T } } +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") @@ -2529,12 +4033,12 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { provider := &toolFeedbackProvider{filePath: heartbeatFile} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -2544,21 +4048,621 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { 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 preview", outbound.Content) + 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 TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { +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() @@ -2586,15 +4690,110 @@ func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { } 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 len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), 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]) + 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_SkipsOversizedFile(t *testing.T) { +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() @@ -2616,6 +4815,11 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { 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) { @@ -2693,11 +4897,13 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 media, got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), 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]) + localPath, _, _ := store.ResolveWithMeta(ref) + expectedContent := "hi [image:" + localPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } @@ -2787,6 +4993,98 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) { } } +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() @@ -2830,13 +5128,12 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (all types use path tags), got %d", len(result[0].Media)) } - if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { - t.Fatal("expected image to be base64 encoded") - } - expectedContent := "check these [file:" + pdfPath + "]" + 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) } @@ -2972,13 +5269,13 @@ func TestProcessMessage_ContextOverflowRecovery(t *testing.T) { agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"}) } - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -3014,12 +5311,12 @@ func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) { return &providers.LLMResponse{Content: "Anthropic recovery success"}, nil } - response, err := al.processMessage(context.Background(), bus.InboundMessage{ + 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) } @@ -3030,3 +5327,267 @@ func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) { 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 1ff25f296..e0ea70f97 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -1,6 +1,7 @@ package agent import ( + "context" "errors" "fmt" "io/fs" @@ -11,8 +12,8 @@ import ( "strings" "sync" "time" + "unicode/utf8" - "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -21,13 +22,12 @@ import ( ) type ContextBuilder struct { - workspace string - skillsLoader *skills.SkillsLoader - memory *MemoryStore - toolDiscoveryBM25 bool - toolDiscoveryRegex bool - splitOnMarker bool - agentDiscovery func(workspace string) []AgentDescriptor + workspace string + skillsLoader *skills.SkillsLoader + memory *MemoryStore + splitOnMarker bool + agentDiscovery func(workspace string) []AgentDescriptor + promptRegistry *PromptRegistry // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -49,8 +49,16 @@ type ContextBuilder struct { } func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder { - cb.toolDiscoveryBM25 = useBM25 - cb.toolDiscoveryRegex = useRegex + 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 } @@ -63,18 +71,21 @@ func (cb *ContextBuilder) WithAgentDiscovery( discover func(workspace string) []AgentDescriptor, ) *ContextBuilder { cb.agentDiscovery = discover + if discover != nil { + if err := cb.RegisterPromptContributor(agentDiscoveryPromptContributor{ + workspace: cb.workspace, + discover: discover, + }); err != nil { + logger.WarnCF("agent", "Failed to register agent discovery prompt contributor", map[string]any{ + "error": err.Error(), + }) + } + } return cb } func getGlobalConfigDir() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, pkg.DefaultPicoClawHome) + return config.GetHome() } func NewContextBuilder(workspace string) *ContextBuilder { @@ -88,15 +99,38 @@ 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)) - toolDiscovery := cb.getDiscoveryRule() version := config.FormatVersion() return fmt.Sprintf( @@ -118,29 +152,20 @@ 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. - -%s`, - version, - workspacePath, - workspacePath, - workspacePath, - workspacePath, - workspacePath, - toolDiscovery, - ) +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.`, + version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) } -func (cb *ContextBuilder) getDiscoveryRule() string { - if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex { +func formatToolDiscoveryRule(useBM25, useRegex bool) string { + if !useBM25 && !useRegex { return "" } var toolNames []string - if cb.toolDiscoveryBM25 { + if useBM25 { toolNames = append(toolNames, `"tool_search_tool_bm25"`) } - if cb.toolDiscoveryRegex { + if useRegex { toolNames = append(toolNames, `"tool_search_tool_regex"`) } @@ -151,43 +176,103 @@ func (cb *ContextBuilder) getDiscoveryRule() string { } 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.skillsLoader.BuildSkillsSummary() 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, + }) } // Multi-Message Sending (if enabled) if cb.splitOnMarker { - parts = append(parts, `# MULTI-MESSAGE OUTPUT + 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.`) +Each part separated by the marker will be sent as an independent message.`, + Stable: true, + Cache: PromptCacheEphemeral, + }) } - // Join with "---" separator - return strings.Join(parts, "\n\n---\n\n") + stack.Seal() + return stack.Parts() } func (cb *ContextBuilder) buildAgentDiscoveryContext() string { @@ -240,6 +325,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. @@ -549,6 +677,20 @@ func (cb *ContextBuilder) BuildMessages( 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 @@ -563,8 +705,7 @@ func (cb *ContextBuilder) BuildMessages( staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName) - discoveryCtx := cb.buildAgentDiscoveryContext() + 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 @@ -578,42 +719,74 @@ func (cb *ContextBuilder) BuildMessages( stringParts := []string{staticPrompt} contentBlocks := []providers.ContentBlock{ - { - Type: "text", - Text: staticPrompt, - CacheControl: &providers.CacheControl{Type: "ephemeral"}, - }, + promptContentBlock(PromptPart{ + ID: "kernel.static", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "static"}, + Content: staticPrompt, + }, &providers.CacheControl{Type: "ephemeral"}), } - if discoveryCtx != "" { - stringParts = append(stringParts, discoveryCtx) - contentBlocks = append( - contentBlocks, - providers.ContentBlock{Type: "text", Text: discoveryCtx}, - ) + 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, providers.ContentBlock{Type: "text", Text: dynamicCtx}) + contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) - if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { - stringParts = append(stringParts, skillsText) - contentBlocks = append( - contentBlocks, - providers.ContentBlock{Type: "text", Text: skillsText}, - ) - } - - 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}, - ) + 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") @@ -630,7 +803,8 @@ 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, }) @@ -641,7 +815,7 @@ func (cb *ContextBuilder) BuildMessages( "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; @@ -655,16 +829,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 @@ -740,43 +909,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)) - seenToolCallID := make(map[string]bool) for i := 0; i < len(sanitized); i++ { msg := sanitized[i] - // Deduplicate tool results by ToolCallID - if msg.Role == "tool" && msg.ToolCallID != "" { - if seenToolCallID[msg.ToolCallID] { - logger.DebugCF("agent", "Dropping duplicate tool result", map[string]any{ - "tool_call_id": msg.ToolCallID, - }) - continue - } - seenToolCallID[msg.ToolCallID] = true - } - 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 @@ -786,7 +972,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 @@ -794,11 +980,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) } @@ -865,6 +1063,26 @@ 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 diff --git a/pkg/agent/context_budget.go b/pkg/agent/context_budget.go index c87695c7a..72f80382a 100644 --- a/pkg/agent/context_budget.go +++ b/pkg/agent/context_budget.go @@ -6,10 +6,8 @@ package agent import ( - "encoding/json" - "unicode/utf8" - "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tokenizer" ) // parseTurnBoundaries returns the starting index of each Turn in the history. @@ -86,73 +84,16 @@ func findSafeBoundary(history []providers.Message, targetIndex int) int { return 0 } -// 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 { - chars := utf8.RuneCountInString(msg.Content) - - // ReasoningContent (extended thinking / chain-of-thought) can be - // substantial and is stored in session history via AddFullMessage. - if msg.ReasoningContent != "" { - 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 +// 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. Each tool's name, description, and -// JSON schema parameters contribute to the context window budget. -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 +// 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 @@ -166,10 +107,10 @@ func isOverContextBudget( ) bool { msgTokens := 0 for _, m := range messages { - msgTokens += estimateMessageTokens(m) + msgTokens += EstimateMessageTokens(m) } - toolTokens := estimateToolDefsTokens(toolDefs) + 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 index 870f0fbe6..9de1707ec 100644 --- a/pkg/agent/context_budget_test.go +++ b/pkg/agent/context_budget_test.go @@ -417,9 +417,9 @@ func TestEstimateMessageTokens(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := estimateMessageTokens(tt.msg) + got := EstimateMessageTokens(tt.msg) if got < tt.want { - t.Errorf("estimateMessageTokens() = %d, want >= %d", got, tt.want) + t.Errorf("EstimateMessageTokens() = %d, want >= %d", got, tt.want) } }) } @@ -443,8 +443,8 @@ func TestEstimateMessageTokens_ToolCallsContribute(t *testing.T) { }, } - plainTokens := estimateMessageTokens(plain) - withTCTokens := estimateMessageTokens(withTC) + plainTokens := EstimateMessageTokens(plain) + withTCTokens := EstimateMessageTokens(withTC) if withTCTokens <= plainTokens { t.Errorf("message with ToolCalls (%d tokens) should exceed plain message (%d tokens)", @@ -457,7 +457,7 @@ func TestEstimateMessageTokens_MultibyteContent(t *testing.T) { // 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) + tokens := EstimateMessageTokens(msg) if tokens <= 0 { t.Errorf("multibyte message should produce positive token count, got %d", tokens) } @@ -481,7 +481,7 @@ func TestEstimateMessageTokens_LargeArguments(t *testing.T) { }, } - tokens := estimateMessageTokens(msg) + 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) @@ -496,8 +496,8 @@ func TestEstimateMessageTokens_ReasoningContent(t *testing.T) { ReasoningContent: strings.Repeat("thinking step ", 200), } - plainTokens := estimateMessageTokens(plain) - reasoningTokens := estimateMessageTokens(withReasoning) + plainTokens := EstimateMessageTokens(plain) + reasoningTokens := EstimateMessageTokens(withReasoning) if reasoningTokens <= plainTokens { t.Errorf("message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)", @@ -513,8 +513,8 @@ func TestEstimateMessageTokens_MediaItems(t *testing.T) { Media: []string{"media://img1.png", "media://img2.png"}, } - plainTokens := estimateMessageTokens(plain) - mediaTokens := estimateMessageTokens(withMedia) + plainTokens := EstimateMessageTokens(plain) + mediaTokens := EstimateMessageTokens(withMedia) if mediaTokens <= plainTokens { t.Errorf("message with Media (%d tokens) should exceed plain message (%d tokens)", @@ -529,7 +529,27 @@ func TestEstimateMessageTokens_MediaItems(t *testing.T) { } } -// --- estimateToolDefsTokens tests --- +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 { @@ -579,9 +599,9 @@ func TestEstimateToolDefsTokens(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := estimateToolDefsTokens(tt.defs) + got := EstimateToolDefsTokens(tt.defs) if got < tt.want { - t.Errorf("estimateToolDefsTokens() = %d, want >= %d", got, tt.want) + t.Errorf("EstimateToolDefsTokens() = %d, want >= %d", got, tt.want) } }) } @@ -604,8 +624,8 @@ func TestEstimateToolDefsTokens_ScalesWithCount(t *testing.T) { } } - one := estimateToolDefsTokens([]providers.ToolDefinition{makeTool("tool_a")}) - three := estimateToolDefsTokens([]providers.ToolDefinition{ + one := EstimateToolDefsTokens([]providers.ToolDefinition{makeTool("tool_a")}) + three := EstimateToolDefsTokens([]providers.ToolDefinition{ makeTool("tool_a"), makeTool("tool_b"), makeTool("tool_c"), }) @@ -750,7 +770,7 @@ func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) { }, } - tokens := estimateMessageTokens(msg) + tokens := EstimateMessageTokens(msg) // ReasoningContent alone is ~1700 chars → ~680 tokens. // Content + TC + overhead adds more. Should be well above 500. @@ -761,7 +781,7 @@ func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) { // Compare without reasoning to ensure it's counted. msgNoReasoning := msg msgNoReasoning.ReasoningContent = "" - tokensNoReasoning := estimateMessageTokens(msgNoReasoning) + tokensNoReasoning := EstimateMessageTokens(msgNoReasoning) if tokens <= tokensNoReasoning { t.Errorf("reasoning content should add tokens: with=%d, without=%d", tokens, tokensNoReasoning) diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 81a1534b9..ef5e6c5de 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -707,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-*") 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 0d7948eef..ed64d1578 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -213,6 +213,47 @@ func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) { } } +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/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.go b/pkg/agent/eventbus.go deleted file mode 100644 index 546d8436d..000000000 --- a/pkg/agent/eventbus.go +++ /dev/null @@ -1,121 +0,0 @@ -package agent - -import ( - "sync" - "sync/atomic" - "time" -) - -const defaultEventSubscriberBuffer = 16 - -// EventSubscription identifies a subscriber channel returned by EventBus.Subscribe. -type EventSubscription struct { - ID uint64 - C <-chan Event -} - -type eventSubscriber struct { - ch chan Event -} - -// EventBus is a lightweight multi-subscriber broadcaster for agent-loop events. -type EventBus struct { - mu sync.RWMutex - subs map[uint64]eventSubscriber - nextID uint64 - closed bool - dropped [eventKindCount]atomic.Int64 -} - -// NewEventBus creates a new in-process event broadcaster. -func NewEventBus() *EventBus { - return &EventBus{ - subs: make(map[uint64]eventSubscriber), - } -} - -// Subscribe registers a new subscriber with the requested channel buffer size. -// A non-positive buffer uses the default size. -func (b *EventBus) Subscribe(buffer int) EventSubscription { - if buffer <= 0 { - buffer = defaultEventSubscriberBuffer - } - - b.mu.Lock() - defer b.mu.Unlock() - - if b.closed { - ch := make(chan Event) - close(ch) - return EventSubscription{C: ch} - } - - b.nextID++ - id := b.nextID - ch := make(chan Event, buffer) - b.subs[id] = eventSubscriber{ch: ch} - return EventSubscription{ID: id, C: ch} -} - -// Unsubscribe removes a subscriber and closes its channel. -func (b *EventBus) Unsubscribe(id uint64) { - b.mu.Lock() - defer b.mu.Unlock() - - sub, ok := b.subs[id] - if !ok { - return - } - - delete(b.subs, id) - close(sub.ch) -} - -// Emit broadcasts an event to all current subscribers without blocking. -// When a subscriber channel is full, the event is dropped for that subscriber. -func (b *EventBus) Emit(evt Event) { - if evt.Time.IsZero() { - evt.Time = time.Now() - } - - b.mu.RLock() - defer b.mu.RUnlock() - - if b.closed { - return - } - - for _, sub := range b.subs { - select { - case sub.ch <- evt: - default: - if evt.Kind < eventKindCount { - b.dropped[evt.Kind].Add(1) - } - } - } -} - -// Dropped returns the number of dropped events for a given kind. -func (b *EventBus) Dropped(kind EventKind) int64 { - if kind >= eventKindCount { - return 0 - } - return b.dropped[kind].Load() -} - -// Close closes all subscriber channels and stops future broadcasts. -func (b *EventBus) Close() { - b.mu.Lock() - defer b.mu.Unlock() - - if b.closed { - return - } - - b.closed = true - for id, sub := range b.subs { - close(sub.ch) - delete(b.subs, id) - } -} diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 19a1ea9eb..86d7f4afa 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -9,59 +9,94 @@ import ( "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 TestEventBus_SubscribeEmitUnsubscribeClose(t *testing.T) { - eventBus := NewEventBus() - sub := eventBus.Subscribe(1) - - eventBus.Emit(Event{ - Kind: EventKindTurnStart, - Meta: EventMeta{TurnID: "turn-1"}, - }) - - select { - case evt := <-sub.C: - if evt.Kind != EventKindTurnStart { - t.Fatalf("expected %v, got %v", EventKindTurnStart, evt.Kind) +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) } - if evt.Meta.TurnID != "turn-1" { - t.Fatalf("expected turn id turn-1, got %q", evt.Meta.TurnID) + }() + + 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) } - case <-time.After(time.Second): - t.Fatal("timed out waiting for event") + }() + + 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) } - - eventBus.Unsubscribe(sub.ID) - if _, ok := <-sub.C; ok { - t.Fatal("expected subscriber channel to be closed after unsubscribe") + if runtimeEvt.Source != (runtimeevents.Source{Component: "agent", Name: "main"}) { + t.Fatalf("runtime source = %+v", runtimeEvt.Source) } - - eventBus.Close() - closedSub := eventBus.Subscribe(1) - if _, ok := <-closedSub.C; ok { - t.Fatal("expected closed bus to return a closed subscriber channel") + 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) } -} - -func TestEventBus_DropsWhenSubscriberIsFull(t *testing.T) { - eventBus := NewEventBus() - sub := eventBus.Subscribe(1) - defer eventBus.Unsubscribe(sub.ID) - - start := time.Now() - for i := 0; i < 1000; i++ { - eventBus.Emit(Event{Kind: EventKindLLMRequest}) + if runtimeEvt.Correlation.TraceID != "trace/root" || + runtimeEvt.Correlation.ParentTurnID != "parent-turn" { + t.Fatalf("runtime correlation = %+v", runtimeEvt.Correlation) } - - if elapsed := time.Since(start); elapsed > 100*time.Millisecond { - t.Fatalf("Emit took too long with a blocked subscriber: %s", elapsed) + if runtimeEvt.Attrs["agent_source"] != "pipeline_execute" || runtimeEvt.Attrs["iteration"] != 2 { + t.Fatalf("runtime attrs = %+v", runtimeEvt.Attrs) } - - if got := eventBus.Dropped(EventKindLLMRequest); got != 999 { - t.Fatalf("expected 999 dropped events, got %d", got) + 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) } } @@ -125,8 +160,18 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { t.Fatal("expected default agent") } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + 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", @@ -136,6 +181,31 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { 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) @@ -144,37 +214,36 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { t.Fatalf("expected final response 'done', got %q", response) } - events := collectEventStream(sub.C) + events := collectRuntimeEventStream(runtimeCh) if len(events) != 8 { t.Fatalf("expected 8 events, got %d", len(events)) } - kinds := make([]EventKind, 0, len(events)) + kinds := make([]runtimeevents.Kind, 0, len(events)) for _, evt := range events { kinds = append(kinds, evt.Kind) } - expectedKinds := []EventKind{ - EventKindTurnStart, - EventKindLLMRequest, - EventKindLLMResponse, - EventKindToolExecStart, - EventKindToolExecEnd, - EventKindLLMRequest, - EventKindLLMResponse, - EventKindTurnEnd, - } if !slices.Equal(kinds, expectedKinds) { t.Fatalf("unexpected event sequence: got %v want %v", kinds, expectedKinds) } - turnID := events[0].Meta.TurnID + turnID := events[0].Scope.TurnID + if turnID == "" { + t.Fatal("expected runtime events to include turn id") + } for i, evt := range events { - if evt.Meta.TurnID != turnID { - t.Fatalf("event %d has mismatched turn id %q, want %q", i, evt.Meta.TurnID, turnID) + if evt.Scope.TurnID != turnID { + t.Fatalf("event %d has mismatched turn id %q, want %q", i, evt.Scope.TurnID, turnID) } - if evt.Meta.SessionKey != "session-1" { - t.Fatalf("event %d has session key %q, want session-1", i, evt.Meta.SessionKey) + 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) } } @@ -270,8 +339,15 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { al.RegisterTool(tool1) al.RegisterTool(tool2) - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentSteeringInjected, + runtimeevents.KindAgentToolExecSkipped, + runtimeevents.KindAgentInterruptReceived, + ) + defer closeRuntimeEvents() resultCh := make(chan string, 1) go func() { @@ -298,8 +374,8 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatal("timeout waiting for steered response") } - events := collectEventStream(sub.C) - steeringEvt, ok := findEvent(events, EventKindSteeringInjected) + events := collectRuntimeEventStream(runtimeCh) + steeringEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentSteeringInjected) if !ok { t.Fatal("expected steering injected event") } @@ -311,7 +387,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatalf("expected 1 steering message, got %d", steeringPayload.Count) } - skippedEvt, ok := findEvent(events, EventKindToolExecSkipped) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) if !ok { t.Fatal("expected skipped tool event") } @@ -323,7 +399,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatalf("expected skipped tool_two, got %q", skippedPayload.Tool) } - interruptEvt, ok := findEvent(events, EventKindInterruptReceived) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) if !ok { t.Fatal("expected interrupt received event") } @@ -381,8 +457,14 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { {Role: "user", Content: "Trigger message"}, }) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentLLMRetry, + runtimeevents.KindAgentContextCompress, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ SessionKey: "session-1", @@ -400,8 +482,8 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { t.Fatalf("expected retry success, got %q", resp) } - events := collectEventStream(sub.C) - retryEvt, ok := findEvent(events, EventKindLLMRetry) + events := collectRuntimeEventStream(runtimeCh) + retryEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentLLMRetry) if !ok { t.Fatal("expected llm retry event") } @@ -416,7 +498,7 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { t.Fatalf("expected retry attempt 1, got %d", retryPayload.Attempt) } - compressEvt, ok := findEvent(events, EventKindContextCompress) + compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress) if !ok { t.Fatal("expected context compress event") } @@ -469,14 +551,19 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) { {Role: "assistant", Content: "Answer three"}, }) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSessionSummarize, + ) + defer closeRuntimeEvents() - turnScope := al.newTurnEventScope(defaultAgent.ID, "session-1") - al.summarizeSession(defaultAgent, "session-1", turnScope) + lcm := &legacyContextManager{al: al} + lcm.summarizeSession(defaultAgent, "session-1") - events := collectEventStream(sub.C) - summaryEvt, ok := findEvent(events, EventKindSessionSummarize) + events := collectRuntimeEventStream(runtimeCh) + summaryEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentSessionSummarize) if !ok { t.Fatal("expected session summarize event") } @@ -536,8 +623,13 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { t.Fatal("expected default agent") } - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentFollowUpQueued, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ SessionKey: "session-1", @@ -561,8 +653,8 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { t.Fatal("timeout waiting for async tool completion") } - followUpEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool { - return evt.Kind == EventKindFollowUpQueued + followUpEvt := waitForRuntimeEvent(t, runtimeCh, 2*time.Second, func(evt runtimeevents.Event) bool { + return evt.Kind == runtimeevents.KindAgentFollowUpQueued }) payload, ok := followUpEvt.Payload.(FollowUpQueuedPayload) if !ok { @@ -571,68 +663,32 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { if payload.SourceTool != "async_followup" { t.Fatalf("expected source tool async_followup, got %q", payload.SourceTool) } - if payload.Channel != "cli" { - t.Fatalf("expected channel cli, got %q", payload.Channel) - } - if payload.ChatID != "direct" { - t.Fatalf("expected chat id direct, got %q", payload.ChatID) - } if payload.ContentLen != len("background result") { t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen) } - if followUpEvt.Meta.SessionKey != "session-1" { - t.Fatalf("expected session key session-1, got %q", followUpEvt.Meta.SessionKey) + if followUpEvt.Scope.SessionKey != "session-1" { + t.Fatalf("expected session key session-1, got %q", followUpEvt.Scope.SessionKey) } - if followUpEvt.Meta.TurnID == "" { + if followUpEvt.Scope.TurnID == "" { t.Fatal("expected follow-up event to include turn id") } } -func collectEventStream(ch <-chan Event) []Event { - var events []Event - for { - select { - case evt, ok := <-ch: - if !ok { - return events - } - events = append(events, evt) - default: - return events - } - } -} - -func waitForEvent(t *testing.T, ch <-chan Event, timeout time.Duration, match func(Event) bool) Event { +func receiveRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { t.Helper() - timer := time.NewTimer(timeout) - defer timer.Stop() - - for { - select { - case evt, ok := <-ch: - if !ok { - t.Fatal("event stream closed before expected event arrived") - } - if match(evt) { - return evt - } - case <-timer.C: - t.Fatal("timed out waiting for expected event") + 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{} } } -func findEvent(events []Event, kind EventKind) (Event, bool) { - for _, evt := range events { - if evt.Kind == kind { - return evt, true - } - } - return Event{}, false -} - type stringError string func (e stringError) Error() string { diff --git a/pkg/agent/events.go b/pkg/agent/events.go index f4562b360..0dd861f43 100644 --- a/pkg/agent/events.go +++ b/pkg/agent/events.go @@ -1,96 +1,8 @@ package agent -import ( - "fmt" - "time" -) - -// EventKind identifies a structured agent-loop event. -type EventKind uint8 - -const ( - // EventKindTurnStart is emitted when a turn begins processing. - EventKindTurnStart EventKind = iota - // EventKindTurnEnd is emitted when a turn finishes, successfully or with an error. - EventKindTurnEnd - // EventKindLLMRequest is emitted before a provider chat request is made. - EventKindLLMRequest - // EventKindLLMDelta is emitted when a streaming provider yields a partial delta. - EventKindLLMDelta - // EventKindLLMResponse is emitted after a provider chat response is received. - EventKindLLMResponse - // EventKindLLMRetry is emitted when an LLM request is retried. - EventKindLLMRetry - // EventKindContextCompress is emitted when session history is forcibly compressed. - EventKindContextCompress - // EventKindSessionSummarize is emitted when asynchronous summarization completes. - EventKindSessionSummarize - // EventKindToolExecStart is emitted immediately before a tool executes. - EventKindToolExecStart - // EventKindToolExecEnd is emitted immediately after a tool finishes executing. - EventKindToolExecEnd - // EventKindToolExecSkipped is emitted when a queued tool call is skipped. - EventKindToolExecSkipped - // EventKindSteeringInjected is emitted when queued steering is injected into context. - EventKindSteeringInjected - // EventKindFollowUpQueued is emitted when an async tool queues a follow-up system message. - EventKindFollowUpQueued - // EventKindInterruptReceived is emitted when a soft interrupt message is accepted. - EventKindInterruptReceived - // EventKindSubTurnSpawn is emitted when a sub-turn is spawned. - EventKindSubTurnSpawn - // EventKindSubTurnEnd is emitted when a sub-turn finishes. - EventKindSubTurnEnd - // EventKindSubTurnResultDelivered is emitted when a sub-turn result is delivered. - EventKindSubTurnResultDelivered - // EventKindSubTurnOrphan is emitted when a sub-turn result cannot be delivered. - EventKindSubTurnOrphan - // EventKindError is emitted when a turn encounters an execution error. - EventKindError - - eventKindCount -) - -var eventKindNames = [...]string{ - "turn_start", - "turn_end", - "llm_request", - "llm_delta", - "llm_response", - "llm_retry", - "context_compress", - "session_summarize", - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped", - "steering_injected", - "follow_up_queued", - "interrupt_received", - "subturn_spawn", - "subturn_end", - "subturn_result_delivered", - "subturn_orphan", - "error", -} - -// String returns the stable string form of an EventKind. -func (k EventKind) String() string { - if k >= eventKindCount { - return fmt.Sprintf("event_kind(%d)", k) - } - return eventKindNames[k] -} - -// Event is the structured envelope broadcast by the agent EventBus. -type Event struct { - Kind EventKind - Time time.Time - Meta EventMeta - Payload any -} - -// EventMeta contains correlation fields shared by all agent-loop events. -type EventMeta struct { +// 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 @@ -98,174 +10,5 @@ type EventMeta struct { Iteration int TracePath string Source string -} - -// 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 { - Channel string - ChatID string - 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" -) - -// 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 - Channel string - ChatID 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 + 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 index c92145f1f..c518feee8 100644 --- a/pkg/agent/hook_mount.go +++ b/pkg/agent/hook_mount.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) type hookRuntime struct { @@ -295,10 +296,11 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error) case "", "*", "all": return nil, true, nil default: - if _, ok := validKinds[kind]; !ok { + normalizedKind, ok := validKinds[kind] + if !ok { return nil, false, fmt.Errorf("unsupported observe event %q", kind) } - normalized = append(normalized, kind) + normalized = append(normalized, normalizedKind) } } @@ -308,10 +310,30 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error) return normalized, true, nil } -func validHookEventKinds() map[string]struct{} { - kinds := make(map[string]struct{}, int(eventKindCount)) - for kind := EventKind(0); kind < eventKindCount; kind++ { - kinds[kind.String()] = struct{}{} +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 index 85d8f5c11..5cd64af7b 100644 --- a/pkg/agent/hook_mount_test.go +++ b/pkg/agent/hook_mount_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "path/filepath" + "slices" "testing" "github.com/sipeed/picoclaw/pkg/bus" @@ -155,7 +156,27 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T) t.Fatalf("expected process model, got %q", lastModel) } - waitForFileContains(t, eventLog, "turn_end") + 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) { diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go index e5632913d..ce8e932d2 100644 --- a/pkg/agent/hook_process.go +++ b/pkg/agent/hook_process.go @@ -12,7 +12,10 @@ import ( "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 ( @@ -90,7 +93,8 @@ type processHookAfterLLMResponse struct { type processHookBeforeToolResponse struct { processHookDecisionResponse - Call *ToolCallHookRequest `json:"call,omitempty"` + Call *ToolCallHookRequest `json:"call,omitempty"` + Result *tools.ToolResult `json:"result,omitempty"` // Result returned directly by hook (for respond action) } type processHookAfterToolResponse struct { @@ -120,7 +124,9 @@ func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) ( if err != nil { return nil, fmt.Errorf("create process hook stderr: %w", err) } - if err := cmd.Start(); err != nil { + // 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) } @@ -178,7 +184,7 @@ func (ph *ProcessHook) Close() error { return ph.closeErr } -func (ph *ProcessHook) OnEvent(ctx context.Context, evt Event) error { +func (ph *ProcessHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { if ph == nil || !ph.opts.Observe { return nil } @@ -187,7 +193,7 @@ func (ph *ProcessHook) OnEvent(ctx context.Context, evt Event) error { return nil } } - return ph.notify(ctx, "hook.event", evt) + return ph.notify(ctx, "hook.runtime_event", evt) } func (ph *ProcessHook) BeforeLLM( @@ -241,6 +247,10 @@ func (ph *ProcessHook) BeforeTool( 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 } diff --git a/pkg/agent/hook_process_test.go b/pkg/agent/hook_process_test.go index 50f89811f..0fd1ec38d 100644 --- a/pkg/agent/hook_process_test.go +++ b/pkg/agent/hook_process_test.go @@ -7,10 +7,14 @@ import ( "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" ) @@ -63,7 +67,7 @@ func TestAgentLoop_MountProcessHook_LLMAndObserver(t *testing.T) { t.Fatalf("expected process model, got %q", lastModel) } - waitForFileContains(t, eventLog, "turn_end") + waitForFileContains(t, eventLog, "agent.turn.end") } func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) { @@ -143,8 +147,13 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { t.Fatalf("MountProcessHook failed: %v", err) } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ SessionKey: "session-1", @@ -164,8 +173,8 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { t.Fatalf("expected %q, got %q", expected, resp) } - events := collectEventStream(sub.C) - skippedEvt, ok := findEvent(events, EventKindToolExecSkipped) + events := collectRuntimeEventStream(runtimeCh) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) if !ok { t.Fatal("expected tool skipped event") } @@ -178,6 +187,76 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { } } +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", "--"} } @@ -193,6 +272,59 @@ func processHookHelperEnv(mode, eventLog string) []string { 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() @@ -224,12 +356,11 @@ func runProcessHookHelper() error { } if msg.ID == 0 { - if msg.Method == "hook.event" && eventLog != "" { + if msg.Method == "hook.runtime_event" && eventLog != "" { var evt map[string]any if err := json.Unmarshal(msg.Params, &evt); err == nil { - if rawKind, ok := evt["Kind"].(float64); ok { - kind := EventKind(rawKind) - _ = os.WriteFile(eventLog, []byte(kind.String()+"\n"), 0o644) + if kind, ok := evt["kind"].(string); ok { + _ = os.WriteFile(eventLog, []byte(kind+"\n"), 0o644) } } } diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index c1ef58ffd..a4f0fac82 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -4,10 +4,12 @@ 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" @@ -25,6 +27,7 @@ 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" @@ -69,8 +72,8 @@ func NamedHook(name string, hook any) HookRegistration { } } -type EventObserver interface { - OnEvent(ctx context.Context, evt Event) error +type RuntimeEventObserver interface { + OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error } type LLMInterceptor interface { @@ -88,13 +91,12 @@ type ToolApprover interface { } type LLMHookRequest struct { - Meta EventMeta `json:"meta"` + 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"` - Channel string `json:"channel,omitempty"` - ChatID string `json:"chat_id,omitempty"` GracefulTerminal bool `json:"graceful_terminal,omitempty"` } @@ -103,6 +105,8 @@ func (r *LLMHookRequest) Clone() *LLMHookRequest { 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) @@ -110,11 +114,10 @@ func (r *LLMHookRequest) Clone() *LLMHookRequest { } type LLMHookResponse struct { - Meta EventMeta `json:"meta"` + Meta HookMeta `json:"meta"` + Context *TurnContext `json:"context,omitempty"` Model string `json:"model"` Response *providers.LLMResponse `json:"response,omitempty"` - Channel string `json:"channel,omitempty"` - ChatID string `json:"chat_id,omitempty"` } func (r *LLMHookResponse) Clone() *LLMHookResponse { @@ -122,16 +125,20 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse { 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 EventMeta `json:"meta"` - Tool string `json:"tool"` - Arguments map[string]any `json:"arguments,omitempty"` - Channel string `json:"channel,omitempty"` - ChatID string `json:"chat_id,omitempty"` + 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 { @@ -139,16 +146,18 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest { 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 EventMeta `json:"meta"` + 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"` } func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest { @@ -156,18 +165,19 @@ func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest { 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 EventMeta `json:"meta"` + 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"` - Channel string `json:"channel,omitempty"` - ChatID string `json:"chat_id,omitempty"` } func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse { @@ -175,13 +185,15 @@ func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse { 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 { - eventBus *EventBus + runtimeEvents runtimeevents.EventChannel observerTimeout time.Duration interceptorTimeout time.Duration approvalTimeout time.Duration @@ -190,28 +202,39 @@ type HookManager struct { hooks map[string]HookRegistration ordered []HookRegistration - sub EventSubscription - done chan struct{} - closeOnce sync.Once + runtimeSub runtimeevents.Subscription + runtimeDone chan struct{} + closeOnce sync.Once } -func NewHookManager(eventBus *EventBus) *HookManager { +func NewHookManager(runtimeEvents runtimeevents.EventChannel) *HookManager { hm := &HookManager{ - eventBus: eventBus, + runtimeEvents: runtimeEvents, observerTimeout: defaultHookObserverTimeout, interceptorTimeout: defaultHookInterceptorTimeout, approvalTimeout: defaultHookApprovalTimeout, hooks: make(map[string]HookRegistration), - done: make(chan struct{}), + runtimeDone: make(chan struct{}), } - if eventBus == nil { - close(hm.done) - return hm + 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) } - hm.sub = eventBus.Subscribe(hookObserverBufferSize) - go hm.dispatchEvents() return hm } @@ -221,10 +244,14 @@ func (hm *HookManager) Close() { } hm.closeOnce.Do(func() { - if hm.eventBus != nil { - hm.eventBus.Unsubscribe(hm.sub.ID) + 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.done + <-hm.runtimeDone hm.closeAllHooks() }) } @@ -281,16 +308,16 @@ func (hm *HookManager) Unmount(name string) { hm.rebuildOrdered() } -func (hm *HookManager) dispatchEvents() { - defer close(hm.done) +func (hm *HookManager) dispatchRuntimeEvents(ch <-chan runtimeevents.Event) { + defer close(hm.runtimeDone) - for evt := range hm.sub.C { + for evt := range ch { for _, reg := range hm.snapshotHooks() { - observer, ok := reg.Hook.(EventObserver) + observer, ok := reg.Hook.(RuntimeEventObserver) if !ok { continue } - hm.runObserver(reg.Name, observer, evt) + hm.runRuntimeObserver(reg.Name, observer, evt) } } } @@ -315,6 +342,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if next != nil { + next = hm.applyBeforeLLMControls(reg.Name, current, next) current = next } case HookActionAbortTurn, HookActionHardAbort: @@ -357,6 +385,84 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL 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, @@ -382,6 +488,10 @@ func (hm *HookManager) BeforeTool( 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: @@ -487,26 +597,30 @@ func (hm *HookManager) closeAllHooks() { hm.ordered = nil } -func (hm *HookManager) runObserver(name string, observer EventObserver, evt Event) { +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.OnEvent(ctx, evt) + done <- observer.OnRuntimeEvent(ctx, evt) }() select { case err := <-done: if err != nil { - logger.WarnCF("hooks", "Event observer failed", map[string]any{ + 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", "Event observer timed out", map[string]any{ + logger.WarnCF("hooks", "Runtime event observer timed out", map[string]any{ "hook": name, "event": evt.Kind.String(), "timeout_ms": hm.observerTimeout.Milliseconds(), @@ -774,7 +888,7 @@ func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse { func cloneStringAnyMap(src map[string]any) map[string]any { if len(src) == 0 { - return nil + return map[string]any{} } cloned := make(map[string]any, len(src)) @@ -793,6 +907,13 @@ func cloneToolResult(result *tools.ToolResult) *tools.ToolResult { 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 } diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index 49e1b1784..4deef38c7 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -2,14 +2,20 @@ 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" ) @@ -106,11 +112,14 @@ func (p *llmHookTestProvider) GetDefaultModel() string { } type llmObserverHook struct { - eventCh chan Event + eventCh chan runtimeevents.Event + lastInbound *bus.InboundContext + lastRoute *routing.ResolvedRoute + lastScope *session.SessionScope } -func (h *llmObserverHook) OnEvent(ctx context.Context, evt Event) error { - if evt.Kind == EventKindTurnEnd { +func (h *llmObserverHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + if evt.Kind == runtimeevents.KindAgentTurnEnd { select { case h.eventCh <- evt: default: @@ -123,6 +132,11 @@ 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 @@ -137,12 +151,288 @@ func (h *llmObserverHook) AfterLLM( 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 Event, 1)} + hook := &llmObserverHook{eventCh: make(chan runtimeevents.Event, 1)} if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil { t.Fatalf("MountHook failed: %v", err) } @@ -155,6 +445,31 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { 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) @@ -169,17 +484,170 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { 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 != EventKindTurnEnd { + 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 @@ -264,6 +732,24 @@ func (h *toolRewriteHook) AfterTool( 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) @@ -291,6 +777,75 @@ func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { } } +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) { @@ -310,8 +865,13 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ SessionKey: "session-1", @@ -330,8 +890,8 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) { t.Fatalf("expected %q, got %q", expected, resp) } - events := collectEventStream(sub.C) - skippedEvt, ok := findEvent(events, EventKindToolExecSkipped) + events := collectRuntimeEventStream(runtimeCh) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) if !ok { t.Fatal("expected tool skipped event") } @@ -343,3 +903,676 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) { 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 2df65b905..6d629ac57 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -9,6 +9,7 @@ import ( "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" @@ -52,6 +53,10 @@ type AgentInstance struct { // 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. @@ -61,6 +66,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) @@ -83,9 +94,12 @@ func NewAgentInstance( if cfg.Tools.IsToolEnabled("read_file") { maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize - toolsRegistry.Register( - tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths), - ) + 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)) @@ -182,6 +196,9 @@ func NewAgentInstance( // Resolve fallback candidates candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) + 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 @@ -213,6 +230,7 @@ func NewAgentInstance( }) lightCandidates = resolved lightProvider = lp + populateCandidateProvidersFromNames(cfg, workspace, []string{rc.LightModel}, candidateProviders) } } } else { @@ -245,6 +263,43 @@ func NewAgentInstance( 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 } } diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 3edac0724..2b144914e 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -9,6 +9,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" ) func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { @@ -103,6 +104,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { name string aliasName string modelName string + provider string apiBase string wantProvider string wantModel string @@ -123,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 { @@ -144,6 +155,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { { ModelName: tt.aliasName, Model: tt.modelName, + Provider: tt.provider, APIBase: tt.apiBase, }, }, @@ -165,6 +177,95 @@ 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() @@ -248,6 +349,240 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { } } +// 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() 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 ef2951365..000000000 --- a/pkg/agent/loop.go +++ /dev/null @@ -1,3585 +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" - "path/filepath" - "regexp" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "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/media" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/routing" - "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 { - // Core dependencies - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - - // Event system (from Incoming) - eventBus *EventBus - hooks *HookManager - - // Runtime state - running atomic.Bool - summarizing sync.Map - fallback *providers.FallbackChain - channelManager *channels.Manager - mediaStore media.MediaStore - transcriber voice.Transcriber - cmdRegistry *commands.Registry - mcp mcpRuntime - hookRuntime hookRuntime - steering *steeringQueue - pendingSkills sync.Map - mu sync.RWMutex - - // Concurrent turn management (from HEAD) - activeTurnStates sync.Map // key: sessionKey (string), value: *turnState - subTurnCounter atomic.Int64 // Counter for generating unique SubTurn IDs - - // Turn tracking (from Incoming) - turnSeq atomic.Uint64 - activeRequests sync.WaitGroup - - reloadFunc func() error -} - -// 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 - 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 - 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) -} - -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:" - 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) - - // 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) - } - - eventBus := NewEventBus() - al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - eventBus: eventBus, - summarizing: sync.Map{}, - fallback: fallbackChain, - cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), - steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), - } - al.hooks = NewHookManager(eventBus) - configureHookManagerFromConfig(al.hooks, cfg) - - // Register shared tools to all agents (now that al is created) - registerSharedTools(al, cfg, msgBus, registry, provider) - - return al -} - -// registerSharedTools registers tools that are shared across all agents (web, message, spawn). -func registerSharedTools( - al *AgentLoop, - cfg *config.Config, - msgBus *bus.MessageBus, - registry *AgentRegistry, - provider providers.LLMProvider, -) { - allowReadPaths := buildAllowReadPatterns(cfg) - - for _, agentID := range registry.ListAgentIDs() { - agent, ok := registry.GetAgent(agentID) - if !ok { - continue - } - - if cfg.Tools.IsToolEnabled("web") { - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - 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, - 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, - }) - 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()) - } - - // 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, - allowReadPaths, - ) - 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) { - clawHubConfig := cfg.Tools.Skills.Registries.ClawHub - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig{ - Enabled: clawHubConfig.Enabled, - BaseURL: clawHubConfig.BaseURL, - AuthToken: clawHubConfig.AuthToken.String(), - SearchPath: clawHubConfig.SearchPath, - SkillsPath: clawHubConfig.SkillsPath, - DownloadPath: clawHubConfig.DownloadPath, - Timeout: clawHubConfig.Timeout, - MaxZipSize: clawHubConfig.MaxZipSize, - MaxResponseSize: clawHubConfig.MaxResponseSize, - }, - }) - - 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) - - // 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) - } - } -} - -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 - } - - // Start a goroutine that drains the bus while processMessage is - // running. Only messages that resolve to the active turn scope are - // redirected into steering; other inbound messages are requeued. - drainCancel := func() {} - if activeScope, activeAgentID, ok := al.resolveSteeringTarget(msg); ok { - drainCtx, cancel := context.WithCancel(ctx) - drainCancel = cancel - go al.drainBusToSteering(drainCtx, activeScope, activeAgentID) - } - - // Process message - func() { - defer func() { - if al.channelManager != nil { - al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) - } - }() - // 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(), - // }) - // } - // } - // }() - - drainCanceled := false - cancelDrain := func() { - if drainCanceled { - return - } - drainCancel() - drainCanceled = true - } - defer cancelDrain() - - response, err := al.processMessage(ctx, msg) - if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) - } - finalResponse := response - - target, targetErr := al.buildContinuationTarget(msg) - if targetErr != nil { - logger.WarnCF("agent", "Failed to build steering continuation target", - map[string]any{ - "channel": msg.Channel, - "error": targetErr.Error(), - }) - return - } - if target == nil { - cancelDrain() - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse) - } - return - } - - for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - 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 { - logger.WarnCF("agent", "Failed to continue queued steering", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "error": continueErr.Error(), - }) - return - } - if continued == "" { - return - } - - finalResponse = continued - } - - cancelDrain() - - for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - logger.InfoCF("agent", "Draining steering queued during turn shutdown", - 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 { - logger.WarnCF("agent", "Failed to continue queued steering after shutdown drain", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "error": continueErr.Error(), - }) - return - } - if continued == "" { - break - } - - finalResponse = continued - } - - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) - } - }() - } - } -} - -// drainBusToSteering consumes inbound messages and redirects messages from the -// active scope into the steering queue. Messages from other scopes are requeued -// so they can be processed normally after the active turn. It drains all -// immediately available messages, blocking for the first one until ctx is done. -func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, activeAgentID string) { - blocking := true - for { - var msg bus.InboundMessage - - if blocking { - // Block waiting for the first available message or ctx cancellation. - select { - case <-ctx.Done(): - return - case m, ok := <-al.bus.InboundChan(): - if !ok { - return - } - msg = m - } - } else { - // Non-blocking: drain any remaining queued messages, return when empty. - select { - case m, ok := <-al.bus.InboundChan(): - if !ok { - return - } - msg = m - default: - return - } - } - blocking = false - - msgScope, _, scopeOK := al.resolveSteeringTarget(msg) - if !scopeOK || msgScope != activeScope { - if err := al.requeueInboundMessage(msg); err != nil { - logger.WarnCF("agent", "Failed to requeue non-steering inbound message", map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - "sender_id": msg.SenderID, - }) - } - continue - } - - // Transcribe audio if needed before steering, so the agent sees text. - msg, _ = al.transcribeAudioInMessage(ctx, msg) - - logger.InfoCF("agent", "Redirecting inbound message to steering queue", - map[string]any{ - "channel": msg.Channel, - "sender_id": msg.SenderID, - "content_len": len(msg.Content), - "scope": activeScope, - }) - - if err := al.enqueueSteeringMessage(activeScope, activeAgentID, providers.Message{ - Role: "user", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), - }); err != nil { - logger.WarnCF("agent", "Failed to steer message, will be lost", - map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - }) - } - } -} - -func (al *AgentLoop) Stop() { - al.running.Store(false) -} - -func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { - if response == "" { - return - } - - alreadySent := false - defaultAgent := al.GetRegistry().GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() - } - } - } - - if alreadySent { - logger.DebugCF( - "agent", - "Skipped outbound (message tool already sent)", - map[string]any{"channel": channel}, - ) - return - } - - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: response, - }) - logger.InfoCF("agent", "Published outbound response", - map[string]any{ - "channel": channel, - "chat_id": chatID, - "content_len": len(response), - }) -} - -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 - } - - return &continuationTarget{ - SessionKey: resolveScopeKey(route, msg.SessionKey), - Channel: msg.Channel, - ChatID: msg.ChatID, - }, nil -} - -// 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() - } - if al.eventBus != nil { - al.eventBus.Close() - } -} - -// 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) -} - -// SubscribeEvents registers a subscriber for agent-loop events. -func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription { - if al == nil || al.eventBus == nil { - ch := make(chan Event) - close(ch) - return EventSubscription{C: ch} - } - return al.eventBus.Subscribe(buffer) -} - -// UnsubscribeEvents removes a previously registered event subscriber. -func (al *AgentLoop) UnsubscribeEvents(id uint64) { - if al == nil || al.eventBus == nil { - return - } - al.eventBus.Unsubscribe(id) -} - -// EventDrops returns the number of dropped events for the given kind. -func (al *AgentLoop) EventDrops(kind EventKind) int64 { - if al == nil || al.eventBus == nil { - return 0 - } - return al.eventBus.Dropped(kind) -} - -type turnEventScope struct { - agentID string - sessionKey string - turnID string -} - -func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string) turnEventScope { - seq := al.turnSeq.Add(1) - return turnEventScope{ - agentID: agentID, - sessionKey: sessionKey, - turnID: fmt.Sprintf("%s-turn-%d", agentID, seq), - } -} - -func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta { - return EventMeta{ - AgentID: ts.agentID, - TurnID: ts.turnID, - SessionKey: ts.sessionKey, - Iteration: iteration, - Source: source, - TracePath: tracePath, - } -} - -func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) { - evt := Event{ - Kind: kind, - Meta: meta, - Payload: payload, - } - - if al == nil || al.eventBus == nil { - return - } - - al.logEvent(evt) - - al.eventBus.Emit(evt) -} - -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 (al *AgentLoop) hookAbortError(ts *turnState, stage string, decision HookDecision) error { - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - - err := fmt.Errorf("hook aborted turn during %s: %s", stage, reason) - al.emitEvent( - EventKindError, - ts.eventMeta("hooks", "turn.error"), - ErrorPayload{ - Stage: "hook." + stage, - Message: err.Error(), - }, - ) - return err -} - -func hookDeniedToolContent(prefix, reason string) string { - if reason == "" { - return prefix - } - return prefix + ": " + reason -} - -func (al *AgentLoop) logEvent(evt Event) { - fields := map[string]any{ - "event_kind": evt.Kind.String(), - "agent_id": evt.Meta.AgentID, - "turn_id": evt.Meta.TurnID, - "session_key": evt.Meta.SessionKey, - "iteration": evt.Meta.Iteration, - } - - if evt.Meta.TracePath != "" { - fields["trace"] = evt.Meta.TracePath - } - if evt.Meta.Source != "" { - fields["source"] = evt.Meta.Source - } - - switch payload := evt.Payload.(type) { - case TurnStartPayload: - fields["channel"] = payload.Channel - fields["chat_id"] = payload.ChatID - 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["channel"] = payload.Channel - fields["chat_id"] = payload.ChatID - 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 ErrorPayload: - fields["stage"] = payload.Stage - fields["error"] = payload.Message - } - - logger.InfoCF("eventbus", fmt.Sprintf("Agent event: %s", evt.Kind.String()), fields) -} - -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 -} - -// 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 { - 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 - al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker()) - - al.mu.Unlock() - - al.hookRuntime.reset(al) - configureHookManagerFromConfig(al.hooks, cfg) - - // 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) -func (al *AgentLoop) GetRegistry() *AgentRegistry { - al.mu.RLock() - defer al.mu.RUnlock() - return al.registry -} - -// GetConfig returns the current config (thread-safe) -func (al *AgentLoop) GetConfig() *config.Config { - al.mu.RLock() - defer al.mu.RUnlock() - return al.cfg -} - -// SetMediaStore injects a MediaStore for media lifecycle management. -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) - } - } -} - -// SetTranscriber injects a voice transcriber for agent-level audio transcription. -func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { - al.transcriber = t -} - -// SetReloadFunc sets the callback function for triggering config reload. -func (al *AgentLoop) SetReloadFunc(fn func() error) { - al.reloadFunc = fn -} - -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. -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 - 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, 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++ - 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, true -} - -// 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. -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{ - Channel: channel, - ChatID: chatID, - Content: feedbackMsg, - ReplyToMessageID: messageID, - }) - if err != nil { - logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) - } -} - -// 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) { - if err := al.ensureHooksInitialized(ctx); err != nil { - return "", err - } - if err := al.ensureMCPInitialized(ctx); err != nil { - return "", err - } - - msg := bus.InboundMessage{ - Channel: channel, - SenderID: "cron", - ChatID: chatID, - Content: content, - SessionKey: sessionKey, - } - - return al.processMessage(ctx, msg) -} - -// 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) { - 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") - } - return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: "heartbeat", - Channel: channel, - ChatID: chatID, - UserMessage: content, - 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) { - // 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 - } - - // 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, - }) - - opts := processOptions{ - SessionKey: sessionKey, - Channel: msg.Channel, - ChatID: msg.ChatID, - SenderID: msg.SenderID, - SenderDisplayName: msg.Sender.DisplayName, - UserMessage: msg.Content, - Media: msg.Media, - DefaultResponse: defaultResponse, - EnableSummary: true, - SendResponse: false, - } - - // 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.SessionKey); len(pending) > 0 { - opts.ForcedSkills = append(opts.ForcedSkills, pending...) - logger.InfoCF("agent", "Applying pending skill override", - map[string]any{ - "session_key": opts.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() - route := 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 := 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 resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string { - if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { - return msgSessionKey - } - return route.SessionKey -} - -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 - } - - return resolveScopeKey(route, msg.SessionKey), agent.ID, true -} - -func (al *AgentLoop) requeueInboundMessage(msg bus.InboundMessage) error { - if al.bus == nil { - return nil - } - pubCtx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: msg.Content, - }) -} - -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 := routing.BuildAgentMainSessionKey(agent.ID) - - return 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, - }) -} - -// 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) { - // Record last channel for heartbeat notifications (skip internal channels and cli) - if opts.Channel != "" && opts.ChatID != "" && !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()}, - ) - } - } - - ts := newTurnState(agent, opts, al.newTurnEventScope(agent.ID, opts.SessionKey)) - result, err := al.runTurn(ctx, ts) - 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 != "" { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: result.finalContent, - }) - } - - 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.SessionKey, - "iterations": ts.currentIteration(), - "final_length": len(result.finalContent), - }) - } - - return result.finalContent, 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(), - }) - } - } -} - -func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (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) - - turnStatus := TurnEndStatusCompleted - defer func() { - al.emitEvent( - EventKindTurnEnd, - ts.eventMeta("runTurn", "turn.end"), - TurnEndPayload{ - Status: turnStatus, - Iterations: ts.currentIteration(), - Duration: time.Since(ts.startedAt), - FinalContentLen: ts.finalContentLen(), - }, - ) - }() - - al.emitEvent( - EventKindTurnStart, - ts.eventMeta("runTurn", "turn.start"), - TurnStartPayload{ - Channel: ts.channel, - ChatID: ts.chatID, - UserMessage: ts.userMessage, - MediaCount: len(ts.media), - }, - ) - - var history []providers.Message - var summary string - if !ts.opts.NoHistory { - history = ts.agent.Sessions.GetHistory(ts.sessionKey) - summary = ts.agent.Sessions.GetSummary(ts.sessionKey) - } - ts.captureRestorePoint(history, summary) - - messages := ts.agent.ContextBuilder.BuildMessages( - history, - summary, - ts.userMessage, - ts.media, - ts.channel, - ts.chatID, - ts.opts.SenderID, - ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - - cfg := al.GetConfig() - maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.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 compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok { - al.emitEvent( - EventKindContextCompress, - ts.eventMeta("runTurn", "turn.context.compress"), - ContextCompressPayload{ - Reason: ContextCompressReasonProactive, - DroppedMessages: compression.DroppedMessages, - RemainingMessages: compression.RemainingMessages, - }, - ) - ts.refreshRestorePointFromSession(ts.agent) - } - newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey) - newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) - messages = ts.agent.ContextBuilder.BuildMessages( - newHistory, newSummary, ts.userMessage, - ts.media, ts.channel, ts.chatID, - ts.opts.SenderID, ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - } - } - - // Save user message to session (from Incoming) - if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { - rootMsg := providers.Message{ - Role: "user", - Content: ts.userMessage, - Media: append([]string(nil), 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) - } - - activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) - activeProvider := ts.agent.Provider - if usedLight && ts.agent.LightProvider != nil { - activeProvider = ts.agent.LightProvider - } - pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...) - var finalContent string - -turnLoop: - for ts.currentIteration() < ts.agent.MaxIterations || len(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 { - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - } 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 := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", 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) - } - 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( - EventKindSteeringInjected, - ts.eventMeta("runTurn", "turn.steering.injected"), - SteeringInjectedPayload{ - Count: len(pendingMessages), - TotalContentLen: totalContentLen, - }, - ) - pendingMessages = nil - } - - logger.DebugCF("agent", "LLM iteration", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "max": ts.agent.MaxIterations, - }) - - gracefulTerminal, _ := ts.gracefulInterruptRequested() - providerToolDefs := ts.agent.Tools.ToProviderDefs() - - // Native web search support (from HEAD) - _, hasWebSearch := ts.agent.Tools.Get("web_search") - useNativeSearch := al.cfg.Tools.Web.PreferNative && - hasWebSearch && - func() bool { - // Check if provider supports native search - if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { - return ns.SupportsNativeSearch() - } - return false - }() - - if useNativeSearch { - // Filter out client-side web_search tool - filtered := make([]providers.ToolDefinition, 0, len(providerToolDefs)) - for _, td := range providerToolDefs { - if td.Function.Name != "web_search" { - filtered = append(filtered, td) - } - } - providerToolDefs = filtered - } - - callMessages := messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - providerToolDefs = nil - ts.markGracefulTerminalUsed() - } - - llmOpts := map[string]any{ - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "prompt_cache_key": ts.agent.ID, - } - if useNativeSearch { - llmOpts["native_search"] = true - } - if ts.agent.ThinkingLevel != ThinkingOff { - if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - 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)}) - } - } - - llmModel := activeModel - if al.hooks != nil { - llmReq, decision := al.hooks.BeforeLLM(turnCtx, &LLMHookRequest{ - Meta: ts.eventMeta("runTurn", "turn.llm.request"), - Model: llmModel, - Messages: callMessages, - Tools: providerToolDefs, - Options: llmOpts, - Channel: ts.channel, - ChatID: ts.chatID, - GracefulTerminal: gracefulTerminal, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmReq != nil { - llmModel = llmReq.Model - callMessages = llmReq.Messages - providerToolDefs = llmReq.Tools - llmOpts = llmReq.Options - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - al.emitEvent( - EventKindLLMRequest, - ts.eventMeta("runTurn", "turn.llm.request"), - LLMRequestPayload{ - Model: llmModel, - MessagesCount: len(callMessages), - ToolsCount: len(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": llmModel, - "messages_count": len(callMessages), - "tools_count": len(providerToolDefs), - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "system_prompt_len": len(callMessages[0].Content), - }) - logger.DebugCF("agent", "Full LLM request", - map[string]any{ - "iteration": iteration, - "messages_json": formatMessagesForLog(callMessages), - "tools_json": formatToolsForLog(providerToolDefs), - }) - - 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(activeCandidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute( - providerCtx, - activeCandidates, - func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return activeProvider.Chat(ctx, messagesForCall, toolDefsForCall, 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": ts.agent.ID, "iteration": iteration}, - ) - } - return fbResult.Response, nil - } - return activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, llmModel, llmOpts) - } - - var response *providers.LLMResponse - var err error - maxRetries := 2 - for retry := 0; retry <= maxRetries; retry++ { - response, err = callLLM(callMessages, providerToolDefs) - if err == nil { - break - } - if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - 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") - - 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) * 5 * time.Second - al.emitEvent( - EventKindLLMRetry, - 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() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - err = sleepErr - break - } - continue - } - - if isContextError && retry < maxRetries && !ts.opts.NoHistory { - al.emitEvent( - EventKindLLMRetry, - 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, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: "Context window exceeded. Compressing history and retrying...", - }) - } - - if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok { - al.emitEvent( - EventKindContextCompress, - ts.eventMeta("runTurn", "turn.context.compress"), - ContextCompressPayload{ - Reason: ContextCompressReasonRetry, - DroppedMessages: compression.DroppedMessages, - RemainingMessages: compression.RemainingMessages, - }, - ) - ts.refreshRestorePointFromSession(ts.agent) - } - - newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey) - newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) - messages = ts.agent.ContextBuilder.BuildMessages( - newHistory, newSummary, "", - nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - callMessages = messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - } - continue - } - break - } - - if err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - 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": llmModel, - "error": err.Error(), - }) - return turnResult{}, fmt.Errorf("LLM call failed after retries: %w", err) - } - - if al.hooks != nil { - llmResp, decision := al.hooks.AfterLLM(turnCtx, &LLMHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.llm.response"), - Model: llmModel, - Response: response, - Channel: ts.channel, - ChatID: ts.chatID, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmResp != nil && llmResp.Response != nil { - response = llmResp.Response - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - // Save finishReason to turnState for SubTurn truncation detection - if innerTS := turnStateFromContext(ctx); innerTS != nil { - innerTS.SetLastFinishReason(response.FinishReason) - // Save usage for token budget tracking - if response.Usage != nil { - innerTS.SetLastUsage(response.Usage) - } - } - - reasoningContent := response.Reasoning - if reasoningContent == "" { - reasoningContent = response.ReasoningContent - } - go al.handleReasoning( - turnCtx, - reasoningContent, - ts.channel, - al.targetReasoningChannelID(ts.channel), - ) - al.emitEvent( - EventKindLLMResponse, - ts.eventMeta("runTurn", "turn.llm.response"), - LLMResponsePayload{ - ContentLen: len(response.Content), - ToolCalls: len(response.ToolCalls), - HasReasoning: response.Reasoning != "" || response.ReasoningContent != "", - }, - ) - - llmResponseFields := map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_chars": len(response.Content), - "tool_calls": len(response.ToolCalls), - "reasoning": response.Reasoning, - "target_channel": al.targetReasoningChannelID(ts.channel), - "channel": ts.channel, - } - if response.Usage != nil { - llmResponseFields["prompt_tokens"] = response.Usage.PromptTokens - llmResponseFields["completion_tokens"] = response.Usage.CompletionTokens - llmResponseFields["total_tokens"] = response.Usage.TotalTokens - } - logger.DebugCF("agent", "LLM response", llmResponseFields) - - if len(response.ToolCalls) == 0 || gracefulTerminal { - responseContent := response.Content - if responseContent == "" && response.ReasoningContent != "" { - responseContent = 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), - }) - pendingMessages = append(pendingMessages, steerMsgs...) - continue - } - 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(finalContent), - }) - break - } - - normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) - } - - 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": ts.agent.ID, - "tools": toolNames, - "count": len(normalizedToolCalls), - "iteration": iteration, - }) - - allResponsesHandled := len(normalizedToolCalls) > 0 - assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, - ReasoningContent: response.ReasoningContent, - } - for _, tc := range normalizedToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) - 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) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) - ts.recordPersistedMessage(assistantMsg) - } - - ts.setPhase(TurnPhaseTools) - for i, tc := range normalizedToolCalls { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - 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"), - Tool: toolName, - Arguments: toolArgs, - Channel: ts.channel, - ChatID: ts.chatID, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolReq != nil { - toolName = toolReq.Tool - toolArgs = toolReq.Arguments - } - case HookActionDenyTool: - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) - al.emitEvent( - EventKindToolExecSkipped, - 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: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - if al.hooks != nil { - approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ - Meta: ts.eventMeta("runTurn", "turn.tool.approve"), - Tool: toolName, - Arguments: toolArgs, - Channel: ts.channel, - ChatID: ts.chatID, - }) - if !approval.Approved { - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) - al.emitEvent( - EventKindToolExecSkipped, - 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( - EventKindToolExecStart, - ts.eventMeta("runTurn", "turn.tool.start"), - ToolExecStartPayload{ - Tool: toolName, - Arguments: cloneEventArguments(toolArgs), - }, - ) - - // Send tool feedback to chat channel if enabled (from HEAD) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - feedbackPreview := utils.Truncate( - string(argsJSON), - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) - feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview) - fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: feedbackMsg, - }) - fbCancel() - } - - toolCallID := tc.ID - toolIteration := iteration - asyncToolName := toolName - 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: ts.channel, - ChatID: ts.chatID, - Content: result.ForUser, - }) - } - - // Determine content for the agent loop (ForLLM or error). - content := result.ContentForLLM() - if content == "" { - return - } - - // Filter sensitive data before publishing - 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( - EventKindFollowUpQueued, - ts.scope.meta(toolIteration, "runTurn", "turn.follow_up.queued"), - FollowUpQueuedPayload{ - SourceTool: asyncToolName, - Channel: ts.channel, - ChatID: ts.chatID, - ContentLen: len(content), - }, - ) - - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Channel: "system", - SenderID: fmt.Sprintf("async:%s", asyncToolName), - ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), - Content: content, - }) - } - - toolStart := time.Now() - toolResult := ts.agent.Tools.ExecuteWithContext( - turnCtx, - toolName, - toolArgs, - ts.channel, - ts.chatID, - asyncCallback, - ) - toolDuration := time.Since(toolStart) - - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - if al.hooks != nil { - toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.tool.after"), - Tool: toolName, - Arguments: toolArgs, - Result: toolResult, - Duration: toolDuration, - Channel: ts.channel, - ChatID: ts.chatID, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolResp != nil { - if toolResp.Tool != "" { - toolName = toolResp.Tool - } - if toolResp.Result != nil { - toolResult = toolResp.Result - } - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - 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, - 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 if al.bus != nil { - al.bus.PublishOutboundMedia(ctx, outboundMedia) - // Queuing media is only best-effort; it has not been delivered yet. - toolResult.ResponseHandled = false - } - } - - if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { - toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) - } - - if !toolResult.ResponseHandled { - allResponsesHandled = false - } - - if !toolResult.Silent && toolResult.ForUser != "" && ts.opts.SendResponse { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: toolResult.ForUser, - }) - logger.DebugCF("agent", "Sent tool result to user", - map[string]any{ - "tool": toolName, - "content_len": len(toolResult.ForUser), - }) - } - - contentForLLM := toolResult.ContentForLLM() - - // Filter sensitive data (API keys, tokens, secrets) before sending to LLM - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: toolCallID, - } - al.emitEvent( - EventKindToolExecEnd, - 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) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - - skipReason := "" - skipMessage := "" - if len(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( - EventKindToolExecSkipped, - 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 - } - - // Also poll for any SubTurn results that arrived during tool execution. - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) - } - default: - // No results available - } - } - } - - if allResponsesHandled { - if len(pendingMessages) > 0 { - logger.InfoCF("agent", "Pending steering exists after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(pendingMessages), - "session_key": ts.sessionKey, - }) - finalContent = "" - goto turnLoop - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - summaryMsg := providers.Message{ - Role: "assistant", - Content: handledToolResponseSummary, - } - - if !ts.opts.NoHistory { - ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) - ts.recordPersistedMessage(summaryMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - if ts.opts.EnableSummary { - al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope) - } - - ts.setPhase(TurnPhaseCompleted) - ts.setFinalContent("") - 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 turnResult{ - finalContent: "", - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil - } - - ts.agent.Tools.TickTTL() - logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ - "agent_id": ts.agent.ID, "iteration": iteration, - }) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - 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 - } - } - - ts.setPhase(TurnPhaseFinalizing) - ts.setFinalContent(finalContent) - if !ts.opts.NoHistory { - finalMsg := providers.Message{Role: "assistant", Content: finalContent} - ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) - ts.recordPersistedMessage(finalMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - - if ts.opts.EnableSummary { - al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope) - } - - ts.setPhase(TurnPhaseCompleted) - return turnResult{ - finalContent: finalContent, - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil -} - -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( - EventKindError, - ts.eventMeta("abortTurn", "turn.error"), - ErrorPayload{ - Stage: "session_restore", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - return turnResult{status: TurnEndStatusAborted}, nil -} - -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 - } -} - -// 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, 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 -} - -// maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { - newHistory := agent.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.summarizeSession(agent, sessionKey, turnScope) - }() - } - } -} - -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. -// -// If the history is a single Turn with no safe split point, the function -// falls back to keeping only the most recent user message. This breaks -// Turn atomicity as a last resort to avoid a context-exceeded loop. -// -// Session history contains only user/assistant/tool messages — the system -// prompt is built dynamically by BuildMessages and is NOT stored here. -// The compression note is recorded in the session summary so that -// BuildMessages can include it in the next system prompt. -func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) { - history := agent.Sessions.GetHistory(sessionKey) - if len(history) <= 2 { - return compressionResult{}, false - } - - // Split at a Turn boundary so no tool-call sequence is torn apart. - // parseTurnBoundaries gives us the start of each Turn; we drop the - // oldest half of Turns and keep the most recent ones. - turns := parseTurnBoundaries(history) - var mid int - if len(turns) >= 2 { - mid = turns[len(turns)/2] - } else { - // Fewer than 2 Turns — fall back to message-level midpoint - // aligned to the nearest Turn boundary. - mid = findSafeBoundary(history, len(history)/2) - } - var keptHistory []providers.Message - if mid <= 0 { - // No safe Turn boundary — the entire history is a single Turn - // (e.g. one user message followed by a massive tool response). - // Keeping everything would leave the agent stuck in a context- - // exceeded loop, so fall back to keeping only the most recent - // user message. This breaks Turn atomicity as a last resort. - 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) - - // Record compression in the session summary so BuildMessages includes it - // in the system prompt. We do not modify history messages themselves. - 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 -} - -// GetStartupInfo returns information about loaded tools and skills for logging. -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 -} - -// 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() -} - -// summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - history := agent.Sessions.GetHistory(sessionKey) - summary := agent.Sessions.GetSummary(sessionKey) - - // Keep the most recent Turns for continuity, aligned to a Turn boundary - // so that no tool-call sequence is split. - if len(history) <= 4 { - return - } - - safeCut := findSafeBoundary(history, len(history)-4) - if safeCut <= 0 { - return - } - keepCount := len(history) - safeCut - toSummarize := history[:safeCut] - - // 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 - } - - const ( - maxSummarizationMessages = 10 - llmMaxRetries = 3 - llmTemperature = 0.3 - fallbackMaxContentLength = 200 - ) - - // Multi-Part Summarization - var finalSummary string - if len(validMessages) > maxSummarizationMessages { - mid := len(validMessages) / 2 - - mid = al.findNearestUserMessage(validMessages, mid) - - part1 := validMessages[:mid] - part2 := validMessages[mid:] - - s1, _ := al.summarizeBatch(ctx, agent, part1, "") - s2, _ := al.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 := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) - if err == nil && resp.Content != "" { - finalSummary = resp.Content - } else { - finalSummary = s1 + " " + s2 - } - } else { - finalSummary, _ = al.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) - al.emitEvent( - EventKindSessionSummarize, - turnScope.meta(0, "summarizeSession", "turn.session.summarize"), - SessionSummarizePayload{ - SummarizedMessages: len(validMessages), - KeptMessages: keepCount, - SummaryLen: len(finalSummary), - OmittedOversized: omitted, - }, - ) - } -} - -// findNearestUserMessage finds the nearest user message to the given index. -// It searches backward first, then forward if no user message is found. -func (al *AgentLoop) 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 -} - -// retryLLMCall calls the LLM with retry logic. -func (al *AgentLoop) 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++ { - al.activeRequests.Add(1) - resp, err = func() (*providers.LLMResponse, error) { - defer 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 -} - -// summarizeBatch summarizes a batch of messages. -func (al *AgentLoop) summarizeBatch( - ctx context.Context, - agent *AgentInstance, - batch []providers.Message, - existingSummary string, -) (string, error) { - const ( - llmMaxRetries = 3 - llmTemperature = 0.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 _, m := range batch { - fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) - } - prompt := sb.String() - - response, err := al.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, m := range batch { - if i > 0 { - fallback.WriteString(" | ") - } - content := strings.TrimSpace(m.Content) - runes := []rune(content) - if len(runes) == 0 { - fallback.WriteString(fmt.Sprintf("%s: ", m.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", m.Role, content)) - } - return fallback.String(), nil -} - -// 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. -func (al *AgentLoop) estimateTokens(messages []providers.Message) int { - total := 0 - for _, m := range messages { - total += estimateMessageTokens(m) - } - return total -} - -func (al *AgentLoop) handleCommand( - ctx context.Context, - msg bus.InboundMessage, - agent *AgentInstance, - opts *processOptions, -) (string, bool) { - 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(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 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 (al *AgentLoop) applyExplicitSkillCommand( - raw string, - agent *AgentInstance, - opts *processOptions, -) (matched bool, handled bool, reply string) { - 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.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.SessionKey) == "" { - return true, true, commandsUnavailableSkillMessage() - } - al.setPendingSkills(opts.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.UserMessage = message - } - - return true, false, "" -} - -func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime { - registry := al.GetRegistry() - cfg := al.GetConfig() - rt := &commands.Runtime{ - Config: cfg, - ListAgentIDs: registry.ListAgentIDs, - ListDefinitions: al.cmdRegistry.Definitions, - 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 - }, - } - 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, modelCfg.Model, 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") - } - if agent.Sessions == nil { - return fmt.Errorf("sessions not initialized for agent") - } - - agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0)) - agent.Sessions.SetSummary(opts.SessionKey, "") - agent.Sessions.Save(opts.SessionKey) - return nil - } - } - return rt -} - -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 (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) -} - -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} -} - -// isNativeSearchProvider reports whether the given LLM provider implements -// NativeSearchCapable and returns true for SupportsNativeSearch. -func isNativeSearchProvider(p providers.LLMProvider) bool { - if ns, ok := p.(providers.NativeSearchCapable); ok { - return ns.SupportsNativeSearch() - } - return false -} - -// filterClientWebSearch returns a copy of tools with the client-side -// web_search tool removed. Used when native provider search is preferred. -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 -} - -// Helper to extract provider from registry for cleanup -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/loop_mcp_test.go b/pkg/agent/loop_mcp_test.go deleted file mode 100644 index ee00d22ba..000000000 --- a/pkg/agent/loop_mcp_test.go +++ /dev/null @@ -1,180 +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 ( - "os" - "path/filepath" - "testing" - - "github.com/sipeed/picoclaw/pkg/config" -) - -func boolPtr(b bool) *bool { return &b } - -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 TestResolveAgentMCPServerAllowlist(t *testing.T) { - workspace := t.TempDir() - agentPath := filepath.Join(workspace, "AGENT.md") - content := `--- -mcpServers: [GitHub, filesystem, github] ---- -# Agent -` - if err := os.WriteFile(agentPath, []byte(content), 0o644); err != nil { - t.Fatalf("WriteFile(AGENT.md) error = %v", err) - } - - allowlist := resolveAgentMCPServerAllowlist(loadAgentDefinition(workspace)) - if len(allowlist) != 2 { - t.Fatalf("len(allowlist) = %d, want 2", len(allowlist)) - } - if _, ok := allowlist["github"]; !ok { - t.Fatal("expected github to be present in MCP allowlist") - } - if _, ok := allowlist["filesystem"]; !ok { - t.Fatal("expected filesystem to be present in MCP allowlist") - } -} - -func TestAgentInstance_AllowsMCPServer(t *testing.T) { - t.Run("nil allowlist allows all", func(t *testing.T) { - agent := &AgentInstance{} - if !agent.AllowsMCPServer("github") { - t.Fatal("expected nil MCP allowlist to allow all servers") - } - }) - - t.Run("explicit allowlist filters servers", func(t *testing.T) { - agent := &AgentInstance{ - MCPServerAllowlist: map[string]struct{}{ - "github": {}, - }, - } - if !agent.AllowsMCPServer("GitHub") { - t.Fatal("expected MCP server matching to be case-insensitive") - } - if agent.AllowsMCPServer("filesystem") { - t.Fatal("expected filesystem to be blocked by MCP allowlist") - } - }) -} - -func TestAgentRegistry_AllowedMCPServers(t *testing.T) { - t.Run("returns nil when any agent allows all servers", func(t *testing.T) { - registry := &AgentRegistry{ - agents: map[string]*AgentInstance{ - "main": {ID: "main", MCPServerAllowlist: nil}, - "research": {ID: "research", MCPServerAllowlist: map[string]struct{}{"github": {}}}, - }, - } - - if allowed := registry.allowedMCPServers(); allowed != nil { - t.Fatalf("expected nil union when one agent allows all, got %v", allowed) - } - }) - - t.Run("returns union of explicit allowlists", func(t *testing.T) { - registry := &AgentRegistry{ - agents: map[string]*AgentInstance{ - "main": {ID: "main", MCPServerAllowlist: map[string]struct{}{"github": {}}}, - "research": {ID: "research", MCPServerAllowlist: map[string]struct{}{"filesystem": {}}}, - }, - } - - allowed := registry.allowedMCPServers() - if len(allowed) != 2 { - t.Fatalf("len(allowed) = %d, want 2", len(allowed)) - } - if _, ok := allowed["github"]; !ok { - t.Fatal("expected github in allowed MCP server union") - } - if _, ok := allowed["filesystem"]; !ok { - t.Fatal("expected filesystem in allowed MCP server union") - } - }) -} - -func TestFilterMCPConfigServers(t *testing.T) { - mcpCfg := config.MCPConfig{ - ToolConfig: config.ToolConfig{Enabled: true}, - Servers: map[string]config.MCPServerConfig{ - "github": {Enabled: true}, - "filesystem": {Enabled: true}, - }, - } - - filtered := filterMCPConfigServers(mcpCfg, map[string]struct{}{"github": {}}) - if len(filtered.Servers) != 1 { - t.Fatalf("len(filtered.Servers) = %d, want 1", len(filtered.Servers)) - } - if _, ok := filtered.Servers["github"]; !ok { - t.Fatal("expected github server to remain after filtering") - } - if _, ok := filtered.Servers["filesystem"]; ok { - t.Fatal("expected filesystem server to be removed by filtering") - } -} diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 140cff718..6065f6403 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -8,44 +8,111 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) -func buildModelListResolver(cfg *config.Config) 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 +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 } - return func(raw string) (string, bool) { - raw = strings.TrimSpace(raw) - if raw == "" || cfg == nil { - return "", false - } - - 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 + 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( @@ -54,14 +121,29 @@ func resolveModelCandidates( primary string, fallbacks []string, ) []providers.FallbackCandidate { - return providers.ResolveCandidatesWithLookup( - providers.ModelConfig{ - Primary: primary, - Fallbacks: fallbacks, - }, - defaultProvider, - buildModelListResolver(cfg), - ) + 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 { 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..02c850360 --- /dev/null +++ b/pkg/agent/prompt.go @@ -0,0 +1,504 @@ +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" + PromptSourceAgentDiscovery PromptSourceID = "agent:discovery" + 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: PromptSourceAgentDiscovery, + Owner: "agent", + Description: "Structured multi-agent discovery registry", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + 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..863df57b2 --- /dev/null +++ b/pkg/agent/prompt_contributors.go @@ -0,0 +1,180 @@ +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 +} + +type agentDiscoveryPromptContributor struct { + workspace string + discover func(workspace string) []AgentDescriptor +} + +func (c agentDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { + return PromptSourceDescriptor{ + ID: PromptSourceAgentDiscovery, + Owner: "agent", + Description: "Structured multi-agent discovery registry", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: false, + } +} + +func (c agentDiscoveryPromptContributor) ContributePrompt( + _ context.Context, + _ PromptBuildRequest, +) ([]PromptPart, error) { + if c.discover == nil { + return nil, nil + } + content := formatAgentDiscoverySection(c.discover(c.workspace)) + if strings.TrimSpace(content) == "" { + return nil, nil + } + + return []PromptPart{ + { + ID: "capability.agent_discovery", + Layer: PromptLayerCapability, + Slot: PromptSlotTooling, + Source: PromptSource{ID: PromptSourceAgentDiscovery, Name: "agent:discovery"}, + Title: "agent discovery", + Content: content, + Stable: false, + Cache: PromptCacheNone, + }, + }, 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 1eba72250..c6a1246ac 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" @@ -72,9 +73,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. 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 index ad6613e8c..7bddbfc31 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -3,12 +3,15 @@ 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/routing" + "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -153,6 +156,18 @@ func (sq *steeringQueue) lenScope(scope string) int { 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() @@ -185,6 +200,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers 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(), @@ -203,7 +219,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers "scope": normalizeSteeringScope(scope), }) - meta := EventMeta{ + meta := HookMeta{ Source: "Steer", TracePath: "turn.interrupt.received", } @@ -227,7 +243,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers } al.emitEvent( - EventKindInterruptReceived, + runtimeevents.KindAgentInterruptReceived, meta, InterruptReceivedPayload{ Kind: InterruptKindSteering, @@ -286,16 +302,33 @@ func (al *AgentLoop) pendingSteeringCountForScope(scope string) int { 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{ - SessionKey: sessionKey, - Channel: channel, - ChatID: chatID, + Dispatch: dispatch, DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, @@ -310,9 +343,19 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { return nil } - if parsed := routing.ParseAgentSessionKey(sessionKey); parsed != nil { - if agent, ok := registry.GetAgent(parsed.AgentID); ok { - return agent + 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 } } @@ -326,33 +369,55 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { // // If no steering messages are pending, it returns an empty string. func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) { - if active := al.GetActiveTurn(); active != nil { - return "", fmt.Errorf("turn %s is still active", active.TurnID) + // 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() }); ok { - resetter.ResetSentInRound() + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) } } - return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, steeringMsgs) + 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 { @@ -365,7 +430,7 @@ func (al *AgentLoop) InterruptGraceful(hint string) error { } al.emitEvent( - EventKindInterruptReceived, + runtimeevents.KindAgentInterruptReceived, ts.eventMeta("InterruptGraceful", "turn.interrupt.received"), InterruptReceivedPayload{ Kind: InterruptKindGraceful, @@ -376,17 +441,24 @@ func (al *AgentLoop) InterruptGraceful(hint string) error { 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( - EventKindInterruptReceived, + runtimeevents.KindAgentInterruptReceived, ts.eventMeta("InterruptHard", "turn.interrupt.received"), InterruptReceivedPayload{ Kind: InterruptKindHard, @@ -447,6 +519,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error { 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, @@ -454,6 +530,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error { "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. diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 75ba9861d..813013649 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -14,9 +14,11 @@ import ( "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" ) @@ -340,97 +342,6 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) { } } -func TestDrainBusToSteering_RequeuesDifferentScopeMessage(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{ - DMScope: "per-peer", - }, - } - - msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, &mockProvider{}) - - activeMsg := bus.InboundMessage{ - Channel: "telegram", - SenderID: "user1", - ChatID: "chat1", - Content: "active turn", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, - } - activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg) - if !ok { - t.Fatal("expected active message to resolve to a steering scope") - } - - otherMsg := bus.InboundMessage{ - Channel: "telegram", - SenderID: "user2", - ChatID: "chat2", - Content: "other session", - Peer: bus.Peer{ - Kind: "direct", - ID: "user2", - }, - } - otherScope, _, ok := al.resolveSteeringTarget(otherMsg) - if !ok { - t.Fatal("expected other message to resolve to a steering scope") - } - if otherScope == activeScope { - t.Fatalf("expected different steering scopes, got same scope %q", activeScope) - } - - if err := msgBus.PublishInbound(context.Background(), otherMsg); err != nil { - t.Fatalf("PublishInbound failed: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - done := make(chan struct{}) - go func() { - al.drainBusToSteering(ctx, activeScope, activeAgentID) - close(done) - }() - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for drainBusToSteering to stop") - } - - if msgs := al.dequeueSteeringMessagesForScope(activeScope); len(msgs) != 0 { - t.Fatalf("expected no steering messages for active scope, got %v", msgs) - } - - select { - case <-ctx.Done(): - t.Fatalf("timeout waiting for requeued message on outbound bus") - case requeued := <-msgBus.OutboundChan(): - if requeued.Channel != otherMsg.Channel || requeued.ChatID != otherMsg.ChatID || - requeued.Content != otherMsg.Content { - t.Fatalf("requeued message mismatch: got %+v want %+v", requeued, otherMsg) - } - } -} - // slowTool simulates a tool that takes some time to execute. type slowTool struct { name string @@ -841,24 +752,22 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { }() first := bus.InboundMessage{ - Channel: "test", - SenderID: "user1", - ChatID: "chat1", - Content: "first message", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", }, + Content: "first message", } late := bus.InboundMessage{ - Channel: "test", - SenderID: "user1", - ChatID: "chat1", - Content: "late append", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", }, + Content: "late append", } pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -931,6 +840,191 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { } } +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 { @@ -949,7 +1043,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing. }, } - sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) provider := &blockingDirectProvider{ firstStarted: make(chan struct{}), releaseFirst: make(chan struct{}), @@ -1013,6 +1107,62 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing. } } +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 { @@ -1060,7 +1210,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { }, } - sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) msgBus := bus.NewMessageBus() al := NewAgentLoop(cfg, msgBus, provider) al.SetMediaStore(store) @@ -1087,16 +1237,16 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { foundResolvedMedia := false for _, msg := range msgs { - if msg.Role != "user" || msg.Content != "describe this image" || len(msg.Media) != 1 { + if msg.Role != "user" || !strings.Contains(msg.Content, "describe this image") { continue } - if strings.HasPrefix(msg.Media[0], "data:image/png;base64,") { + if strings.Contains(msg.Content, "[image:") { foundResolvedMedia = true break } } if !foundResolvedMedia { - t.Fatal("expected continue path to inject steering media into the provider request") + t.Fatal("expected continue path to inject image path tag into the provider request") } defaultAgent := al.registry.GetDefaultAgent() @@ -1168,10 +1318,16 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { al := NewAgentLoop(cfg, msgBus, provider) al.RegisterTool(tool1) al.RegisterTool(tool2) - sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentInterruptReceived, + runtimeevents.KindAgentTurnEnd, + ) + defer closeRuntimeEvents() type result struct { resp string @@ -1258,8 +1414,8 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { t.Fatal("expected remaining tool to be marked as skipped after graceful interrupt") } - events := collectEventStream(sub.C) - interruptEvt, ok := findEvent(events, EventKindInterruptReceived) + events := collectRuntimeEventStream(runtimeCh) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) if !ok { t.Fatal("expected interrupt received event") } @@ -1271,7 +1427,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { t.Fatalf("expected graceful interrupt payload, got %q", interruptPayload.Kind) } - turnEndEvt, ok := findEvent(events, EventKindTurnEnd) + turnEndEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentTurnEnd) if !ok { t.Fatal("expected turn end event") } @@ -1322,7 +1478,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { al := NewAgentLoop(cfg, msgBus, provider) started := make(chan struct{}) al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) - sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { @@ -1335,8 +1491,14 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { } defaultAgent.Sessions.SetHistory(sessionKey, originalHistory) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentInterruptReceived, + runtimeevents.KindAgentTurnEnd, + ) + defer closeRuntimeEvents() type result struct { resp string @@ -1389,8 +1551,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { t.Fatalf("expected history rollback after hard abort, got %#v", finalHistory) } - events := collectEventStream(sub.C) - interruptEvt, ok := findEvent(events, EventKindInterruptReceived) + events := collectRuntimeEventStream(runtimeCh) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) if !ok { t.Fatal("expected interrupt received event") } @@ -1402,7 +1564,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { t.Fatalf("expected hard interrupt payload, got %q", interruptPayload.Kind) } - turnEndEvt, ok := findEvent(events, EventKindTurnEnd) + turnEndEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentTurnEnd) if !ok { t.Fatal("expected turn end event") } @@ -1415,6 +1577,149 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { } } +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 diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index f5ba412ab..86617d02f 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -8,8 +8,10 @@ import ( "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" ) @@ -172,7 +174,10 @@ type SubTurnConfig struct { // Used by team tool to enforce token limits across all team members. InitialTokenBudget *atomic.Int64 - // Can be extended with temperature, topP, etc. + // 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 ====================== @@ -230,6 +235,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn( Critical: cfg.Critical, Timeout: cfg.Timeout, MaxContextRunes: cfg.MaxContextRunes, + TargetAgentID: cfg.TargetAgentID, } return spawnSubTurn(ctx, s.al, parentTS, agentCfg) @@ -312,8 +318,9 @@ func spawnSubTurn( return nil, ErrDepthLimitExceeded } - // 2. Config validation - if cfg.Model == "" { + // 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 } @@ -331,12 +338,22 @@ func spawnSubTurn( childID := al.generateSubTurnID() - // Get the agent instance from parent, falling back to the default agent. - // Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store - // so that child turns never pollute or persist to the parent's session history. - baseAgent := parentTS.agent - if baseAgent == nil { - baseAgent = al.registry.GetDefaultAgent() + // 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") @@ -351,15 +368,17 @@ func spawnSubTurn( } // Create processOptions for the child turn + dispatch := DispatchRequest{ + SessionKey: childID, + UserMessage: cfg.SystemPrompt, + Media: nil, + InboundContext: cloneInboundContext(parentTS.opts.Dispatch.InboundContext), + } opts := processOptions{ - SessionKey: childID, - Channel: parentTS.channel, - ChatID: parentTS.chatID, - SenderID: parentTS.opts.SenderID, + Dispatch: dispatch, + SenderID: parentTS.opts.Dispatch.SenderID(), SenderDisplayName: parentTS.opts.SenderDisplayName, - UserMessage: cfg.SystemPrompt, // Task description becomes the first user message SystemPromptOverride: cfg.ActualSystemPrompt, - Media: nil, InitialSteeringMessages: cfg.InitialMessages, DefaultResponse: "", EnableSummary: false, @@ -369,7 +388,11 @@ func spawnSubTurn( } // Create event scope for the child turn - scope := al.newTurnEventScope(agent.ID, childID) + 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) @@ -415,7 +438,7 @@ func spawnSubTurn( parentTS.mu.Unlock() // 6. Emit Spawn event - al.emitEvent(EventKindSubTurnSpawn, + al.emitEvent(runtimeevents.KindAgentSubTurnSpawn, childTS.eventMeta("spawnSubTurn", "subturn.spawn"), SubTurnSpawnPayload{ AgentID: childTS.agentID, @@ -427,6 +450,7 @@ func spawnSubTurn( // 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{ @@ -445,7 +469,7 @@ func spawnSubTurn( if err != nil { status = "error" } - al.emitEvent(EventKindSubTurnEnd, + al.emitEvent(runtimeevents.KindAgentSubTurnEnd, childTS.eventMeta("spawnSubTurn", "subturn.end"), SubTurnEndPayload{ AgentID: childTS.agentID, @@ -455,7 +479,8 @@ func spawnSubTurn( }() // 8. Execute sub-turn via the real agent loop. - turnRes, turnErr := al.runTurn(childCtx, childTS) + 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: @@ -495,28 +520,29 @@ func spawnSubTurn( // // Delivery behavior: // - If parent turn is still running: attempts to deliver to pendingResults channel -// - If channel is full: emits SubTurnOrphanResultEvent (result is lost from channel but tracked) -// - If parent turn has finished: emits SubTurnOrphanResultEvent (late arrival) +// - 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: -// - SubTurnResultDeliveredEvent: successful delivery to channel -// - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full) +// - 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(EventKindSubTurnOrphan, + al.emitEvent(runtimeevents.KindAgentSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "panic"}, ) @@ -531,7 +557,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re // If parent turn has already finished, treat this as an orphan result if isFinished || resultChan == nil { if result != nil && al != nil { - al.emitEvent(EventKindSubTurnOrphan, + al.emitEvent(runtimeevents.KindAgentSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"}, ) @@ -547,7 +573,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re case resultChan <- result: // Successfully delivered if al != nil { - al.emitEvent(EventKindSubTurnResultDelivered, + al.emitEvent(runtimeevents.KindAgentSubTurnResultDelivered, parentTS.eventMeta("deliverSubTurnResult", "subturn.result_delivered"), SubTurnResultDeliveredPayload{ContentLen: len(result.ForLLM)}, ) @@ -561,7 +587,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re }) if result != nil && al != nil { al.emitEvent( - EventKindSubTurnOrphan, + runtimeevents.KindAgentSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), SubTurnOrphanPayload{ ParentTurnID: parentTS.turnID, @@ -602,6 +628,7 @@ type ephemeralSessionStoreIface interface { SetHistory(key string, history []providers.Message) TruncateHistory(key string, keepLast int) Save(key string) error + ListSessions() []string Close() error } @@ -613,6 +640,10 @@ func (e *ephemeralSessionStore) AddMessage(_, role, content string) { } 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) @@ -642,6 +673,7 @@ func (e *ephemeralSessionStore) SetSummary(_, summary string) { 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() @@ -661,8 +693,9 @@ func (e *ephemeralSessionStore) TruncateHistory(_ string, keepLast int) { 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) 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 { diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 6a2ba835d..e9f557c82 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -4,12 +4,16 @@ 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" ) @@ -22,30 +26,38 @@ const ( // ====================== Test Helper: Event Collector ====================== type eventCollector struct { mu sync.Mutex - events []Event + events []runtimeevents.Event } func newEventCollector(t *testing.T, al *AgentLoop) (*eventCollector, func()) { t.Helper() c := &eventCollector{} - sub := al.SubscribeEvents(16) + 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 sub.C { + for evt := range runtimeCh { c.mu.Lock() c.events = append(c.events, evt) c.mu.Unlock() } }() cleanup := func() { - al.UnsubscribeEvents(sub.ID) + closeRuntimeEvents() <-done } return c, cleanup } -func (c *eventCollector) hasEventOfKind(kind EventKind) bool { +func (c *eventCollector) hasEventOfKind(kind runtimeevents.Kind) bool { c.mu.Lock() defer c.mu.Unlock() for _, e := range c.events { @@ -131,7 +143,7 @@ func TestSpawnSubTurn(t *testing.T) { agent: al.registry.GetDefaultAgent(), } - // Subscribe to real EventBus to capture events + // Subscribe to runtime events to capture sub-turn lifecycle. collector, collectCleanup := newEventCollector(t, al) defer collectCleanup() @@ -158,12 +170,12 @@ func TestSpawnSubTurn(t *testing.T) { // Verify event emission time.Sleep(10 * time.Millisecond) // let event goroutine flush if tt.wantSpawn { - if !collector.hasEventOfKind(EventKindSubTurnSpawn) { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnSpawn) { t.Error("SubTurnSpawnEvent not emitted") } } if tt.wantEnd { - if !collector.hasEventOfKind(EventKindSubTurnEnd) { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnEnd) { t.Error("SubTurnEndEvent not emitted") } } @@ -316,8 +328,8 @@ func TestSpawnSubTurn_OrphanResultRouting(t *testing.T) { time.Sleep(10 * time.Millisecond) // let event goroutine flush // Verify Orphan event is emitted - if !collector.hasEventOfKind(EventKindSubTurnOrphan) { - t.Error("SubTurnOrphanResultEvent not emitted for finished parent") + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnOrphan) { + t.Error("agent.subturn.orphan not emitted for finished parent") } // Verify history is NOT polluted @@ -591,12 +603,16 @@ func TestNestedSubTurnHierarchy(t *testing.T) { var spawnedTurns []turnInfo var mu sync.Mutex - // Subscribe to real EventBus to capture spawn events - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSubTurnSpawn, + ) + defer closeRuntimeEvents() go func() { - for evt := range sub.C { - if evt.Kind == EventKindSubTurnSpawn { + for evt := range runtimeCh { + if evt.Kind == runtimeevents.KindAgentSubTurnSpawn { p, _ := evt.Payload.(SubTurnSpawnPayload) mu.Lock() spawnedTurns = append(spawnedTurns, turnInfo{ @@ -879,7 +895,7 @@ func TestSpawnSubTurn_PanicRecovery(t *testing.T) { time.Sleep(10 * time.Millisecond) // let event goroutine flush // SubTurnEndEvent should still be emitted - if !collector.hasEventOfKind(EventKindSubTurnEnd) { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnEnd) { t.Error("SubTurnEndEvent not emitted after panic") } @@ -1229,18 +1245,23 @@ func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) { al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled defer cleanup() - // Collect events via real EventBus var mu sync.Mutex var deliveredCount, orphanCount int - sub := al.SubscribeEvents(64) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 64, + runtimeevents.KindAgentSubTurnResultDelivered, + runtimeevents.KindAgentSubTurnOrphan, + ) + defer closeRuntimeEvents() go func() { - for evt := range sub.C { + for evt := range runtimeCh { mu.Lock() switch evt.Kind { - case EventKindSubTurnResultDelivered: + case runtimeevents.KindAgentSubTurnResultDelivered: deliveredCount++ - case EventKindSubTurnOrphan: + case runtimeevents.KindAgentSubTurnOrphan: orphanCount++ } mu.Unlock() @@ -1650,6 +1671,38 @@ func TestGrandchildAbort_CascadingCancellation(t *testing.T) { } } +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) { @@ -1763,13 +1816,20 @@ func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) { provider := &slowMockProvider{delay: 5 * time.Second} // SubTurn takes 5 seconds al := NewAgentLoop(cfg, msgBus, provider) - // Capture events via real EventBus var mu sync.Mutex - var events []Event - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + 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 sub.C { + for evt := range runtimeCh { mu.Lock() events = append(events, evt) mu.Unlock() @@ -2065,3 +2125,206 @@ func TestSubTurn_IndependentContext(t *testing.T) { 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.go b/pkg/agent/turn_state.go similarity index 61% rename from pkg/agent/turn.go rename to pkg/agent/turn_state.go index e4970c519..b769ebcd0 100644 --- a/pkg/agent/turn.go +++ b/pkg/agent/turn_state.go @@ -1,3 +1,5 @@ +// PicoClaw - Ultra-lightweight personal AI agent + package agent import ( @@ -8,11 +10,16 @@ import ( "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 ( @@ -24,6 +31,65 @@ const ( 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 @@ -39,12 +105,70 @@ type ActiveTurnInfo struct { ChildTurnIDs []string } -type turnResult struct { +// ============================================================================= +// 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 - status TurnEndStatus - followUps []bus.InboundMessage + + // 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 @@ -55,6 +179,7 @@ type turnState struct { turnID string agentID string sessionKey string + turnCtx *TurnContext channel string chatID string @@ -107,6 +232,10 @@ type turnState struct { al *AgentLoop } +// ============================================================================= +// turnState constructors and active turn management +// ============================================================================= + func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState { ts := &turnState{ agent: agent, @@ -114,11 +243,12 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop scope: scope, turnID: scope.turnID, agentID: agent.ID, - sessionKey: opts.SessionKey, - channel: opts.Channel, - chatID: opts.ChatID, - userMessage: opts.UserMessage, - media: append([]string(nil), opts.Media...), + 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(), } @@ -126,7 +256,10 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop // Bind session store and capture initial history length for rollback logic if agent != nil && agent.Sessions != nil { ts.session = agent.Sessions - ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.SessionKey)) + 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 @@ -142,7 +275,11 @@ func (al *AgentLoop) clearActiveTurn(ts *turnState) { func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState { if val, ok := al.activeTurnStates.Load(sessionKey); ok { - return val.(*turnState) + 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 } @@ -151,8 +288,11 @@ func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState { func (al *AgentLoop) getAnyActiveTurnState() *turnState { var firstTS *turnState al.activeTurnStates.Range(func(key, value any) bool { - firstTS = value.(*turnState) - return false // stop after first + if ts, ok := value.(*turnState); ok { + firstTS = ts + return false + } + return true }) return firstTS } @@ -162,8 +302,11 @@ func (al *AgentLoop) GetActiveTurn() *ActiveTurnInfo { // In the new architecture, there can be multiple concurrent turns var firstTS *turnState al.activeTurnStates.Range(func(key, value any) bool { - firstTS = value.(*turnState) - return false // stop after first + if ts, ok := value.(*turnState); ok { + firstTS = ts + return false + } + return true }) if firstTS == nil { return nil @@ -181,6 +324,10 @@ func (al *AgentLoop) GetActiveTurnBySession(sessionKey string) *ActiveTurnInfo { return &info } +// ============================================================================= +// turnState - getters and setters +// ============================================================================= + func (ts *turnState) snapshot() ActiveTurnInfo { ts.mu.RLock() defer ts.mu.RUnlock() @@ -298,15 +445,16 @@ func (ts *turnState) hardAbortRequested() bool { return ts.hardAbort } -func (ts *turnState) eventMeta(source, tracePath string) EventMeta { +func (ts *turnState) eventMeta(source, tracePath string) HookMeta { snap := ts.snapshot() - return EventMeta{ - AgentID: snap.AgentID, - TurnID: snap.TurnID, - SessionKey: snap.SessionKey, - Iteration: snap.Iteration, - Source: source, - TracePath: tracePath, + return HookMeta{ + AgentID: snap.AgentID, + TurnID: snap.TurnID, + SessionKey: snap.SessionKey, + Iteration: snap.Iteration, + Source: source, + TracePath: tracePath, + turnContext: cloneTurnContext(ts.turnCtx), } } @@ -338,6 +486,23 @@ func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) { 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...) @@ -365,13 +530,12 @@ func (ts *turnState) interruptHintMessage() providers.Message { if hint != "" { content += "\n\nInterrupt hint: " + hint } - return providers.Message{ - Role: "user", - Content: content, - } + return interruptPromptMessage(content) } +// ============================================================================= // SubTurn-related methods +// ============================================================================= // Finish marks the turn as finished and closes the pendingResults channel func (ts *turnState) Finish(isHardAbort bool) { @@ -390,9 +554,9 @@ func (ts *turnState) Finish(isHardAbort bool) { ts.mu.Unlock() }) - // If this is a graceful finish (not hard abort), signal to children - if !isHardAbort && ts.parentTurnState == nil { - // This is a root turn finishing gracefully + // 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) } @@ -408,7 +572,9 @@ func (ts *turnState) Finish(isHardAbort bool) { ts.mu.RUnlock() for _, childID := range children { if val, ok := ts.al.activeTurnStates.Load(childID); ok { - val.(*turnState).Finish(true) + if child, ok := val.(*turnState); ok { + child.Finish(true) + } } } } @@ -460,7 +626,9 @@ func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) { ts.lastUsage = usage } -// Context helper functions for SubTurn +// ============================================================================= +// Context helper functions for turnState +// ============================================================================= type turnStateKeyType struct{} 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/voice/transcriber_test.go b/pkg/audio/asr/asr_test.go similarity index 63% rename from pkg/voice/transcriber_test.go rename to pkg/audio/asr/asr_test.go index 3e71ff13a..f877b1198 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/audio/asr/asr_test.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "testing" @@ -33,26 +33,83 @@ func TestDetectTranscriber(t *testing.T) { wantName: "audio-model", }, { - name: "groq via model list", + 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/llama-3.3-70b", + Model: "groq/whisper-large-v3-turbo", APIKeys: config.SimpleSecureStrings("sk-groq-model"), }, }, }, - wantName: "groq", + wantName: "whisper", }, { - name: "voice model name selects non-gemini audio model transcriber", + name: "voice model name alias selects non-gemini audio model transcriber", cfg: &config.Config{ - Voice: config.VoiceConfig{ModelName: "voice-openai-audio"}, + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, ModelList: []*config.ModelConfig{ { - ModelName: "voice-openai-audio", + ModelName: "my-asr-model", Model: "openai/gpt-4o-audio-preview", APIKeys: config.SimpleSecureStrings("sk-openai"), }, @@ -92,7 +149,7 @@ func TestDetectTranscriber(t *testing.T) { name: "groq model list entry without key is skipped", cfg: &config.Config{ ModelList: []*config.ModelConfig{ - {Model: "groq/llama-3.3-70b"}, + {Model: "groq/whisper-large-v3"}, }, }, wantNil: true, @@ -103,12 +160,12 @@ func TestDetectTranscriber(t *testing.T) { ModelList: []*config.ModelConfig{ { ModelName: "groq", - Model: "groq/llama-3.3-70b", + Model: "groq/whisper-large-v3", APIKeys: config.SimpleSecureStrings("sk-groq-model"), }, }, }, - wantName: "groq", + wantName: "whisper", }, { name: "missing voice model name config returns nil", @@ -127,15 +184,17 @@ func TestDetectTranscriber(t *testing.T) { { name: "elevenlabs voice config key", cfg: &config.Config{ - Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, + 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{ - Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, ModelList: []*config.ModelConfig{ + {Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, { ModelName: "groq", Model: "groq/llama-3.3-70b", @@ -149,10 +208,10 @@ func TestDetectTranscriber(t *testing.T) { name: "voice model name takes priority over elevenlabs", cfg: &config.Config{ Voice: config.VoiceConfig{ - ModelName: "voice-gemini", - ElevenLabsAPIKey: "sk_elevenlabs_test", + ModelName: "voice-gemini", }, ModelList: []*config.ModelConfig{ + {Model: "elevenlabs", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, { ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash", diff --git a/pkg/voice/audio_model_transcriber.go b/pkg/audio/asr/audio_model_transcriber.go similarity index 99% rename from pkg/voice/audio_model_transcriber.go rename to pkg/audio/asr/audio_model_transcriber.go index f3ca81961..e8ded15dd 100644 --- a/pkg/voice/audio_model_transcriber.go +++ b/pkg/audio/asr/audio_model_transcriber.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "context" diff --git a/pkg/voice/audio_model_transcriber_test.go b/pkg/audio/asr/audio_model_transcriber_test.go similarity index 99% rename from pkg/voice/audio_model_transcriber_test.go rename to pkg/audio/asr/audio_model_transcriber_test.go index c33e3bf97..5aaa82061 100644 --- a/pkg/voice/audio_model_transcriber_test.go +++ b/pkg/audio/asr/audio_model_transcriber_test.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "context" diff --git a/pkg/voice/elevenlabs_transcriber.go b/pkg/audio/asr/elevenlabs_transcriber.go similarity index 91% rename from pkg/voice/elevenlabs_transcriber.go rename to pkg/audio/asr/elevenlabs_transcriber.go index 93db10f8d..a89d62848 100644 --- a/pkg/voice/elevenlabs_transcriber.go +++ b/pkg/audio/asr/elevenlabs_transcriber.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "bytes" @@ -20,15 +20,24 @@ import ( type ElevenLabsTranscriber struct { apiKey string apiBase string + modelID string httpClient *http.Client } -func NewElevenLabsTranscriber(apiKey string) *ElevenLabsTranscriber { +func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber { logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) + if apiBase == "" { + apiBase = "https://api.elevenlabs.io" + } + if modelID == "" || modelID != ElevenLabsSupportedModelID() { + modelID = ElevenLabsSupportedModelID() + } + return &ElevenLabsTranscriber{ apiKey: apiKey, - apiBase: "https://api.elevenlabs.io", + apiBase: apiBase, + modelID: modelID, httpClient: &http.Client{ Timeout: 120 * time.Second, }, @@ -70,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st return nil, fmt.Errorf("failed to copy file content: %w", err) } - if err = writer.WriteField("model_id", "scribe_v1"); err != nil { + if err = writer.WriteField("model_id", t.modelID); err != nil { return nil, fmt.Errorf("failed to write model_id field: %w", err) } 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 2bf719dd4..c03c30d10 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -30,6 +30,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", @@ -76,6 +85,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) @@ -86,55 +99,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.", @@ -142,11 +145,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 { @@ -176,6 +184,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 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 8a878d553..0e6567a03 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -4,9 +4,9 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" - "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -26,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 @@ -41,11 +46,126 @@ func (c *AuthCredential) NeedsRefresh() bool { } func authFilePath() string { - if home := os.Getenv(config.EnvHome); 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, pkg.DefaultPicoClawHome, "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) { @@ -62,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 } @@ -84,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 } @@ -96,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) } @@ -105,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 37fcb74c5..dee67d87c 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -6,12 +6,19 @@ import ( "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 @@ -34,12 +41,21 @@ type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage outboundMedia chan OutboundMediaMessage + 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 { @@ -47,6 +63,8 @@ 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{}), } } @@ -80,7 +98,16 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error } func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { - return publish(ctx, mb, mb.inbound, msg) + 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 { @@ -88,7 +115,16 @@ func (mb *MessageBus) InboundChan() <-chan InboundMessage { } func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { - return publish(ctx, mb, mb.outbound, msg) + msg = NormalizeOutboundMessage(msg) + if msg.Context.isZero() { + mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundContext) + return ErrMissingOutboundContext + } + if err := publish(ctx, mb, mb.outbound, msg); err != nil { + mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), err) + return err + } + return nil } func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { @@ -96,18 +132,56 @@ func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { } func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { - return publish(ctx, mb, mb.outboundMedia, msg) + msg = NormalizeOutboundMediaMessage(msg) + if msg.Context.isZero() { + mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundMediaContext) + return ErrMissingOutboundMediaContext + } + if err := publish(ctx, mb, mb.outboundMedia, msg); err != nil { + mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), err) + return err + } + return nil } 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 { @@ -118,6 +192,7 @@ func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) ( func (mb *MessageBus) Close() { mb.closeOnce.Do(func() { + mb.publishCloseEvent(runtimeevents.KindBusCloseStarted, 0) // notify all blocked publishers to exit close(mb.done) @@ -132,6 +207,8 @@ func (mb *MessageBus) Close() { close(mb.inbound) close(mb.outbound) close(mb.outboundMedia) + close(mb.audioChunks) + close(mb.voiceControls) // clean up any remaining messages in channels drained := 0 @@ -144,11 +221,19 @@ func (mb *MessageBus) Close() { for range mb.outboundMedia { drained++ } + 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 9b6324ca6..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,10 +16,13 @@ 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 { @@ -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", } @@ -59,6 +278,222 @@ func TestPublishOutboundSubscribe(t *testing.T) { 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) } @@ -112,14 +577,30 @@ func TestConsumeInbound_ContextCancel(t *testing.T) { defer mb.Close() for i := range defaultBusBufferSize { - if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil { + 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) } } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() - mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"}) + mb.PublishInbound(ctx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-cancel", + ChatType: "direct", + SenderID: "user-cancel", + }, + Content: "ContextCancel", + }) select { case <-ctx.Done(): @@ -213,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) } } @@ -222,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") } @@ -240,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 12da3f1dd..953e69d9c 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,25 +9,77 @@ 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 } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + 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"` } // MediaPart describes a single media attachment to send. @@ -47,7 +93,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 7f238ece5..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. @@ -1270,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 @@ -1279,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 { diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 8bc8c8dbc..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()` 中通过名字查找工厂并调用它。 @@ -1269,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 @@ -1278,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 { diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 882e72d08..3585fb075 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -48,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 @@ -103,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, @@ -112,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 } @@ -165,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 } @@ -187,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 @@ -221,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 } } @@ -229,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 @@ -242,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 @@ -258,20 +289,28 @@ 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. @@ -282,14 +321,14 @@ func (c *BaseChannel) HandleMessage( if c.owner != nil && c.placeholderRecorder != nil { // 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 - 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) + 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. @@ -298,8 +337,8 @@ func (c *BaseChannel) HandleMessage( // "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, chatID); err == nil && phID != "" { - c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) + if phID, err := pc.SendPlaceholder(ctx, deliveryChatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, deliveryChatID, phID) } } } @@ -308,12 +347,24 @@ 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(), }) } } +// 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 450dcce54..9cd461bc8 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -25,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 @@ -36,7 +36,11 @@ type DingTalkChannel struct { } // NewDingTalkChannel creates a new DingTalk channel instance -func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { +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") } @@ -44,10 +48,10 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( // Set the logger for the Stream SDK dinglog.SetLogger(logger.NewLogger("dingtalk")) - base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, + 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{ @@ -104,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{ @@ -126,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 @@ -181,16 +185,15 @@ func (c *DingTalkChannel) onChatBotMessageReceived( "session_webhook": data.SessionWebhook, } - var peer bus.Peer + var ( + chatType string + isMentioned bool + ) if data.ConversationType == "1" { - peerID := senderID - if peerID == "" { - peerID = chatID - } - peer = bus.Peer{Kind: "direct", ID: peerID} + chatType = "direct" } else { - peer = bus.Peer{Kind: "group", ID: data.ConversationId} - isMentioned := data.IsInAtList + chatType = "group" + isMentioned = data.IsInAtList if isMentioned { content = stripLeadingAtMentions(content) } @@ -228,8 +231,21 @@ func (c *DingTalkChannel) onChatBotMessageReceived( return nil, nil } - // Handle the message through the base channel - c.HandleMessage(ctx, peer, "", resolvedSenderID, 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 diff --git a/pkg/channels/dingtalk/dingtalk_test.go b/pkg/channels/dingtalk/dingtalk_test.go index 437616456..6dfc44730 100644 --- a/pkg/channels/dingtalk/dingtalk_test.go +++ b/pkg/channels/dingtalk/dingtalk_test.go @@ -11,7 +11,11 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -func newTestDingTalkChannel(t *testing.T, cfg config.DingTalkConfig) (*DingTalkChannel, *bus.MessageBus) { +func newTestDingTalkChannel( + t *testing.T, + cfg config.DingTalkSettings, + bc *config.Channel, +) (*DingTalkChannel, *bus.MessageBus) { t.Helper() if cfg.ClientID == "" { @@ -22,7 +26,10 @@ func newTestDingTalkChannel(t *testing.T, cfg config.DingTalkConfig) (*DingTalkC } msgBus := bus.NewMessageBus() - ch, err := NewDingTalkChannel(cfg, msgBus) + 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) } @@ -41,9 +48,12 @@ func mustReceiveInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage } func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention(t *testing.T) { - ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{ + 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 "}, @@ -65,8 +75,8 @@ func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention if inbound.ChatID != "group-abc" { t.Fatalf("chat_id=%q", inbound.ChatID) } - if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-abc" { - t.Fatalf("peer=%+v", inbound.Peer) + if inbound.Context.ChatType != "group" { + t.Fatalf("chat_type=%q", inbound.Context.ChatType) } if inbound.Content != "/help" { t.Fatalf("content=%q", inbound.Content) @@ -74,7 +84,7 @@ func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention } func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *testing.T) { - ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{}) + ch, msgBus := newTestDingTalkChannel(t, config.DingTalkSettings{}, nil) _, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{ Text: chatbot.BotCallbackDataTextModel{Content: "ping"}, @@ -93,12 +103,15 @@ func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *te if inbound.ChatID != "conv-direct-42" { t.Fatalf("chat_id=%q", inbound.ChatID) } - if inbound.Peer.Kind != "direct" || inbound.Peer.ID != "openid-user-42" { - t.Fatalf("peer=%+v", inbound.Peer) + if inbound.Context.ChatType != "direct" { + t.Fatalf("chat_type=%q", inbound.Context.ChatType) } - if inbound.SenderID != "dingtalk:openid-user-42" { + 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") 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 cc0ef4ffe..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,16 +38,33 @@ 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) { +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, @@ -61,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 { @@ -90,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) } @@ -120,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) @@ -128,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, msg.ReplyToMessageID) + 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 @@ -202,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 { @@ -236,25 +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.GetRandomText() + text := c.bc.Placeholder.GetRandomText() msg, err := c.session.ChannelMessageSend(chatID, text) if err != nil { @@ -264,18 +392,100 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID 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() { - var err error + var ( + msg *discordgo.Message + err error + ) // If we have an ID, we send the message as "Reply" if replyToID != "" { - _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: content, Reference: &discordgo.MessageReference{ MessageID: replyToID, @@ -284,20 +494,21 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, repl }) } else { // Otherwise, we send a normal message - _, err = c.session.ChannelMessageSend(channelID, content) + msg, err = c.session.ChannelMessageSend(channelID, content) } - done <- err + 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() } } @@ -339,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 @@ -441,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, @@ -457,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. @@ -610,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/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 4952394b7..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") } @@ -145,3 +203,8 @@ func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) { } } } + +// 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 ff4af0148..dcf7861a2 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -291,6 +291,100 @@ 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 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 5c57cfb02..d09c021c7 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "sync/atomic" + "time" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -37,21 +38,31 @@ const errCodeTenantTokenInvalid = 99991663 type FeishuChannel struct { *channels.BaseChannel - config config.FeishuConfig + 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() @@ -61,10 +72,13 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan } ch := &FeishuChannel{ BaseChannel: base, + bc: bc, config: cfg, 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 } @@ -123,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") @@ -131,26 +148,64 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { // Send sends a message using Interactive Card format for markdown rendering. // 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) error { +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) } + 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 + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + // Build interactive card with markdown content - cardContent, err := buildMarkdownCard(msg.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 - return c.sendText(ctx, msg.ChatID, msg.Content) + 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 - err = c.sendCard(ctx, msg.ChatID, cardContent) + msgID, err := c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return 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) @@ -165,16 +220,21 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error }) // Second attempt: fall back to plain text message - textErr := c.sendText(ctx, msg.ChatID, msg.Content) + msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent) if textErr == nil { - return 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 textErr + return nil, textErr } // For other errors, return the original card error - return err + return nil, err } // EditMessage implements channels.MessageEditor. @@ -201,17 +261,42 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont 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.GetRandomText() + text := c.bc.Placeholder.GetRandomText() cardContent, err := buildMarkdownCard(text) if err != nil { @@ -242,18 +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 - var chosenEmoji string - if len(emojiList) == 0 { - // Default to "Pin" if no config - chosenEmoji = "Pin" - } else { - idx := rand.Intn(len(emojiList)) - chosenEmoji = emojiList[idx] + // 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) + } + } + chosenEmoji := "Pin" + if len(candidates) > 0 { + chosenEmoji = candidates[rand.Intn(len(candidates))] } req := larkim.NewCreateMessageReactionReqBuilder(). @@ -307,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. @@ -436,30 +616,20 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. 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 { @@ -474,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 } @@ -606,6 +803,14 @@ 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) @@ -645,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). @@ -658,41 +892,80 @@ 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() { c.invalidateTokenOnAuthError(resp.Code) - logger.ErrorCF("feishu", "Resource download api error", map[string]any{ - "code": resp.Code, - "msg": resp.Msg, + 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. +// 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{ @@ -711,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{ @@ -746,8 +1019,8 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { return content } - // Don't append tags to JSON content (interactive cards) - would produce invalid JSON - if messageType == larkim.MsgTypeInteractive { + // Don't append tags to JSON content - would produce invalid JSON + if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost { return content } @@ -772,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(). @@ -784,23 +1057,26 @@ 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() { c.invalidateTokenOnAuthError(resp.Code) - return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + 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) error { +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) { content, _ := json.Marshal(map[string]string{"text": text}) req := larkim.NewCreateMessageReqBuilder(). @@ -814,18 +1090,21 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + 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) + 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, }) - return nil + 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. diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 9010abf69..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) { @@ -176,6 +180,13 @@ func TestAppendMediaTags(t *testing.T) { 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 { @@ -279,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/irc/handler.go b/pkg/channels/irc/handler.go index b92359da4..73df9c43c 100644 --- a/pkg/channels/irc/handler.go +++ b/pkg/channels/irc/handler.go @@ -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 3a4f213ca..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 } @@ -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 867ab24ee..760506a31 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -48,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 @@ -61,15 +61,19 @@ type LINEChannel struct { } // NewLINEChannel creates a new LINE channel instance. -func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { +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{ @@ -350,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{ @@ -367,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, @@ -392,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. @@ -496,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 @@ -516,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. @@ -549,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. @@ -684,3 +700,8 @@ func (c *LINEChannel) downloadContent(messageID, filename string) 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 index 00770f1c7..c5f4e9be2 100644 --- a/pkg/channels/line/line_test.go +++ b/pkg/channels/line/line_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestWebhookRejectsOversizedBody(t *testing.T) { @@ -66,7 +68,9 @@ func TestWebhookRejectsNonPostMethod(t *testing.T) { } func TestWebhookRejectsInvalidSignature(t *testing.T) { - ch := &LINEChannel{} + ch := &LINEChannel{ + config: &config.LINESettings{}, + } body := `{"events":[]}` req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) 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 4e8074189..9d6ca543f 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 ( @@ -80,11 +85,13 @@ 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 *dynamicServeMux httpServer *http.Server + httpListeners []net.Listener mu sync.RWMutex placeholders sync.Map // "channel:chatID" → placeholderID (string) typingStops sync.Map // "channel:chatID" → func() @@ -93,10 +100,161 @@ type Manager struct { 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) { @@ -158,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 already delivered (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 { @@ -176,34 +335,82 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 3. If a stream already finalized this message, delete the placeholder and skip send + 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, msg.ChatID, entry.id) // best effort + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort } else if editor, ok := ch.(MessageEditor); ok { - editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content) // fallback + editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback } } } - return true + if !isToolFeedback { + if separateToolFeedbackMessages { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } else { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } + } + return nil, true } - // 4. Try editing placeholder + 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 } // preSendMedia handles typing stop, reaction undo, and placeholder cleanup @@ -211,7 +418,8 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess // 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) { - key := name + ":" + msg.ChatID + chatID := outboundMediaChatID(msg) + key := name + ":" + chatID // 1. Stop typing if v, loaded := m.typingStops.LoadAndDelete(key); loaded { @@ -230,17 +438,26 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun // 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, msg.ChatID, entry.id) // best effort + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort } } } } -func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { +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), @@ -249,6 +466,11 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi 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) @@ -291,41 +513,70 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) ( // Mark streamActive on Finalize so preSend knows to clean up the placeholder key := channelName + ":" + chatID return &finalizeHookStreamer{ - Streamer: streamer, - onFinalize: func() { m.streamActive.Store(key, true) }, + 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() + 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 } - s.onFinalize() + if s.onFinalize != nil { + s.onFinalize(ctx) + } return 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) +// 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 { @@ -343,86 +594,107 @@ 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) 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() != "" + } + + 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 channels.Telegram.Enabled && channels.Telegram.Token.String() != "" { - m.initChannel("telegram", "Telegram") - } - - if channels.WhatsApp.Enabled { - waCfg := 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 channels.Feishu.Enabled { - m.initChannel("feishu", "Feishu") - } - - if channels.Discord.Enabled && channels.Discord.Token.String() != "" { - m.initChannel("discord", "Discord") - } - - if channels.MaixCam.Enabled { - m.initChannel("maixcam", "MaixCam") - } - - if channels.QQ.Enabled { - m.initChannel("qq", "QQ") - } - - if channels.DingTalk.Enabled && channels.DingTalk.ClientID != "" { - m.initChannel("dingtalk", "DingTalk") - } - - if channels.Slack.Enabled && channels.Slack.BotToken.String() != "" { - m.initChannel("slack", "Slack") - } - - if channels.Matrix.Enabled && - m.config.Channels.Matrix.Homeserver != "" && - m.config.Channels.Matrix.UserID != "" && - m.config.Channels.Matrix.AccessToken.String() != "" { - m.initChannel("matrix", "Matrix") - } - - if channels.LINE.Enabled && channels.LINE.ChannelAccessToken.String() != "" { - m.initChannel("line", "LINE") - } - - if channels.OneBot.Enabled && channels.OneBot.WSUrl != "" { - m.initChannel("onebot", "OneBot") - } - - if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret.String() != "" { - m.initChannel("wecom", "WeCom") - } - - if channels.Weixin.Enabled && channels.Weixin.Token.String() != "" { - m.initChannel("weixin", "Weixin") - } - - if channels.Pico.Enabled && channels.Pico.Token.String() != "" { - m.initChannel("pico", "Pico") - } - - if channels.PicoClient.Enabled && channels.PicoClient.URL != "" { - m.initChannel("pico_client", "Pico Client") - } - - if channels.IRC.Enabled && 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{ @@ -436,6 +708,12 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) 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.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 @@ -452,6 +730,7 @@ 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 @@ -468,6 +747,13 @@ func (m *Manager) registerHTTPHandlersLocked() { 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(), @@ -487,6 +773,13 @@ func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) { 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(), @@ -513,6 +806,8 @@ func (m *Manager) StartAll(ctx context.Context) error { 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{ @@ -523,13 +818,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 @@ -541,19 +888,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.FatalCF("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 } @@ -573,6 +941,7 @@ func (m *Manager) StopAll(ctx context.Context) error { }) } m.httpServer = nil + m.httpListeners = nil } // Cancel dispatcher @@ -614,7 +983,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") @@ -622,10 +999,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))) @@ -660,18 +1037,21 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) // Collect all message chunks to send var chunks []string - // Step 1: Try marker-based splitting if enabled - if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { + // 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 { - chunks = append(chunks, splitByLength(chunk, maxLen)...) + chunkMsg := msg + chunkMsg.Content = chunk + chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...) } } } // Step 2: Fallback to length-based splitting if no chunks from marker if len(chunks) == 0 { - chunks = splitByLength(msg.Content, maxLen) + chunks = splitOutboundMessageContent(msg, maxLen) } // Step 3: Send all chunks @@ -686,12 +1066,25 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } -// splitByLength splits content by maxLen if needed, otherwise returns single chunk. -func splitByLength(content string, maxLen int) []string { - if maxLen > 0 && len([]rune(content)) > maxLen { - return SplitMessage(content, maxLen) +// 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{content} + return []string{msg.Content} } // sendWithRetry sends a message through the channel with rate limiting and @@ -699,23 +1092,42 @@ func splitByLength(content string, maxLen int) []string { // - 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 @@ -734,7 +1146,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return + return nil, false } } @@ -743,17 +1155,20 @@ 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]( @@ -810,10 +1225,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { dispatchLoop( ctx, m, m.bus.OutboundChan(), - func(msg bus.OutboundMessage) string { return msg.Channel }, + 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 @@ -830,10 +1246,11 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) { dispatchLoop( ctx, m, m.bus.OutboundMediaChan(), - func(msg bus.OutboundMediaMessage) string { return msg.Channel }, + 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 @@ -855,7 +1272,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 } @@ -863,14 +1280,14 @@ 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. It returns nil on success, or the last error after retries, -// including when the channel does not support MediaSender. +// 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, -) error { +) ([]string, error) { ms, ok := w.ch.(MediaSender) if !ok { err := fmt.Errorf("channel %q does not support media sending", name) @@ -878,22 +1295,34 @@ func (m *Manager) sendMediaWithRetry( "channel": name, "error": err.Error(), }) - return err + return nil, err } // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { - return err + 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 nil + m.publishOutboundMediaSent(name, msg, msgIDs) + return msgIDs, nil } // Permanent failures — don't retry @@ -912,7 +1341,7 @@ func (m *Manager) sendMediaWithRetry( case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() } } @@ -921,18 +1350,19 @@ func (m *Manager) sendMediaWithRetry( select { case <-time.After(backoff): case <-ctx.Done(): - return ctx.Err() + 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, }) - return lastErr + m.publishOutboundMediaFailed(name, msg, lastErr) + return nil, lastErr } // runTTLJanitor periodically scans the typingStops and placeholders maps @@ -1069,13 +1499,33 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) 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()}, + ) 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}, + ) deferFuncs = append(deferFuncs, func() { m.RegisterChannel(name, channel) }) @@ -1121,30 +1571,36 @@ func (m *Manager) UnregisterChannel(name string) { // 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[msg.Channel] - w, wExists := m.workers[msg.Channel] + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] m.mu.RUnlock() if !exists { - return fmt.Errorf("channel %s not found", msg.Channel) + return fmt.Errorf("channel %s not found", channelName) } if !wExists || w == nil { - return fmt.Errorf("channel %s has no active worker", msg.Channel) + return fmt.Errorf("channel %s has no active worker", channelName) } maxLen := 0 if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - for _, chunk := range SplitMessage(msg.Content, maxLen) { + if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 { + for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk - m.sendWithRetry(ctx, msg.Channel, w, chunkMsg) + m.sendWithRetry(ctx, channelName, w, chunkMsg) } } else { - m.sendWithRetry(ctx, msg.Channel, w, msg) + if len(chunks) == 1 { + msg.Content = chunks[0] + } + m.sendWithRetry(ctx, channelName, w, msg) } return nil } @@ -1154,19 +1610,23 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro // 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[msg.Channel] - w, wExists := m.workers[msg.Channel] + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] m.mu.RUnlock() if !exists { - return fmt.Errorf("channel %s not found", msg.Channel) + return fmt.Errorf("channel %s not found", channelName) } if !wExists || w == nil { - return fmt.Errorf("channel %s has no active worker", msg.Channel) + return fmt.Errorf("channel %s has no active worker", channelName) } - return m.sendMediaWithRetry(ctx, msg.Channel, w, msg) + _, err := m.sendMediaWithRetry(ctx, channelName, w, msg) + return err } func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { @@ -1180,14 +1640,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() @@ -1196,5 +1657,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 index b1c8c25e0..1f5978e7d 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -6,7 +6,6 @@ import ( "encoding/json" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" ) func toChannelHashes(cfg *config.Config) map[string]string { @@ -21,7 +20,7 @@ func toChannelHashes(cfg *config.Config) map[string]string { if !value["enabled"].(bool) { continue } - hiddenValues(key, value, ch) + hiddenValues(key, value, ch.Get(key)) valueBytes, _ := json.Marshal(value) hash := md5.Sum(valueBytes) result[key] = hex.EncodeToString(hash[:]) @@ -30,38 +29,79 @@ func toChannelHashes(cfg *config.Config) map[string]string { return result } -func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) { +func hiddenValues(key string, value map[string]any, ch *config.Channel) { + v, err := ch.GetDecoded() + if err != nil { + return + } switch key { case "pico": - value["token"] = ch.Pico.Token.String() + if settings, ok := v.(*config.PicoSettings); ok { + value["token"] = settings.Token.String() + } case "telegram": - value["token"] = ch.Telegram.Token.String() + if settings, ok := v.(*config.TelegramSettings); ok { + value["token"] = settings.Token.String() + } case "discord": - value["token"] = ch.Discord.Token.String() + if settings, ok := v.(*config.DiscordSettings); ok { + value["token"] = settings.Token.String() + } case "slack": - value["bot_token"] = ch.Slack.BotToken.String() - value["app_token"] = ch.Slack.AppToken.String() + if settings, ok := v.(*config.SlackSettings); ok { + value["bot_token"] = settings.BotToken.String() + value["app_token"] = settings.AppToken.String() + } case "matrix": - value["token"] = ch.Matrix.AccessToken.String() + if settings, ok := v.(*config.MatrixSettings); ok { + value["token"] = settings.AccessToken.String() + } case "onebot": - value["token"] = ch.OneBot.AccessToken.String() + if settings, ok := v.(*config.OneBotSettings); ok { + value["token"] = settings.AccessToken.String() + } case "line": - value["token"] = ch.LINE.ChannelAccessToken.String() - value["secret"] = ch.LINE.ChannelSecret.String() + if settings, ok := v.(*config.LINESettings); ok { + value["token"] = settings.ChannelAccessToken.String() + value["secret"] = settings.ChannelSecret.String() + } case "wecom": - value["secret"] = ch.WeCom.Secret.String() + if settings, ok := v.(*config.WeComSettings); ok { + value["secret"] = settings.Secret.String() + } case "dingtalk": - value["secret"] = ch.DingTalk.ClientSecret.String() + if settings, ok := v.(*config.DingTalkSettings); ok { + value["secret"] = settings.ClientSecret.String() + } case "qq": - value["secret"] = ch.QQ.AppSecret.String() + if settings, ok := v.(*config.QQSettings); ok { + value["secret"] = settings.AppSecret.String() + } case "irc": - value["password"] = ch.IRC.Password.String() - value["serv_password"] = ch.IRC.NickServPassword.String() - value["sasl_password"] = ch.IRC.SASLPassword.String() + 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": - value["app_secret"] = ch.Feishu.AppSecret.String() - value["encrypt_key"] = ch.Feishu.EncryptKey.String() - value["verification_token"] = ch.Feishu.VerificationToken.String() + 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 } } @@ -85,85 +125,13 @@ func compareChannels(old, news map[string]string) (added, removed []string) { } func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, error) { - result := &config.ChannelsConfig{} - ch := cfg.Channels - // should not be error - marshal, _ := json.Marshal(ch) - var channelConfig map[string]map[string]any - _ = json.Unmarshal(marshal, &channelConfig) - temp := make(map[string]map[string]any, 0) - - for key, value := range channelConfig { - found := false - for _, s := range list { - if key == s { - found = true - break - } - } - if !found || !value["enabled"].(bool) { + result := make(config.ChannelsConfig) + for _, name := range list { + bc, ok := cfg.Channels[name] + if !ok || !bc.Enabled { continue } - temp[key] = value - } - - marshal, err := json.Marshal(temp) - if err != nil { - logger.Errorf("marshal error: %v", err) - return nil, err - } - err = json.Unmarshal(marshal, result) - if err != nil { - logger.Errorf("unmarshal error: %v", err) - return nil, err - } - - updateKeys(result, &ch) - - return result, nil -} - -func updateKeys(newcfg, old *config.ChannelsConfig) { - if newcfg.Pico.Enabled { - newcfg.Pico.Token = old.Pico.Token - } - if newcfg.Telegram.Enabled { - newcfg.Telegram.Token = old.Telegram.Token - } - if newcfg.Discord.Enabled { - newcfg.Discord.Token = old.Discord.Token - } - if newcfg.Slack.Enabled { - newcfg.Slack.BotToken = old.Slack.BotToken - newcfg.Slack.AppToken = old.Slack.AppToken - } - if newcfg.Matrix.Enabled { - newcfg.Matrix.AccessToken = old.Matrix.AccessToken - } - if newcfg.OneBot.Enabled { - newcfg.OneBot.AccessToken = old.OneBot.AccessToken - } - if newcfg.LINE.Enabled { - newcfg.LINE.ChannelAccessToken = old.LINE.ChannelAccessToken - newcfg.LINE.ChannelSecret = old.LINE.ChannelSecret - } - if newcfg.WeCom.Enabled { - newcfg.WeCom.Secret = old.WeCom.Secret - } - if newcfg.DingTalk.Enabled { - newcfg.DingTalk.ClientSecret = old.DingTalk.ClientSecret - } - if newcfg.QQ.Enabled { - newcfg.QQ.AppSecret = old.QQ.AppSecret - } - if newcfg.IRC.Enabled { - newcfg.IRC.Password = old.IRC.Password - newcfg.IRC.NickServPassword = old.IRC.NickServPassword - newcfg.IRC.SASLPassword = old.IRC.SASLPassword - } - if newcfg.Feishu.Enabled { - newcfg.Feishu.AppSecret = old.Feishu.AppSecret - newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey - newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken + result[name] = bc } + return &result, nil } diff --git a/pkg/channels/manager_channel_test.go b/pkg/channels/manager_channel_test.go index 3de1e2b3f..b991e58d6 100644 --- a/pkg/channels/manager_channel_test.go +++ b/pkg/channels/manager_channel_test.go @@ -1,6 +1,7 @@ package channels import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -15,37 +16,138 @@ func TestToChannelHashes(t *testing.T) { 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.Enabled = true + 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.Enabled = true + 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) - cfg3.Channels.Telegram.SetToken("114314") + + // 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) - logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "114314", cc.Telegram.Token.String()) - assert.Equal(t, true, cc.Telegram.Enabled) + 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) - logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "", cc.Telegram.Token.String()) - assert.Equal(t, false, cc.Telegram.Enabled) + 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 b4fd2ba3d..5aeabc888 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -13,25 +13,44 @@ 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 + 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 { +func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { m.sentMessages = append(m.sentMessages, msg) - return m.sendFn(ctx, 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++ @@ -46,22 +65,23 @@ func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, conten type mockMediaChannel struct { mockChannel - sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) error + sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) sentMediaMessages []bus.OutboundMediaMessage } -func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +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 + return nil, nil } type mockDeletingMediaChannel struct { mockMediaChannel - deleteCalls int - lastDeleted struct { + deleteCalls int + dismissedChatID string + lastDeleted struct { chatID string messageID string } @@ -78,11 +98,228 @@ func (m *mockDeletingMediaChannel) DeleteMessage( 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{} } } @@ -101,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) @@ -110,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 @@ -128,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) @@ -152,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) @@ -176,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) @@ -203,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) @@ -233,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) @@ -247,9 +547,9 @@ func TestSendMedia_Success(t *testing.T) { m := newTestManager() var callCount int ch := &mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { callCount++ - return nil + return nil, nil }, } w := &channelWorker{ @@ -259,11 +559,11 @@ func TestSendMedia_Success(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + 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) } @@ -275,8 +575,8 @@ func TestSendMedia_Success(t *testing.T) { func TestSendMedia_PropagatesFailure(t *testing.T) { m := newTestManager() ch := &mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { - return fmt.Errorf("bad upload: %w", ErrSendFailed) + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, fmt.Errorf("bad upload: %w", ErrSendFailed) }, } w := &channelWorker{ @@ -286,11 +586,11 @@ func TestSendMedia_PropagatesFailure(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + 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") } @@ -313,11 +613,11 @@ func TestSendMedia_UnsupportedChannelReturnsError(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + 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") } @@ -330,8 +630,8 @@ func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) { m := newTestManager() ch := &mockDeletingMediaChannel{ mockMediaChannel: mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { - return nil + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, nil }, }, } @@ -343,11 +643,11 @@ func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) { m.workers["test"] = w m.RecordPlaceholder("test", "chat1", "placeholder-1") - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + 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) } @@ -380,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) @@ -404,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 { @@ -450,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) @@ -475,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") @@ -488,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()) } } } @@ -526,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) @@ -567,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) @@ -590,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 @@ -627,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)") @@ -641,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() @@ -657,8 +1834,8 @@ 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") @@ -716,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 { @@ -733,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") @@ -763,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") @@ -827,7 +2004,7 @@ func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) { t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls) } - 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, &mockChannel{}) if newStopCalls != 1 { @@ -861,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 { @@ -1024,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") @@ -1111,7 +2288,7 @@ func TestManager_PlaceholderConsumedByResponse(t *testing.T) { return nil }, } - worker := newChannelWorker("mock", mockCh) + worker := newChannelWorker("mock", mockCh, "mock") mgr.channels["mock"] = mockCh mgr.workers["mock"] = worker @@ -1127,11 +2304,11 @@ func TestManager_PlaceholderConsumedByResponse(t *testing.T) { // Transcription feedback arrives first — it should consume the placeholder // and be delivered via EditMessage, not Send. - msgTranscript := bus.OutboundMessage{ + msgTranscript := testOutboundMessage(bus.OutboundMessage{ Channel: "mock", ChatID: "chat-1", Content: "Transcript: hello", - } + }) mgr.sendWithRetry(ctx, "mock", worker, msgTranscript) if mockCh.editedMessages != 1 { @@ -1147,11 +2324,11 @@ func TestManager_PlaceholderConsumedByResponse(t *testing.T) { } // Final LLM response arrives — no placeholder left, so it goes through Send - msgFinal := bus.OutboundMessage{ + msgFinal := testOutboundMessage(bus.OutboundMessage{ Channel: "mock", ChatID: "chat-1", Content: "Final Answer", - } + }) mgr.sendWithRetry(ctx, "mock", worker, msgFinal) if len(mockCh.sentMessages) != 1 { @@ -1177,12 +2354,12 @@ func TestSendMessage_Synchronous(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - msg := bus.OutboundMessage{ + msg := testOutboundMessage(bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "hello world", ReplyToMessageID: "msg-456", - } + }) err := m.SendMessage(context.Background(), msg) if err != nil { @@ -1204,11 +2381,11 @@ func TestSendMessage_Synchronous(t *testing.T) { func TestSendMessage_UnknownChannel(t *testing.T) { m := newTestManager() - msg := bus.OutboundMessage{ + msg := testOutboundMessage(bus.OutboundMessage{ Channel: "nonexistent", ChatID: "123", Content: "hello", - } + }) err := m.SendMessage(context.Background(), msg) if err == nil { @@ -1225,11 +2402,11 @@ func TestSendMessage_NoWorker(t *testing.T) { m.channels["test"] = ch // No worker registered - msg := bus.OutboundMessage{ + msg := testOutboundMessage(bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "hello", - } + }) err := m.SendMessage(context.Background(), msg) if err == nil { @@ -1258,11 +2435,11 @@ func TestSendMessage_WithRetry(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - msg := bus.OutboundMessage{ + msg := testOutboundMessage(bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "retry me", - } + }) err := m.SendMessage(context.Background(), msg) if err != nil { @@ -1274,6 +2451,46 @@ func TestSendMessage_WithRetry(t *testing.T) { } } +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() @@ -1295,11 +2512,11 @@ func TestSendMessage_WithSplitting(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - msg := bus.OutboundMessage{ + msg := testOutboundMessage(bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "hello world", - } + }) err := m.SendMessage(context.Background(), msg) if err != nil { @@ -1311,6 +2528,43 @@ func TestSendMessage_WithSplitting(t *testing.T) { } } +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() @@ -1330,12 +2584,12 @@ func TestSendMessage_PreservesOrdering(t *testing.T) { m.workers["test"] = w // Send two messages sequentially — they must arrive in order - _ = m.SendMessage(context.Background(), bus.OutboundMessage{ + _ = m.SendMessage(context.Background(), testOutboundMessage(bus.OutboundMessage{ Channel: "test", ChatID: "1", Content: "first", - }) - _ = m.SendMessage(context.Background(), bus.OutboundMessage{ + })) + _ = 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)) diff --git a/pkg/channels/matrix/init.go b/pkg/channels/matrix/init.go index 4d6ad45a7..f645a464b 100644 --- a/pkg/channels/matrix/init.go +++ b/pkg/channels/matrix/init.go @@ -9,12 +9,30 @@ import ( ) func init() { - channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - matrixCfg := cfg.Channels.Matrix - cryptoDatabasePath := matrixCfg.CryptoDatabasePath - if cryptoDatabasePath == "" { - cryptoDatabasePath = filepath.Join(cfg.WorkspacePath(), "matrix") - } - return NewMatrixChannel(matrixCfg, b, cryptoDatabasePath) - }) + 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 09b4eaa76..04599d6d2 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -46,6 +46,13 @@ const ( 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 @@ -174,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 @@ -191,10 +199,12 @@ type MatrixChannel struct { cryptoHelper *cryptohelper.CryptoHelper cryptoDbPath string + progress *channels.ToolFeedbackAnimator } func NewMatrixChannel( - cfg config.MatrixConfig, + bc *config.Channel, + cfg *config.MatrixSettings, messageBus *bus.MessageBus, cryptoDatabasePath string, ) (*MatrixChannel, error) { @@ -228,14 +238,15 @@ func NewMatrixChannel( "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, @@ -245,7 +256,9 @@ func NewMatrixChannel( localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, cryptoDbPath: cryptoDatabasePath, - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *MatrixChannel) Start(ctx context.Context) error { @@ -294,6 +307,9 @@ 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 { @@ -374,31 +390,57 @@ func (c *MatrixChannel) initCrypto(ctx context.Context) error { } func markdownToHTML(md string) string { - p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) - renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) + 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) error { +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, c.messageContent(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 { @@ -411,10 +453,12 @@ func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { } // 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() @@ -422,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) @@ -497,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) @@ -510,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. @@ -564,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 } @@ -573,7 +626,7 @@ func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", fmt.Errorf("matrix room ID is empty") } - text := c.config.Placeholder.GetRandomText() + text := c.bc.Placeholder.GetRandomText() resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -603,6 +656,89 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI 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 @@ -714,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 } @@ -730,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{ @@ -746,17 +880,19 @@ 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. @@ -1294,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 7484c8d87..066f08059 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -14,6 +14,7 @@ import ( "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" ) @@ -41,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") @@ -341,32 +370,105 @@ func TestMatrixOutboundContent(t *testing.T) { } func TestMarkdownToHTML(t *testing.T) { - tests := []struct { + cases := []struct { name string - input string - contains string + md string + rendered string }{ - {"bold", "**hello**", "hello"}, - {"italic", "_world_", "world"}, - {"header", "### Title", ""}, - {"inline code", "`x`", "x"}, - {"plain text", "just text", "just text"}, + { + 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 _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := markdownToHTML(tt.input) - if !strings.Contains(got, tt.contains) { - t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains) + + 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.MatrixConfig{MessageFormat: "richtext"}} - plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}} - defaultt := &MatrixChannel{config: config.MatrixConfig{}} + 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**") 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 e5f8b8fd1..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 @@ -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 { @@ -991,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{} @@ -1003,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) @@ -1072,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 { @@ -1104,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 index 4fdcbbf39..009900e01 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -22,7 +22,7 @@ import ( // PicoClientChannel connects to a remote Pico Protocol WebSocket server. type PicoClientChannel struct { *channels.BaseChannel - config config.PicoClientConfig + config *config.PicoClientSettings conn *picoConn mu sync.Mutex ctx context.Context @@ -31,14 +31,15 @@ type PicoClientChannel struct { // NewPicoClientChannel creates a new Pico Protocol client channel. func NewPicoClientChannel( - cfg config.PicoClientConfig, + 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, cfg.AllowFrom) + base := channels.NewBaseChannel("pico_client", cfg, messageBus, bc.AllowFrom) return &PicoClientChannel{ BaseChannel: base, @@ -242,7 +243,11 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) { } func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { - content, _ := msg.Payload["content"].(string) + if isThoughtPayload(msg.Payload) { + return + } + + content, _ := msg.Payload[PayloadKeyContent].(string) if strings.TrimSpace(content) == "" { return } @@ -254,8 +259,6 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { chatID := "pico_client:" + sessionID senderID := "pico-remote" - peer := bus.Peer{Kind: "direct", ID: chatID} - sender := bus.SenderInfo{ Platform: "pico_client", PlatformID: senderID, @@ -266,29 +269,38 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { return } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, map[string]string{ - "platform": "pico_client", - "session_id": sessionID, - }, sender) + 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) error { +func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } c.mu.Lock() pc := c.conn c.mu.Unlock() if pc == nil || pc.closed.Load() { - return channels.ErrSendFailed + return nil, channels.ErrSendFailed } outMsg := newMessage(TypeMessageSend, map[string]any{ - "content": msg.Content, + PayloadKeyContent: msg.Content, }) outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") - return pc.writeJSON(outMsg) + return nil, pc.writeJSON(outMsg) } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 7f2719e7d..2b167e457 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -18,7 +18,8 @@ import ( ) func TestNewPicoClientChannel_MissingURL(t *testing.T) { - _, err := NewPicoClientChannel(config.PicoClientConfig{}, bus.NewMessageBus()) + 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") } @@ -28,7 +29,8 @@ func TestNewPicoClientChannel_MissingURL(t *testing.T) { } func TestNewPicoClientChannel_OK(t *testing.T) { - ch, err := NewPicoClientChannel(config.PicoClientConfig{ + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ URL: "ws://localhost:9999/ws", }, bus.NewMessageBus()) if err != nil { @@ -40,13 +42,14 @@ func TestNewPicoClientChannel_OK(t *testing.T) { } func TestSend_NotRunning(t *testing.T) { - ch, err := NewPicoClientChannel(config.PicoClientConfig{ + 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"}) + _, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) if !errors.Is(err, channels.ErrNotRunning) { t.Fatalf("expected ErrNotRunning, got %v", err) } @@ -104,7 +107,8 @@ func TestClientChannel_ConnectAndSend(t *testing.T) { defer srv.Close() mb := bus.NewMessageBus() - ch, err := NewPicoClientChannel(config.PicoClientConfig{ + 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", @@ -124,7 +128,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) { defer ch.Stop(ctx) // Send a message - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-1", Content: "hello", }) @@ -137,7 +141,8 @@ func TestClientChannel_AuthFailure(t *testing.T) { srv := testServer(t, "correct-token") defer srv.Close() - ch, err := NewPicoClientChannel(config.PicoClientConfig{ + 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()) @@ -161,7 +166,8 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) { mb := bus.NewMessageBus() - ch, err := NewPicoClientChannel(config.PicoClientConfig{ + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ URL: wsURL(srv.URL), SessionID: "sess-echo", ReadTimeout: 10, @@ -179,7 +185,7 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) { defer ch.Stop(ctx) // Send a message; the echo server replies with message.create - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-echo", Content: "ping", }) @@ -203,7 +209,8 @@ func TestClientChannel_StartTyping(t *testing.T) { srv := testServer(t, "") defer srv.Close() - ch, err := NewPicoClientChannel(config.PicoClientConfig{ + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ URL: wsURL(srv.URL), SessionID: "sess-type", ReadTimeout: 10, @@ -231,7 +238,8 @@ func TestSend_ClosedConnection(t *testing.T) { srv := testServer(t, "") defer srv.Close() - ch, err := NewPicoClientChannel(config.PicoClientConfig{ + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ URL: wsURL(srv.URL), SessionID: "sess-close", ReadTimeout: 10, @@ -252,7 +260,7 @@ func TestSend_ClosedConnection(t *testing.T) { ch.conn.close() ch.mu.Unlock() - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-close", Content: "should fail", }) @@ -262,3 +270,166 @@ func TestSend_ClosedConnection(t *testing.T) { 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 0319279d8..54596fab3 100644 --- a/pkg/channels/pico/init.go +++ b/pkg/channels/pico/init.go @@ -7,10 +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("pico_client", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewPicoClientChannel(cfg.Channels.PicoClient, 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 0e2bea67c..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. @@ -30,6 +36,41 @@ type picoConn struct { 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. func (pc *picoConn) writeJSON(v any) error { if pc.closed.Load() { @@ -54,22 +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 + 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) { +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 { @@ -85,8 +133,9 @@ 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, @@ -95,7 +144,10 @@ func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoCha }, connections: make(map[string]*picoConn), sessionConnections: make(map[string]map[string]*picoConn), - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.deleteMessageFn = ch.DeleteMessage + return ch, nil } // createAndAddConnection checks MaxConnections and registers a connection atomically. @@ -213,6 +265,9 @@ func (c *PicoChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } logger.InfoC("pico", "Pico Protocol channel stopped") return nil @@ -229,32 +284,162 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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) @@ -271,16 +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.GetRandomText() + 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 { @@ -290,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:" @@ -516,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) @@ -525,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 } @@ -539,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, @@ -550,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{ @@ -562,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. @@ -573,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 index e712767ad..bbe73a222 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -4,20 +4,30 @@ 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() - cfg := config.PicoConfig{} + bc := &config.Channel{Type: config.ChannelPico, Enabled: true} + cfg := &config.PicoSettings{} cfg.SetToken("test-token") - ch, err := NewPicoChannel(cfg, bus.NewMessageBus()) + ch, err := NewPicoChannel(bc, cfg, bus.NewMessageBus()) if err != nil { t.Fatalf("NewPicoChannel: %v", err) } @@ -26,6 +36,204 @@ func newTestPicoChannel(t *testing.T) *PicoChannel { 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) @@ -122,6 +330,167 @@ func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) { } } +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() @@ -142,3 +511,39 @@ func (c *PicoChannel) addConnForTest(pc *picoConn) { } 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/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 dfea85ba3..71cba5548 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -56,7 +56,8 @@ type qqAPI interface { type QQChannel struct { *channels.BaseChannel - config config.QQConfig + bc *config.Channel + config *config.QQSettings api qqAPI tokenSource oauth2.TokenSource ctx context.Context @@ -82,15 +83,16 @@ type QQChannel struct { stopOnce sync.Once } -func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { - base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, +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(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &QQChannel{ BaseChannel: base, + bc: bc, config: cfg, dedup: make(map[string]time.Time), done: make(chan struct{}), @@ -161,8 +163,8 @@ func (c *QQChannel) Start(ctx context.Context) error { // Pre-register reasoning_channel_id as group chat if configured, // so outbound-only destinations are routed correctly. - if c.config.ReasoningChannelID != "" { - c.chatType.Store(c.config.ReasoningChannelID, "group") + if c.bc.ReasoningChannelID != "" { + c.chatType.Store(c.bc.ReasoningChannelID, "group") } c.SetRunning(true) @@ -200,9 +202,9 @@ func (c *QQChannel) getChatKind(chatID string) string { return "group" } -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) @@ -236,11 +238,14 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { } // Route to group or C2C. - var err error + var ( + sentMsg *dto.Message + err error + ) if chatKind == "group" { - _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { - _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } if err != nil { @@ -249,10 +254,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_kind": chatKind, "error": err.Error(), }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary) } - return nil + if sentMsg == nil { + return nil, nil + } + return []string{sentMsg.ID}, nil } // StartTyping implements channels.TypingCapable. @@ -319,13 +327,14 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err // 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) error { +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + 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 { @@ -335,22 +344,26 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) "error": err.Error(), }) if errors.Is(err, channels.ErrSendFailed) { - return err + return nil, err } - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) } - if err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil { + 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 fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + if sentMsg != nil && sentMsg.ID != "" { + messageIDs = append(messageIDs, sentMsg.ID) } } - return nil + return messageIDs, nil } type qqMediaUpload struct { @@ -517,7 +530,7 @@ func (c *QQChannel) sendUploadedMedia( chatKind, chatID string, part bus.MediaPart, fileInfo []byte, -) error { +) (*dto.Message, error) { msg := &dto.MessageToCreate{ Content: part.Caption, MsgType: dto.RichMediaMsg, @@ -532,11 +545,11 @@ func (c *QQChannel) sendUploadedMedia( } if chatKind == "group" { - _, err := c.api.PostGroupMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg) + return sentMsg, err } - _, err := c.api.PostC2CMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg) + return sentMsg, err } func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { @@ -577,12 +590,22 @@ func qqFileType(partType string) uint64 { } 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 { @@ -636,17 +659,17 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { 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.HandleMessage(c.ctx, - bus.Peer{Kind: "direct", ID: senderID}, - data.ID, - senderID, - senderID, - content, - mediaPaths, - metadata, - sender, - ) + c.HandleInboundContext(c.ctx, senderID, content, mediaPaths, inboundCtx, sender) return nil } @@ -714,17 +737,18 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { "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.HandleMessage(c.ctx, - bus.Peer{Kind: "group", ID: data.GroupID}, - data.ID, - senderID, - data.GroupID, - content, - mediaPaths, - metadata, - sender, - ) + c.HandleInboundContext(c.ctx, data.GroupID, content, mediaPaths, inboundCtx, sender) return nil } @@ -991,3 +1015,8 @@ func sanitizeURLs(text string) string { 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 index 7ed736827..2ab03ab54 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -54,8 +54,8 @@ func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) { if !ok { t.Fatal("expected inbound message") } - if inbound.Metadata["account_id"] != "7750283E123456" { - t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456") + if inbound.Context.Raw["account_id"] != "7750283E123456" { + t.Fatalf("account_id raw = %q, want %q", inbound.Context.Raw["account_id"], "7750283E123456") } return } @@ -165,8 +165,8 @@ func TestHandleGroupATMessage_AttachmentOnlyPublishesMedia(t *testing.T) { if !strings.HasPrefix(inbound.Media[0], "media://") { t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0]) } - if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-1" { - t.Fatalf("inbound.Peer = %+v, want group/group-1", inbound.Peer) + if inbound.Context.ChatType != "group" { + t.Fatalf("inbound.Context.ChatType = %q, want group", inbound.Context.ChatType) } } @@ -198,6 +198,7 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), @@ -209,7 +210,7 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { ch.lastMsgID.Store("group-1", "msg-1") ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -294,6 +295,7 @@ func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), @@ -303,7 +305,7 @@ func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -329,6 +331,7 @@ func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) { } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), @@ -337,7 +340,7 @@ func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -374,6 +377,7 @@ func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), @@ -383,7 +387,7 @@ func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -409,6 +413,7 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), @@ -417,7 +422,7 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -481,6 +486,7 @@ func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) { } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), @@ -490,7 +496,7 @@ func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) { ch.SetMediaStore(store) ch.chatType.Store("user-1", "direct") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -520,6 +526,7 @@ 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{}), @@ -528,7 +535,7 @@ func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("group-1", "group") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -566,7 +573,7 @@ func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testin api := &fakeQQAPI{} ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), - config: config.QQConfig{ + config: &config.QQSettings{ MaxBase64FileSizeMiB: 1, }, api: api, @@ -578,7 +585,7 @@ func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testin ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "file", 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 acd857a06..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,7 +36,11 @@ type slackMessageRef struct { Timestamp string } -func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) { +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") } @@ -48,10 +52,10 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack 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,14 +112,14 @@ 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{ @@ -130,12 +134,12 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error 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, @@ -148,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 { @@ -188,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. @@ -354,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, @@ -377,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) { @@ -425,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, @@ -441,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) { @@ -489,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 { @@ -535,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 d1980a7c9..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,32 +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{} + cfg := &config.SlackSettings{} cfg.AppToken = *config.NewSecureString("xapp-test") - _, err := NewSlackChannel(cfg, msgBus) + _, 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{} + cfg := &config.SlackSettings{} cfg.BotToken = *config.NewSecureString("xoxb-test") - _, err := NewSlackChannel(cfg, msgBus) + _, 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{ - AllowFrom: []string{"U123"}, - } + cfg := &config.SlackSettings{} cfg.BotToken = *config.NewSecureString("xoxb-test") cfg.AppToken = *config.NewSecureString("xapp-test") - ch, err := NewSlackChannel(cfg, msgBus) + 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) } @@ -142,24 +160,22 @@ func TestSlackChannelIsAllowed(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.SlackConfig{ - AllowFrom: []string{}, - } + 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(cfg, msgBus) + 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{ - AllowFrom: []string{"U_ALLOWED"}, - } + 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(cfg, msgBus) + 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/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go index bdaa51807..0614b6e32 100644 --- a/pkg/channels/telegram/parser_markdown_to_html.go +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -2,9 +2,13 @@ package telegram import ( "fmt" + "html" + "regexp" "strings" ) +var reRawURL = regexp.MustCompile(`https?://[^\s<]+`) + func markdownToTelegramHTML(text string) string { if text == "" { return "" @@ -16,14 +20,18 @@ func markdownToTelegramHTML(text string) string { 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 = reLink.ReplaceAllString(text, `$1`) - text = reBoldStar.ReplaceAllString(text, "$1") text = reBoldUnder.ReplaceAllString(text, "$1") @@ -40,6 +48,21 @@ func markdownToTelegramHTML(text string) string { 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)) @@ -57,11 +80,39 @@ func markdownToTelegramHTML(text string) string { 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) @@ -80,6 +131,24 @@ func extractCodeBlocks(text string) codeBlockMatch { 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 @@ -109,3 +178,7 @@ func escapeHTML(text string) string { 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 f64a8f79b..cebebfed6 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "errors" "fmt" "io" "net/http" @@ -44,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) @@ -89,21 +97,24 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann } 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 { @@ -161,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() } @@ -168,27 +182,52 @@ 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 } - useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 + useMarkdownV2 := c.tgCfg.UseMarkdownV2 - chatID, threadID, err := parseTelegramChatID(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) } 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:] @@ -196,6 +235,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err content := parseContent(chunk, useMarkdownV2) if len([]rune(content)) > 4096 { + if isToolFeedback { + fittedChunk := fitToolFeedbackForTelegram(chunk, useMarkdownV2, 4096) + if fittedChunk != "" && fittedChunk != chunk { + queue = append([]string{fittedChunk}, queue...) + continue + } + } runeChunk := []rune(chunk) ratio := float64(len(runeChunk)) / float64(len([]rune(content))) smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin @@ -206,16 +252,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err } if smallerLen <= 0 { - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) replyToID = "" continue } @@ -244,21 +292,29 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err continue } - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) // Only the first chunk should be a reply; subsequent chunks are normal messages. replyToID = "" } - return nil + 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 { @@ -275,7 +331,7 @@ type sendChunkParams struct { func (c *TelegramChannel) sendChunk( ctx context.Context, params sendChunkParams, -) error { +) (string, error) { tgMsg := tu.Message(tu.ID(params.chatID), params.content) tgMsg.MessageThreadID = params.threadID if params.useMarkdownV2 { @@ -292,17 +348,19 @@ func (c *TelegramChannel) sendChunk( } } - if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + pMsg, err := c.bot.SendMessage(ctx, tgMsg) + if err != nil { logParseFailed(err, params.useMarkdownV2) tgMsg.Text = params.mdFallback tgMsg.ParseMode = "" - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + pMsg, err = c.bot.SendMessage(ctx, tgMsg) + if err != nil { + return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary) } } - return nil + return strconv.Itoa(pMsg.MessageID), nil } // maxTypingDuration limits how long the typing indicator can run. @@ -352,7 +410,7 @@ 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 { - useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 + useMarkdownV2 := c.tgCfg.UseMarkdownV2 cid, _, err := parseTelegramChatID(chatID) if err != nil { return err @@ -370,8 +428,38 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag } _, err = c.bot.EditMessageText(ctx, editMsg) if err != nil { - logParseFailed(err, useMarkdownV2) - _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + // 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 @@ -393,11 +481,94 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess }) } +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 } @@ -420,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, threadID, err := parseTelegramChatID(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 { @@ -454,6 +628,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe continue } + var tgResult *telego.Message switch part.Type { case "image": params := &telego.SendPhotoParams{ @@ -462,11 +637,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Photo: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendPhoto(ctx, params) + 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 fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) + return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) } docParams := &telego.SendDocumentParams{ @@ -475,7 +650,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Document: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendDocument(ctx, docParams) + tgResult, err = c.bot.SendDocument(ctx, docParams) } case "audio": // Send OGG files with "voice" in the filename as Telegram voice @@ -488,7 +663,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Voice: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendVoice(ctx, vparams) + tgResult, err = c.bot.SendVoice(ctx, vparams) } else { params := &telego.SendAudioParams{ ChatID: tu.ID(chatID), @@ -496,7 +671,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Audio: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendAudio(ctx, params) + tgResult, err = c.bot.SendAudio(ctx, params) } case "video": params := &telego.SendVideoParams{ @@ -505,7 +680,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe 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), @@ -513,9 +688,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe 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 { @@ -523,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 { @@ -648,8 +830,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } // 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) } @@ -660,6 +843,23 @@ 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 @@ -678,13 +878,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes }) peerKind := "direct" - peerID := fmt.Sprintf("%d", user.ID) if message.Chat.Type != "private" { peerKind = "group" - peerID = compositeChatID } - - peer := bus.Peer{Kind: peerKind, ID: peerID} messageID := fmt.Sprintf("%d", message.MessageID) metadata := map[string]string{ @@ -694,25 +890,149 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } - // Set parent_peer metadata for per-topic agent binding. + 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 { - metadata["parent_peer_kind"] = "topic" - metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID) + inboundCtx.TopicID = fmt.Sprintf("%d", threadID) + } + if message.ReplyToMessage != nil { + inboundCtx.ReplyToMessageID = fmt.Sprintf("%d", message.ReplyToMessage.MessageID) } - c.HandleMessage(c.ctx, - peer, - messageID, - platformID, + 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 { @@ -760,6 +1080,60 @@ func parseContent(text string, useMarkdownV2 bool) string { return markdownToTelegramHTML(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 + } + + low := 1 + high := len([]rune(content)) + best := utils.Truncate(content, 1) + + for low <= high { + mid := (low + high) / 2 + candidate := utils.FitToolFeedbackMessage(content, mid) + if candidate == "" { + high = mid - 1 + continue + } + if len([]rune(parseContent(candidate, useMarkdownV2))) <= animationSafeLen { + best = candidate + low = mid + 1 + continue + } + high = mid - 1 + } + + 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) { @@ -779,6 +1153,28 @@ func parseTelegramChatID(chatID string) (int64, int, error) { 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 { @@ -884,19 +1280,20 @@ func (c *TelegramChannel) stripBotMention(content string) string { // BeginStream implements channels.StreamingCapable. func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) { - if !c.config.Channels.Telegram.Streaming.Enabled { + if !c.tgCfg.Streaming.Enabled { return nil, fmt.Errorf("streaming disabled in config") } - cid, _, err := parseTelegramChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return nil, err } - streamCfg := c.config.Channels.Telegram.Streaming + 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, @@ -909,6 +1306,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann type telegramStreamer struct { bot *telego.Bot chatID int64 + threadID int draftID int throttleInterval time.Duration minGrowth int @@ -936,10 +1334,11 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error { htmlContent := markdownToTelegramHTML(content) err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{ - ChatID: s.chatID, - DraftID: s.draftID, - Text: htmlContent, - ParseMode: telego.ModeHTML, + 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) @@ -958,6 +1357,7 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error { 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 { @@ -985,3 +1385,32 @@ func cryptoRandInt() int { _, _ = 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_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 614b2ca7f..20b2004a9 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -108,7 +108,7 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() select { case <-ctx.Done(): diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index fd189d9a7..69c76b430 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "testing" @@ -97,13 +98,24 @@ func (s *multipartRecordingConstructor) MultipartRequest( // 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{}) @@ -132,7 +144,9 @@ func newTestChannelWithConstructor( BaseChannel: base, bot: bot, chatIDs: make(map[string]int64), - config: config.DefaultConfig(), + bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true}, + tgCfg: &config.TelegramSettings{}, + progress: channels.NewToolFeedbackAnimator(nil), } } @@ -168,7 +182,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) { ) require.NoError(t, err) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", @@ -206,7 +220,7 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) { 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{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", @@ -231,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: "", }) @@ -248,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!", }) @@ -257,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 @@ -271,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, }) @@ -294,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**", }) @@ -312,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", }) @@ -334,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, }) @@ -364,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, }) @@ -399,7 +583,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) { // 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{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "123456", Content: content, }) @@ -435,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", }) @@ -453,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", }) @@ -510,7 +694,7 @@ func TestSend_WithForumThreadID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "-1001234567890/42", Content: "Hello from topic", }) @@ -519,6 +703,90 @@ func TestSend_WithForumThreadID(t *testing.T) { 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{ @@ -548,16 +816,10 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { inbound, ok := <-messageBus.InboundChan() require.True(t, ok, "expected inbound message") - // Composite chatID should include thread ID - assert.Equal(t, "-1001234567890/42", inbound.ChatID) - - // Peer ID should include thread ID for session key isolation - assert.Equal(t, "group", inbound.Peer.Kind) - assert.Equal(t, "-1001234567890/42", inbound.Peer.ID) - - // Parent peer metadata should be set for agent binding - assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"]) - assert.Equal(t, "42", inbound.Metadata["parent_peer_id"]) + // 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) { @@ -590,13 +852,8 @@ func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { // Plain chatID without thread suffix assert.Equal(t, "-100999", inbound.ChatID) - // Peer ID should be raw chat ID (no thread suffix) - assert.Equal(t, "group", inbound.Peer.Kind) - assert.Equal(t, "-100999", inbound.Peer.ID) - - // No parent peer metadata - assert.Empty(t, inbound.Metadata["parent_peer_kind"]) - assert.Empty(t, inbound.Metadata["parent_peer_id"]) + assert.Equal(t, "group", inbound.Context.ChatType) + assert.Empty(t, inbound.Context.TopicID) } func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { @@ -633,13 +890,183 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { // chatID should NOT include thread suffix for non-forum groups assert.Equal(t, "-100999", inbound.ChatID) - // Peer ID should be raw chat ID (shared session for whole group) - assert.Equal(t, "group", inbound.Peer.Kind) - assert.Equal(t, "-100999", inbound.Peer.ID) + assert.Equal(t, "group", inbound.Context.ChatType) + assert.Empty(t, inbound.Context.TopicID) +} - // No parent peer metadata - assert.Empty(t, inbound.Metadata["parent_peer_kind"]) - assert.Empty(t, inbound.Metadata["parent_peer_id"]) +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) { 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/init.go b/pkg/channels/wecom/init.go index 3aad84d42..78e51d18e 100644 --- a/pkg/channels/wecom/init.go +++ b/pkg/channels/wecom/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewChannel(cfg.Channels.WeCom, 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/wecom.go b/pkg/channels/wecom/wecom.go index 6096b7db3..a0a23feda 100644 --- a/pkg/channels/wecom/wecom.go +++ b/pkg/channels/wecom/wecom.go @@ -34,7 +34,7 @@ const ( type WeComChannel struct { *channels.BaseChannel - config config.WeComConfig + config *config.WeComSettings ctx context.Context cancel context.CancelFunc @@ -108,7 +108,7 @@ func (s *recentMessageSet) Mark(id string) bool { return true } -func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChannel, error) { +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") } @@ -120,8 +120,8 @@ func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChann "wecom", cfg, messageBus, - cfg.AllowFrom, - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + bc.AllowFrom, + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) ch := &WeComChannel{ @@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S }, nil } -func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } content := strings.TrimSpace(msg.Content) if content == "" { - return nil + 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 + return nil, nil } } c.consumeTurn(msg.ChatID, turn) @@ -205,20 +205,20 @@ func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if route, ok := c.routes.Get(msg.ChatID); ok { if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil { - return err + return nil, err } - return nil + return nil, nil } if err := c.sendActivePush(msg.ChatID, 0, content); err != nil { - return err + return nil, err } - return nil + return nil, nil } -func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID) @@ -231,7 +231,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa if strings.TrimSpace(part.Ref) == "" { if caption := strings.TrimSpace(part.Caption); caption != "" { if err := c.sendActivePush(chatID, chatType, caption); err != nil { - return err + return nil, err } } continue @@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part) if err != nil { - return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed) + return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed) } func() { @@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa } }() if err != nil { - return err + return nil, err } } - return nil + return nil, nil } func (c *WeComChannel) connectLoop() { @@ -570,7 +570,6 @@ func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage) return err } - peer := bus.Peer{Kind: peerKind, ID: actualChatID} metadata := map[string]string{ "channel": "wecom", "req_id": reqID, @@ -583,7 +582,20 @@ func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage) metadata["quote_text"] = quoteText } - c.HandleMessage(c.ctx, peer, msg.MsgID, senderID, actualChatID, content, mediaRefs, metadata, sender) + 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 } diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go index c7a4adfc0..85a2f6ef7 100644 --- a/pkg/channels/wecom/wecom_test.go +++ b/pkg/channels/wecom/wecom_test.go @@ -50,11 +50,11 @@ func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) { if inbound.MessageID != "msg-1" { t.Fatalf("inbound MessageID = %q, want msg-1", inbound.MessageID) } - if inbound.Peer.ID != "chat-1" { - t.Fatalf("inbound Peer.ID = %q, want chat-1", inbound.Peer.ID) + if inbound.Context.ChatType != "direct" { + t.Fatalf("inbound Context.ChatType = %q, want direct", inbound.Context.ChatType) } - if inbound.Metadata["req_id"] != "req-1" { - t.Fatalf("inbound req_id = %q, want req-1", inbound.Metadata["req_id"]) + 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") @@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) { return wecomTestAck(nil), nil } - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: "hello", @@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) { } content := strings.Repeat("\u4e2d", 30000) - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: content, @@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) { } content := strings.Repeat("a", 30000) - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: content, @@ -346,7 +346,7 @@ func TestSendMedia_SendsActiveImage(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-1", Parts: []bus.MediaPart{{ @@ -457,7 +457,7 @@ func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-1", Parts: []bus.MediaPart{{ @@ -553,7 +553,7 @@ func TestSendMedia_SendsActiveFile(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-2", Parts: []bus.MediaPart{{ @@ -605,9 +605,10 @@ func TestSendMedia_SendsActiveFile(t *testing.T) { func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel { t.Helper() - cfg := config.WeComConfig{BotID: "bot-1"} + cfg := &config.WeComSettings{BotID: "bot-1"} cfg.SetSecret("secret-1") - ch, err := NewChannel(cfg, messageBus) + bc := &config.Channel{Type: config.ChannelWeCom, Enabled: true} + ch, err := NewChannel(bc, cfg, messageBus) if err != nil { t.Fatalf("NewChannel() error = %v", err) } diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go index 4da7f0db9..cf1b45612 100644 --- a/pkg/channels/weixin/media.go +++ b/pkg/channels/weixin/media.go @@ -1097,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), } // SendMedia implements channels.MediaSender. -func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return basechannels.ErrNotRunning + return nil, basechannels.ErrNotRunning } if err := c.ensureSessionActive(); err != nil { - return err + return nil, err } contextToken := "" @@ -1110,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess contextToken, _ = v.(string) } if contextToken == "" { - return fmt.Errorf( + return nil, fmt.Errorf( "weixin send media: missing context token for chat %s: %w", msg.ChatID, basechannels.ErrSendFailed, @@ -1125,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "ref": part.Ref, "error": err.Error(), }) - return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) } func() { if cleanup != nil { @@ -1147,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "error": err.Error(), }) if c.remainingPause() > 0 { - return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) } - return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) } } - return nil + return nil, nil } diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go index c3ff23584..0f8257895 100644 --- a/pkg/channels/weixin/state.go +++ b/pkg/channels/weixin/state.go @@ -41,14 +41,10 @@ type contextTokensFile struct { } func picoclawHomeDir() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - userHome, _ := os.UserHomeDir() - return filepath.Join(userHome, ".picoclaw") + return config.GetHome() } -func genWeixinAccountKey(cfg config.WeixinConfig) string { +func genWeixinAccountKey(cfg *config.WeixinSettings) string { token := strings.TrimSpace(cfg.Token.String()) if token == "" { return "default" @@ -57,11 +53,11 @@ func genWeixinAccountKey(cfg config.WeixinConfig) string { return hex.EncodeToString(sum[:8]) } -func buildWeixinSyncBufPath(cfg config.WeixinConfig) string { +func buildWeixinSyncBufPath(cfg *config.WeixinSettings) string { return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", genWeixinAccountKey(cfg)+".json") } -func buildWeixinContextTokensPath(cfg config.WeixinConfig) string { +func buildWeixinContextTokensPath(cfg *config.WeixinSettings) string { return filepath.Join(picoclawHomeDir(), "channels", "weixin", "context-tokens", genWeixinAccountKey(cfg)+".json") } diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 65fabe399..2897d2422 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -20,7 +20,7 @@ import ( type WeixinChannel struct { *channels.BaseChannel api *ApiClient - config config.WeixinConfig + config *config.WeixinSettings ctx context.Context cancel context.CancelFunc bus *bus.MessageBus @@ -36,25 +36,48 @@ type WeixinChannel struct { } func init() { - channels.RegisterFactory("weixin", func(cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) { - return NewWeixinChannel(cfg.Channels.Weixin, bus) - }) + 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(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) { +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( - "weixin", + bc.Name(), cfg, messageBus, - cfg.AllowFrom, + bc.AllowFrom, channels.WithMaxMessageLength(4000), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &WeixinChannel{ @@ -334,8 +357,6 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess return } - peer := bus.Peer{Kind: "direct", ID: fromUserID} - metadata := map[string]string{ "from_user_id": fromUserID, "context_token": msg.ContextToken, @@ -354,20 +375,34 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess c.persistContextTokens() } - c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, metadata, sender) + 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) error { +func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if err := c.ensureSessionActive(); err != nil { - return err + return nil, err } if msg.Content == "" { - return nil + return nil, nil } // We need a context_token to send a reply. It should be stored in the conversation metadata. @@ -386,7 +421,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{ "to_user_id": toUserID, }) - return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, 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 { @@ -395,10 +430,15 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "error": err.Error(), }) if c.remainingPause() > 0 { - return fmt.Errorf("weixin send: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed) } - return fmt.Errorf("weixin send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary) } - return nil + 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 index b41b930db..aea2cbb0c 100644 --- a/pkg/channels/weixin/weixin_test.go +++ b/pkg/channels/weixin/weixin_test.go @@ -66,7 +66,7 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) { }, nil })}, }, - config: config.WeixinConfig{ + config: &config.WeixinSettings{ CDNBaseURL: "https://cdn.example.com", }, typingCache: make(map[string]typingTicketCacheEntry), @@ -105,7 +105,7 @@ func TestDownloadAndDecryptCDNBufferUsesFullURLWhenProvided(t *testing.T) { return nil, nil })}, }, - config: config.WeixinConfig{ + config: &config.WeixinSettings{ CDNBaseURL: "https://cdn.example.com", }, typingCache: make(map[string]typingTicketCacheEntry), @@ -155,7 +155,7 @@ func TestDownloadAndDecryptCDNBufferFallsBackToConstructedURLWhenFullURLFails(t }, nil })}, }, - config: config.WeixinConfig{ + config: &config.WeixinSettings{ CDNBaseURL: "https://cdn.example.com", }, typingCache: make(map[string]typingTicketCacheEntry), @@ -224,7 +224,7 @@ func TestUploadBufferToCDN(t *testing.T) { }, nil })}, }, - config: config.WeixinConfig{ + config: &config.WeixinSettings{ CDNBaseURL: "https://cdn.example.com", }, typingCache: make(map[string]typingTicketCacheEntry), @@ -259,7 +259,7 @@ func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) { home := t.TempDir() t.Setenv(config.EnvHome, home) - wxCfg := config.WeixinConfig{ + wxCfg := &config.WeixinSettings{ BaseURL: "https://ilinkai.weixin.qq.com/", } wxCfg.SetToken("token-123") 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 2d85d74f8..17ba0d2f9 100644 --- a/pkg/channels/whatsapp/whatsapp_command_test.go +++ b/pkg/channels/whatsapp/whatsapp_command_test.go @@ -12,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(), } 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 e51bec392..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(), } 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 39e76f752..e268812a0 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -8,12 +8,15 @@ 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 5fd8dd9bc..bb9abe360 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -36,12 +36,15 @@ 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|skills]") { + 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) @@ -49,6 +52,59 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { } } +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) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) @@ -174,6 +230,92 @@ func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) { } } +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) @@ -188,3 +330,79 @@ func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { 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_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 7186a6c25..c0021e55c 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -64,6 +64,11 @@ func listCommand() Definition { )) }, }, + { + Name: "mcp", + Description: "Configured MCP servers", + Handler: listMCPServersHandler(), + }, }, } } 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/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/runtime.go b/pkg/commands/runtime.go index 5ba6a1bd2..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,13 +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/config/config.go b/pkg/config/config.go index 533f45a44..acceee4d5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,117 +7,45 @@ import ( "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" + 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. -// 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 { - // 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 -} - // CurrentVersion is the latest config schema version -const CurrentVersion = 1 +const CurrentVersion = 3 -// Config is the current config structure with version support +func init() { + initChannel() +} + +// Config is the current config structure with version support. type Config struct { - Version int `json:"version" yaml:"-"` // Config schema version for migration - Agents AgentsConfig `json:"agents" yaml:"-"` - Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` - Session SessionConfig `json:"session,omitempty" yaml:"-"` - Channels ChannelsConfig `json:"channels" yaml:"channels"` - ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway" 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:"-"` + // 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:"-"` @@ -125,6 +53,21 @@ type Config struct { 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). @@ -180,7 +123,7 @@ type BuildInfo struct { } // MarshalJSON implements custom JSON marshaling for Config -// to omit providers section when empty and session when empty +// to omit providers section when empty and session when empty. func (c *Config) MarshalJSON() ([]byte, error) { type Alias Config aux := &struct { @@ -190,17 +133,18 @@ func (c *Config) MarshalJSON() ([]byte, error) { Alias: (*Alias)(c), } - // 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. @@ -257,26 +201,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"` } @@ -302,31 +249,37 @@ type SubTurnConfig struct { } 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"` + 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 { - 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"` + 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"` + 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"` + 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"` // "one-at-a-time" (default) or "all" - SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` + SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" + MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) + 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"` // split messages on <|[SPLIT]|> marker + SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> 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 @@ -338,7 +291,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } -// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. +// 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 @@ -351,31 +304,19 @@ 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 { return d.ModelName } -type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"` - Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"` - Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"` - Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"` - MaixCam MaixCamConfig `json:"maixcam" yaml:"-"` - QQ QQConfig `json:"qq" yaml:"qq,omitempty"` - DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"` - Slack SlackConfig `json:"slack" yaml:"slack,omitempty"` - Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"` - LINE LINEConfig `json:"line" yaml:"line,omitempty"` - OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"` - WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` - Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"` - Pico PicoConfig `json:"pico" yaml:"pico,omitempty"` - PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"` - IRC IRCConfig `json:"irc" yaml:"irc,omitempty"` -} - // GroupTriggerConfig controls when the bot responds in group chats. type GroupTriggerConfig struct { MentionOnly bool `json:"mention_only,omitempty"` @@ -411,221 +352,168 @@ type StreamingConfig struct { MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"` } -type WhatsAppConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` +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 TelegramConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Typing TypingConfig `json:"typing,omitempty" yaml:"-"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` - Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` - UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` +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"` } -func (c *TelegramConfig) SetToken(token string) { - c.Token = *NewSecureString(token) -} - -type FeishuConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` +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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` 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 DiscordConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` - MentionOnly bool `json:"mention_only" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Typing TypingConfig `json:"typing,omitempty" yaml:"-"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_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 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 MaixCamSettings struct { + Host string `json:"host" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` } -type QQConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - 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"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_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 DingTalkConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_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 SlackConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Typing TypingConfig `json:"typing,omitempty" yaml:"-"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_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 MatrixConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` - 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:"-"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` - CryptoDatabasePath string `json:"crypto_database_path,omitempty" yaml:"-"` - CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"` +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 LINEConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Typing TypingConfig `json:"typing,omitempty" yaml:"-"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` +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 OneBotConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Typing TypingConfig `json:"typing,omitempty" yaml:"-"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` +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 WeComGroupConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"` } -type WeComConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"REASONING_CHANNEL_ID"` +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"` } -func (c *WeComConfig) SetSecret(secret string) { +func (c *WeComSettings) SetSecret(secret string) { c.Secret = *NewSecureString(secret) } -type WeixinConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"` - 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"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` +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 *WeixinConfig) SetToken(token string) { +func (c *WeixinSettings) SetToken(token string) { c.Token = *NewSecureString(token) } -type PicoConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` - 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:"-"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` +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 *PicoConfig) SetToken(token string) { +func (c *PicoSettings) SetToken(token string) { c.Token = *NewSecureString(token) } -type PicoClientConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"` - 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:"-"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"` +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 IRCConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` - 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:"-"` - AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` - Typing TypingConfig `json:"typing,omitempty" yaml:"-"` - ReasoningChannelID string `json:"reasoning_channel_id" 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 { @@ -640,21 +528,23 @@ type DevicesConfig struct { 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 include openai, anthropic, antigravity, claude-cli, +// 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. -// Default protocol is "openai" if no prefix is specified. 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 @@ -667,14 +557,23 @@ 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 - ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body + 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 @@ -701,6 +600,9 @@ 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 } @@ -712,13 +614,6 @@ func (c *ModelConfig) SetAPIKey(value string) { } } -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"` -} - type ToolDiscoveryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` @@ -787,6 +682,11 @@ 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" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` @@ -833,23 +733,25 @@ type WebToolsConfig struct { 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 `json:"prefer_native" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` + 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" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` - Format string `json:"format,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FORMAT"` - PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` + 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 { @@ -868,11 +770,12 @@ type ExecConfig struct { } type SkillsToolsConfig struct { - ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"` - Registries SkillsRegistriesConfig `yaml:",inline,omitempty" json:"registries"` - 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"` + 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 { @@ -882,8 +785,25 @@ type MediaCleanupConfig struct { } type ReadFileToolConfig struct { - Enabled bool `json:"enabled"` - MaxReadFileSize int `json:"max_read_file_size"` + 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 { @@ -911,7 +831,9 @@ type ToolsConfig struct { 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_"` @@ -938,25 +860,86 @@ type SearchCacheConfig struct { TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` } -type SkillsRegistriesConfig struct { - ClawHub ClawHubRegistryConfig `json:"clawhub" yaml:"clawhub,omitempty"` +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 +} + +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 { - 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"` + 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 ClawHubRegistryConfig struct { - Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` - BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` - AuthToken SecureString `json:"auth_token,omitzero" yaml:"auth_token,omitempty" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` - SearchPath string `json:"search_path" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` - SkillsPath string `json:"skills_path" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` - DownloadPath string `json:"download_path" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` - Timeout int `json:"timeout" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` - MaxZipSize int `json:"max_zip_size" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` - MaxResponseSize int `json:"max_response_size" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` +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 @@ -987,22 +970,33 @@ type MCPServerConfig struct { type MCPConfig struct { 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"` } -func LoadConfig(path string) (*Config, error) { - logger.Debugf("loading config from %s", path) +const DefaultMCPMaxInlineTextChars = 16 * 1024 +func (c *MCPConfig) GetMaxInlineTextChars() int { + if c.MaxInlineTextChars > 0 { + return c.MaxInlineTextChars + } + return DefaultMCPMaxInlineTextChars +} + +func LoadConfig(path string) (*Config, error) { updateResolver(filepath.Dir(path)) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - logger.WarnF("config file not found, using default config", map[string]any{"path": path}) + logger.WarnF( + "config file not found, using default config", + map[string]any{"path": path}, + ) return DefaultConfig(), nil } - logger.Errorf("failed to read config file: %v", err) return nil, err } @@ -1011,7 +1005,9 @@ func LoadConfig(path string) (*Config, error) { Version int `json:"version"` } if e := json.Unmarshal(data, &versionInfo); e != nil { - return nil, fmt.Errorf("failed to detect config version: %w", e) + 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))) @@ -1022,35 +1018,181 @@ func LoadConfig(path string) (*Config, error) { var cfg *Config switch versionInfo.Version { case 0: - logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) - // Legacy config (no version field) - v, e := loadConfigV0(data) - if e != nil { - return nil, e + 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 } - cfg, e = v.Migrate() - if e != nil { - logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) - return nil, e + + 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 } - logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + + 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 } - // Load existing security config and merge with migrated one to prevent data loss - secErr := loadSecurityConfig(cfg, securityPath(path)) - if secErr != nil && !os.IsNotExist(secErr) { - logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr}) - return nil, fmt.Errorf("failed to load existing security config: %w", secErr) - } + 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 @@ -1064,45 +1206,143 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) } - if err := env.Parse(cfg); err != nil { + applyLegacyBindingsMigration(data, cfg) + + gatewayHostBeforeEnv := cfg.Gateway.Host + + if err = env.Parse(cfg); err != nil { return nil, err } + applySkillsRegistryEnvCompat(cfg) + + if err = InitChannelList(cfg.Channels); err != nil { + return nil, err + } + cfg.Gateway.Host, err = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) + if err != nil { + return nil, fmt.Errorf("invalid gateway host: %w", err) + } // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = expandMultiKeyModels(cfg.ModelList) - // Migrate legacy channel config fields to new unified structures - cfg.migrateChannelConfigs() - // 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, _ := os.UserHomeDir() - if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { - homePath = picoclawHome - } else if homePath != "" { - homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome) - } + homePath := GetHome() cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName) } return cfg, nil } +func applySkillsRegistryEnvCompat(cfg *Config) { + if cfg == nil { + return + } + + 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 } - // Create backup of the config file before migration - bakPath := path + ".bak" + 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 } @@ -1118,19 +1358,6 @@ func toNameIndex(list []*ModelConfig) []string { return nameList } -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 - } - - // 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 - } -} - func SaveConfig(path string, cfg *Config) error { if cfg.Version < CurrentVersion { cfg.Version = CurrentVersion @@ -1144,6 +1371,10 @@ func SaveConfig(path string, cfg *Config) error { } // 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 { @@ -1152,12 +1383,9 @@ func SaveConfig(path string, cfg *Config) error { } data, err := json.MarshalIndent(cfg, "", " ") - // Restore original ModelList after serialization - cfg.ModelList = originalModelList if err != nil { return err } - logger.Infof("saving config to %s", path) return fileutil.WriteFileAtomic(path, data, 0o600) } @@ -1223,38 +1451,6 @@ func (c *Config) SecurityCopyFrom(path string) error { return loadSecurityConfig(c, securityPath(path)) } -func MergeAPIKeys(apiKey string, apiKeys []string) []string { - seen := make(map[string]struct{}) - var all []string - - if k := strings.TrimSpace(apiKey); k != "" { - if _, exists := seen[k]; !exists { - seen[k] = struct{}{} - all = append(all, k) - } - } - - for _, k := range apiKeys { - if trimmed := strings.TrimSpace(k); trimmed != "" { - if _, exists := seen[trimmed]; !exists { - seen[trimmed] = struct{}{} - all = append(all, trimmed) - } - } - } - - return all -} - -// expandMultiKeyModels expands ModelConfig entries with multiple API keys into -// separate entries for key-level failover. Each key gets its own ModelConfig entry, -// and the original entry's fallbacks are set up to chain through the expanded entries. -// -// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]} -// Becomes: -// - {"model_name": "gpt-4", "api_keys": ["k1"], "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]} -// - {"model_name": "gpt-4__key_1", "api_keys": {"k2"}} -// - {"model_name": "gpt-4__key_2", "api_keys": {"k3"}} func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { var expanded []*ModelConfig @@ -1278,20 +1474,24 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { // Create a copy for the additional key additionalEntry := &ModelConfig{ - ModelName: expandedName, - 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, - ExtraBody: m.ExtraBody, - isVirtual: true, + 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) @@ -1299,19 +1499,23 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { // Create the primary entry with first key and fallbacks primaryEntry := &ModelConfig{ - ModelName: originalName, - 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, - ExtraBody: m.ExtraBody, - APIKeys: SimpleSecureStrings(keys[0]), + 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 @@ -1355,6 +1559,8 @@ 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": @@ -1367,6 +1573,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": diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go new file mode 100644 index 000000000..4e87fcc3e --- /dev/null +++ b/pkg/config/config_channel.go @@ -0,0 +1,704 @@ +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" +) + +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{}), +} + +// 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 index fd54c9e08..c19620427 100644 --- a/pkg/config/config_old.go +++ b/pkg/config/config_old.go @@ -5,945 +5,619 @@ package config -import ( - "encoding/json" -) +import "strings" -type agentDefaultsV0 struct { - 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"` -} - -// 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 *agentDefaultsV0) GetModelName() string { - if d.ModelName != "" { - return d.ModelName - } - return d.Model -} - -type agentsConfigV0 struct { - Defaults agentDefaultsV0 `json:"defaults"` - List []AgentConfig `json:"list,omitempty"` -} - -// configV0 represents the config structure before versioning was introduced. -// This struct is used for loading legacy config files (version 0). -// It is unexported since it's only used internally for migration. -type configV0 struct { - Agents agentsConfigV0 `json:"agents"` - Bindings []AgentBinding `json:"bindings,omitempty"` - Session SessionConfig `json:"session,omitempty"` - Channels channelsConfigV0 `json:"channels"` - Providers providersConfigV0 `json:"providers,omitempty"` - ModelList []modelConfigV0 `json:"model_list"` - Gateway GatewayConfig `json:"gateway"` - Tools toolsConfigV0 `json:"tools"` - Heartbeat HeartbeatConfig `json:"heartbeat"` - Devices DevicesConfig `json:"devices"` -} - -type toolsConfigV0 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 webToolsConfigV0 `json:"web"` - Cron CronToolsConfig `json:"cron"` - Exec ExecConfig `json:"exec"` - Skills skillsToolsConfigV0 `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 ReadFileToolConfig `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_"` - SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` - 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_"` -} - -type channelsConfigV0 struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram telegramConfigV0 `json:"telegram"` - Feishu feishuConfigV0 `json:"feishu"` - Discord discordConfigV0 `json:"discord"` - MaixCam maixcamConfigV0 `json:"maixcam"` - Weixin weixinConfigV0 `json:"weixin"` - QQ qqConfigV0 `json:"qq"` - DingTalk dingtalkConfigV0 `json:"dingtalk"` - Slack slackConfigV0 `json:"slack"` - Matrix matrixConfigV0 `json:"matrix"` - LINE lineConfigV0 `json:"line"` - OneBot onebotConfigV0 `json:"onebot"` - WeCom wecomConfigV0 `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` - Pico picoConfigV0 `json:"pico"` - IRC ircConfigV0 `json:"irc"` -} - -func (v *channelsConfigV0) ToChannelsConfig() ChannelsConfig { - telegram := v.Telegram.ToTelegramConfig() - feishu := v.Feishu.ToFeishuConfig() - discord := v.Discord.ToDiscordConfig() - maixcam := v.MaixCam.ToMaixCamConfig() - qq := v.QQ.ToQQConfig() - weixin := v.Weixin.ToWeiXinConfig() - dingtalk := v.DingTalk.ToDingTalkConfig() - slack := v.Slack.ToSlackConfig() - matrix := v.Matrix.ToMatrixConfig() - line := v.LINE.ToLINEConfig() - onebot := v.OneBot.ToOneBotConfig() - wecom := v.WeCom.ToWeComConfig() - pico := v.Pico.ToPicoConfig() - irc := v.IRC.ToIRCConfig() - - return ChannelsConfig{ - WhatsApp: v.WhatsApp, - Telegram: telegram, - Feishu: feishu, - Discord: discord, - MaixCam: maixcam, - QQ: qq, - Weixin: weixin, - DingTalk: dingtalk, - Slack: slack, - Matrix: matrix, - LINE: line, - OneBot: onebot, - WeCom: wecom, - Pico: pico, - IRC: irc, - } -} - -type qqConfigV0 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"` - MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` - MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` - SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` -} - -func (v *qqConfigV0) ToQQConfig() QQConfig { - return QQConfig{ - Enabled: v.Enabled, - AppID: v.AppID, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - MaxMessageLength: v.MaxMessageLength, - MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB, - SendMarkdown: v.SendMarkdown, - ReasoningChannelID: v.ReasoningChannelID, - AppSecret: *NewSecureString(v.AppSecret), - } -} - -type telegramConfigV0 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"` - UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` -} - -func (v *telegramConfigV0) ToTelegramConfig() TelegramConfig { - cfg := TelegramConfig{ - Enabled: v.Enabled, - BaseURL: v.BaseURL, - Proxy: v.Proxy, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - UseMarkdownV2: v.UseMarkdownV2, - } - if v.Token != "" { - cfg.Token = *NewSecureString(v.Token) - } - return cfg -} - -type feishuConfigV0 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"` - IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` -} - -func (v *feishuConfigV0) ToFeishuConfig() FeishuConfig { - cfg := FeishuConfig{ - Enabled: v.Enabled, - AppID: v.AppID, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.AppSecret != "" { - cfg.AppSecret = *NewSecureString(v.AppSecret) - } - if v.EncryptKey != "" { - cfg.EncryptKey = *NewSecureString(v.EncryptKey) - } - if v.VerificationToken != "" { - cfg.VerificationToken = *NewSecureString(v.VerificationToken) - } - return cfg -} - -type discordConfigV0 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"` -} - -func (v *discordConfigV0) ToDiscordConfig() DiscordConfig { - cfg := DiscordConfig{ - Enabled: v.Enabled, - Proxy: v.Proxy, - AllowFrom: v.AllowFrom, - MentionOnly: v.MentionOnly, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.Token != "" { - cfg.Token = *NewSecureString(v.Token) - } - return cfg -} - -type maixcamConfigV0 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"` -} - -func (v *maixcamConfigV0) ToMaixCamConfig() MaixCamConfig { - return MaixCamConfig{ - Enabled: v.Enabled, - Host: v.Host, - Port: v.Port, - AllowFrom: v.AllowFrom, - ReasoningChannelID: v.ReasoningChannelID, - } -} - -type dingtalkConfigV0 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"` -} - -func (v *dingtalkConfigV0) ToDingTalkConfig() DingTalkConfig { - cfg := DingTalkConfig{ - Enabled: v.Enabled, - ClientID: v.ClientID, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.ClientSecret != "" { - cfg.ClientSecret = *NewSecureString(v.ClientSecret) - } - return cfg -} - -type slackConfigV0 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"` -} - -func (v *slackConfigV0) ToSlackConfig() SlackConfig { - cfg := SlackConfig{ - Enabled: v.Enabled, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.BotToken != "" { - cfg.BotToken = *NewSecureString(v.BotToken) - } - if v.AppToken != "" { - cfg.AppToken = *NewSecureString(v.AppToken) - } - return cfg -} - -type matrixConfigV0 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"` - MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"` - 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"` -} - -func (v *matrixConfigV0) ToMatrixConfig() MatrixConfig { - cfg := MatrixConfig{ - Enabled: v.Enabled, - Homeserver: v.Homeserver, - UserID: v.UserID, - DeviceID: v.DeviceID, - JoinOnInvite: v.JoinOnInvite, - MessageFormat: v.MessageFormat, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.AccessToken != "" { - cfg.AccessToken = *NewSecureString(v.AccessToken) - } - return cfg -} - -type lineConfigV0 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"` -} - -func (v *lineConfigV0) ToLINEConfig() LINEConfig { - cfg := LINEConfig{ - Enabled: v.Enabled, - WebhookHost: v.WebhookHost, - WebhookPort: v.WebhookPort, - WebhookPath: v.WebhookPath, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.ChannelSecret != "" { - cfg.ChannelSecret = *NewSecureString(v.ChannelSecret) - } - if v.ChannelAccessToken != "" { - cfg.ChannelAccessToken = *NewSecureString(v.ChannelAccessToken) - } - return cfg -} - -type onebotConfigV0 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"` -} - -func (v *onebotConfigV0) ToOneBotConfig() OneBotConfig { - cfg := OneBotConfig{ - Enabled: v.Enabled, - WSUrl: v.WSUrl, - ReconnectInterval: v.ReconnectInterval, - GroupTriggerPrefix: v.GroupTriggerPrefix, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.AccessToken != "" { - cfg.AccessToken = *NewSecureString(v.AccessToken) - } - return cfg -} - -type wecomConfigV0 struct { - Enabled bool `json:"enabled" env:"ENABLED"` - BotID string `json:"bot_id" env:"BOT_ID"` - Secret string `json:"secret" env:"SECRET"` - WebSocketURL string `json:"websocket_url,omitempty" env:"WEBSOCKET_URL"` - SendThinkingMessage bool `json:"send_thinking_message" env:"SEND_THINKING_MESSAGE"` - DMPolicy string `json:"dm_policy,omitempty" env:"DM_POLICY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"ALLOW_FROM"` - GroupPolicy string `json:"group_policy,omitempty" env:"GROUP_POLICY"` - GroupAllowFrom FlexibleStringSlice `json:"group_allow_from,omitempty" env:"GROUP_ALLOW_FROM"` - Groups map[string]WeComGroupConfig `json:"groups,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"` -} - -func (v *wecomConfigV0) ToWeComConfig() WeComConfig { - cfg := WeComConfig{ - Enabled: v.Enabled, - BotID: v.BotID, - WebSocketURL: v.WebSocketURL, - SendThinkingMessage: v.SendThinkingMessage, - AllowFrom: v.AllowFrom, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.Secret != "" { - cfg.Secret = *NewSecureString(v.Secret) - } - return cfg -} - -type weixinConfigV0 struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"` - CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` -} - -func (v *weixinConfigV0) ToWeiXinConfig() WeixinConfig { - cfg := WeixinConfig{ - Enabled: v.Enabled, - BaseURL: v.BaseURL, - CDNBaseURL: v.CDNBaseURL, - Proxy: v.Proxy, - AllowFrom: v.AllowFrom, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.Token != "" { - cfg.Token = *NewSecureString(v.Token) - } - return cfg -} - -type picoConfigV0 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"` -} - -func (v *picoConfigV0) ToPicoConfig() PicoConfig { - cfg := PicoConfig{ - Enabled: v.Enabled, - AllowTokenQuery: v.AllowTokenQuery, - AllowOrigins: v.AllowOrigins, - PingInterval: v.PingInterval, - ReadTimeout: v.ReadTimeout, - WriteTimeout: v.WriteTimeout, - MaxConnections: v.MaxConnections, - AllowFrom: v.AllowFrom, - Placeholder: v.Placeholder, - } - if v.Token != "" { - cfg.Token = *NewSecureString(v.Token) - } - return cfg -} - -type ircConfigV0 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"` -} - -func (v *ircConfigV0) ToIRCConfig() IRCConfig { - cfg := IRCConfig{ - Enabled: v.Enabled, - Server: v.Server, - TLS: v.TLS, - Nick: v.Nick, - User: v.User, - RealName: v.RealName, - SASLUser: v.SASLUser, - Channels: v.Channels, - RequestCaps: v.RequestCaps, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - ReasoningChannelID: v.ReasoningChannelID, - } - if v.Password != "" { - cfg.Password = *NewSecureString(v.Password) - } - if v.NickServPassword != "" { - cfg.NickServPassword = *NewSecureString(v.NickServPassword) - } - if v.SASLPassword != "" { - cfg.SASLPassword = *NewSecureString(v.SASLPassword) - } - return cfg -} - -type providersConfigV0 struct { - Anthropic providerConfigV0 `json:"anthropic"` - OpenAI openAIProviderConfigV0 `json:"openai"` - LiteLLM providerConfigV0 `json:"litellm"` - OpenRouter providerConfigV0 `json:"openrouter"` - Groq providerConfigV0 `json:"groq"` - Zhipu providerConfigV0 `json:"zhipu"` - VLLM providerConfigV0 `json:"vllm"` - Gemini providerConfigV0 `json:"gemini"` - Nvidia providerConfigV0 `json:"nvidia"` - Ollama providerConfigV0 `json:"ollama"` - Moonshot providerConfigV0 `json:"moonshot"` - ShengSuanYun providerConfigV0 `json:"shengsuanyun"` - DeepSeek providerConfigV0 `json:"deepseek"` - Cerebras providerConfigV0 `json:"cerebras"` - Vivgrid providerConfigV0 `json:"vivgrid"` - VolcEngine providerConfigV0 `json:"volcengine"` - GitHubCopilot providerConfigV0 `json:"github_copilot"` - Antigravity providerConfigV0 `json:"antigravity"` - Qwen providerConfigV0 `json:"qwen"` - Mistral providerConfigV0 `json:"mistral"` - Avian providerConfigV0 `json:"avian"` - Minimax providerConfigV0 `json:"minimax"` - LongCat providerConfigV0 `json:"longcat"` - ModelScope providerConfigV0 `json:"modelscope"` - Novita providerConfigV0 `json:"novita"` -} - -// 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 providersConfigV0) 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 == "" && - p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && - p.LongCat.APIKey == "" && p.LongCat.APIBase == "" && - p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" && - p.Novita.APIKey == "" && p.Novita.APIBase == "" -} - -type providerConfigV0 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` -} - -// MarshalJSON implements custom JSON marshaling for providersConfig -// to omit the entire section when empty -func (p providersConfigV0) MarshalJSON() ([]byte, error) { - if p.IsEmpty() { - return []byte("null"), nil - } - type Alias providersConfigV0 - return json.Marshal((*Alias)(&p)) -} - -type openAIProviderConfigV0 struct { - providerConfigV0 - WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` -} - -type modelConfigV0 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") - - // HTTP-based providers - APIBase string `json:"api_base,omitempty"` // API endpoint URL - APIKey string `json:"api_key"` // API authentication key (single key) - APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) - 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 - ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc - 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 -} - -func (c *configV0) migrateChannelConfigs() { - // Discord: mention_only -> group_trigger.mention_only - if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { - c.Channels.Discord.GroupTrigger.MentionOnly = true - } - - // 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 - } -} - -func (c *configV0) Migrate() (*Config, error) { - // Migrate legacy channel config fields to new unified structures - cfg := DefaultConfig() - - // Always copy user's Agents config to preserve settings like Provider, Model, MaxTokens - cfg.Agents.List = c.Agents.List - cfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace - cfg.Agents.Defaults.RestrictToWorkspace = c.Agents.Defaults.RestrictToWorkspace - cfg.Agents.Defaults.AllowReadOutsideWorkspace = c.Agents.Defaults.AllowReadOutsideWorkspace - cfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider - cfg.Agents.Defaults.ModelName = c.Agents.Defaults.GetModelName() - cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks - cfg.Agents.Defaults.ImageModel = c.Agents.Defaults.ImageModel - cfg.Agents.Defaults.ImageModelFallbacks = c.Agents.Defaults.ImageModelFallbacks - cfg.Agents.Defaults.MaxTokens = c.Agents.Defaults.MaxTokens - cfg.Agents.Defaults.Temperature = c.Agents.Defaults.Temperature - cfg.Agents.Defaults.MaxToolIterations = c.Agents.Defaults.MaxToolIterations - cfg.Agents.Defaults.SummarizeMessageThreshold = c.Agents.Defaults.SummarizeMessageThreshold - cfg.Agents.Defaults.SummarizeTokenPercent = c.Agents.Defaults.SummarizeTokenPercent - cfg.Agents.Defaults.MaxMediaSize = c.Agents.Defaults.MaxMediaSize - cfg.Agents.Defaults.Routing = c.Agents.Defaults.Routing - - // Copy other top-level fields - cfg.Bindings = c.Bindings - cfg.Session = c.Session - cfg.Channels = c.Channels.ToChannelsConfig() - cfg.Gateway = c.Gateway - cfg.Tools.Web = c.Tools.Web.ToWebToolsConfig() - cfg.Tools.Cron = c.Tools.Cron - cfg.Tools.Exec = c.Tools.Exec - cfg.Tools.Skills = c.Tools.Skills.ToSkillsToolsConfig() - cfg.Tools.MediaCleanup = c.Tools.MediaCleanup - cfg.Tools.MCP = c.Tools.MCP - cfg.Tools.AppendFile = c.Tools.AppendFile - cfg.Tools.EditFile = c.Tools.EditFile - cfg.Tools.FindSkills = c.Tools.FindSkills - cfg.Tools.I2C = c.Tools.I2C - cfg.Tools.InstallSkill = c.Tools.InstallSkill - cfg.Tools.ListDir = c.Tools.ListDir - cfg.Tools.Message = c.Tools.Message - cfg.Tools.ReadFile = c.Tools.ReadFile - cfg.Tools.SendFile = c.Tools.SendFile - cfg.Tools.Spawn = c.Tools.Spawn - cfg.Tools.SpawnStatus = c.Tools.SpawnStatus - cfg.Tools.SPI = c.Tools.SPI - cfg.Tools.Subagent = c.Tools.Subagent - cfg.Tools.WebFetch = c.Tools.WebFetch - cfg.Tools.AllowReadPaths = c.Tools.AllowReadPaths - cfg.Tools.AllowWritePaths = c.Tools.AllowWritePaths - cfg.Heartbeat = c.Heartbeat - cfg.Devices = c.Devices - - if len(c.ModelList) > 0 { - // Convert []modelConfigV0 to []ModelConfig - cfg.ModelList = make([]*ModelConfig, len(c.ModelList)) - for i, m := range c.ModelList { - cfg.ModelList[i] = &ModelConfig{ - ModelName: m.ModelName, - Model: m.Model, - APIBase: m.APIBase, - Proxy: m.Proxy, - Fallbacks: m.Fallbacks, - AuthMethod: m.AuthMethod, - ConnectMode: m.ConnectMode, - Workspace: m.Workspace, - RPM: m.RPM, - MaxTokensField: m.MaxTokensField, - RequestTimeout: m.RequestTimeout, - ThinkingLevel: m.ThinkingLevel, - APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)), +// 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 } } } - - cfg.Version = CurrentVersion - return cfg, nil + return true } -type webToolsConfigV0 struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` - Brave braveConfigV0 ` json:"brave"` - Tavily tavilyConfigV0 ` json:"tavily"` - DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` - Perplexity perplexityConfigV0 ` json:"perplexity"` - SearXNG SearXNGConfig ` json:"searxng"` - GLMSearch glmSearchConfigV0 ` json:"glm_search"` - BaiduSearch baiduSearchConfigV0 ` json:"baidu_search"` - PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` - Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` - Format string ` json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` - PrivateHostWhitelist FlexibleStringSlice ` json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` -} - -type braveConfigV0 struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` - APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` -} - -func toSecureStrings(keys []string) SecureStrings { - apikeys := make(SecureStrings, len(keys)) - for i, key := range keys { - apikeys[i] = NewSecureString(key) +// 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 } - return apikeys -} -func (v *braveConfigV0) ToBraveConfig() BraveConfig { - return BraveConfig{ - Enabled: v.Enabled, - MaxResults: v.MaxResults, - APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), + 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 + }, + }, } -} -type tavilyConfigV0 struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` - APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` -} + // 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 -func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig { - return TavilyConfig{ - Enabled: v.Enabled, - BaseURL: v.BaseURL, - MaxResults: v.MaxResults, - APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), - } -} - -type perplexityConfigV0 struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` - APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` -} - -func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig { - return PerplexityConfig{ - Enabled: v.Enabled, - MaxResults: v.MaxResults, - APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), - } -} - -type glmSearchConfigV0 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"` - SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` -} - -func (v *glmSearchConfigV0) ToGLMSearchConfig() GLMSearchConfig { - return GLMSearchConfig{ - Enabled: v.Enabled, - APIKey: *NewSecureString(v.APIKey), - BaseURL: v.BaseURL, - SearchEngine: v.SearchEngine, - } -} - -type baiduSearchConfigV0 struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` -} - -func (v *baiduSearchConfigV0) ToBaiduSearchConfig() BaiduSearchConfig { - return BaiduSearchConfig{ - Enabled: v.Enabled, - APIKey: *NewSecureString(v.APIKey), - BaseURL: v.BaseURL, - MaxResults: v.MaxResults, - } -} - -func (v *webToolsConfigV0) ToWebToolsConfig() WebToolsConfig { - brave := v.Brave.ToBraveConfig() - tavily := v.Tavily.ToTavilyConfig() - perplexity := v.Perplexity.ToPerplexityConfig() - glmSearch := v.GLMSearch.ToGLMSearchConfig() - baiduSearch := v.BaiduSearch.ToBaiduSearchConfig() - - return WebToolsConfig{ - ToolConfig: v.ToolConfig, - Brave: brave, - Tavily: tavily, - DuckDuckGo: v.DuckDuckGo, - Perplexity: perplexity, - SearXNG: v.SearXNG, - GLMSearch: glmSearch, - PreferNative: v.PreferNative, - Proxy: v.Proxy, - FetchLimitBytes: v.FetchLimitBytes, - Format: v.Format, - PrivateHostWhitelist: v.PrivateHostWhitelist, - BaiduSearch: baiduSearch, - } -} - -type skillsToolsConfigV0 struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` - Registries skillsRegistriesConfigV0 ` json:"registries"` - Github skillsGithubConfigV0 ` json:"github"` - MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` - SearchCache SearchCacheConfig ` json:"search_cache"` -} - -type skillsRegistriesConfigV0 struct { - ClawHub clawHubRegistryConfigV0 `json:"clawhub"` -} - -type clawHubRegistryConfigV0 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"` -} - -func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() ClawHubRegistryConfig { - cfg := ClawHubRegistryConfig{ - Enabled: v.Enabled, - BaseURL: v.BaseURL, - SearchPath: v.SearchPath, - SkillsPath: v.SkillsPath, - } - if v.AuthToken != "" { - cfg.AuthToken = *NewSecureString(v.AuthToken) - } - return cfg -} - -type skillsGithubConfigV0 struct { - Token string `json:"token" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` -} - -func (v *skillsGithubConfigV0) ToSkillsGithubConfig() SkillsGithubConfig { - return SkillsGithubConfig{ - Token: *NewSecureString(v.Token), - Proxy: v.Proxy, - } -} - -func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() SkillsRegistriesConfig { - clawHub := v.ClawHub.ToClawHubRegistryConfig() - - return SkillsRegistriesConfig{ - ClawHub: clawHub, - } -} - -func (v *skillsToolsConfigV0) ToSkillsToolsConfig() SkillsToolsConfig { - registries := v.Registries.ToSkillsRegistriesConfig() - github := v.Github.ToSkillsGithubConfig() - return SkillsToolsConfig{ - ToolConfig: v.ToolConfig, - Registries: registries, - Github: github, - MaxConcurrentSearches: v.MaxConcurrentSearches, - SearchCache: v.SearchCache, + 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 afb4ce425..fc810ad7d 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -80,23 +80,6 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) { } } -func TestProvidersConfig_IsEmpty(t *testing.T) { - var empty providersConfigV0 - t.Logf("empty: %+v", empty) - if !empty.IsEmpty() { - t.Fatal("empty providersConfig should report empty") - } - - novita := providersConfigV0{ - Novita: providerConfigV0{ - APIKey: "test-key", - }, - } - if novita.IsEmpty() { - t.Fatal("providersConfig with novita settings should not report empty") - } -} - func TestAgentConfig_FullParse(t *testing.T) { jsonData := `{ "agents": { @@ -126,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"] } @@ -175,20 +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) @@ -199,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": { @@ -219,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") } } @@ -273,7 +503,7 @@ 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 { @@ -288,17 +518,56 @@ func TestDefaultConfig_Gateway(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) } } @@ -366,7 +635,9 @@ func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) { path := filepath.Join(tmpDir, "config.json") cfg := DefaultConfig() - cfg.Channels.Telegram.Placeholder.Enabled = false + 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) @@ -387,10 +658,9 @@ func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) { if err != nil { t.Fatalf("LoadConfig failed: %v", err) } - if loaded.Channels.Telegram.Placeholder.Enabled { - t.Fatal( - "telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip", - ) + bc := loaded.Channels.Get("telegram") + if bc != nil && bc.Placeholder.Enabled { + t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip") } } @@ -469,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 { @@ -490,11 +760,36 @@ func TestDefaultConfig_WebPreferNativeEnabled(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) { @@ -517,6 +812,9 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { "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_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { @@ -551,6 +849,72 @@ func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) { } } +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 { @@ -763,7 +1127,7 @@ func TestLoadConfig_HooksProcessConfig(t *testing.T) { } } -// TestDefaultConfig_DMScope verifies the default dm_scope value +// TestDefaultConfig_SessionDimensions verifies the default session dimensions // TestDefaultConfig_SummarizationThresholds verifies summarization defaults func TestDefaultConfig_SummarizationThresholds(t *testing.T) { cfg := DefaultConfig() @@ -779,11 +1143,11 @@ 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) } } @@ -822,6 +1186,37 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { } } +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 { @@ -949,6 +1344,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { input string expected []string }{ + { + name: "null", + input: `null`, + expected: nil, + }, { name: "single string", input: `"Thinking..."`, @@ -977,6 +1377,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { 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)) } @@ -995,7 +1401,6 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) { data := `{ "version": 1, "agents": { "defaults": { "workspace": "", "model": "", "max_tokens": 0, "max_tool_iterations": 0 } }, - "bindings": [], "session": {}, "channels": { "telegram": { @@ -1023,32 +1428,19 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := []string(cfg.Channels.Telegram.Placeholder.Text); len(got) != 1 || - got[0] != "Thinking..." { + 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_key into memory but does NOT rewrite the config file. File writes are the sole +// 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":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` - if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } - secPath := filepath.Join(dir, SecurityConfigFile) - const securityConfig = ` -model_list: - test:0: - api_keys: - - "sk-plaintext" -` - if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } + 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) } @@ -1451,6 +1843,118 @@ func TestConfigLogLevelEmpty(t *testing.T) { } } +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") @@ -1487,12 +1991,78 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { } } +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].Model == "minimax/MiniMax-M2.5" { + if cfg.ModelList[i].Provider == "minimax" && cfg.ModelList[i].Model == "MiniMax-M2.5" { minimaxCfg = cfg.ModelList[i] break } @@ -1593,28 +2163,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { }, }, // Channel tokens - Channels: ChannelsConfig{ - Telegram: TelegramConfig{Token: *NewSecureString("telegram-bot-token-abcdef")}, - Discord: DiscordConfig{Token: *NewSecureString("discord-bot-token-xyz789")}, - Slack: SlackConfig{ - BotToken: *NewSecureString("xoxb-slack-bot-token"), - AppToken: *NewSecureString("xapp-slack-app-token"), - }, - Matrix: MatrixConfig{AccessToken: *NewSecureString("matrix-access-token-abc")}, - Feishu: FeishuConfig{ - AppSecret: *NewSecureString("feishu-app-secret-123"), - EncryptKey: *NewSecureString("feishu-encrypt-key"), - }, - DingTalk: DingTalkConfig{ClientSecret: *NewSecureString("dingtalk-client-secret")}, - OneBot: OneBotConfig{AccessToken: *NewSecureString("onebot-access-token")}, - WeCom: WeComConfig{Secret: *NewSecureString("wecom-secret")}, - Pico: PicoConfig{Token: *NewSecureString("pico-token-abc123")}, - IRC: IRCConfig{ - Password: *NewSecureString("irc-password"), - NickServPassword: *NewSecureString("nickserv-pass"), - SASLPassword: *NewSecureString("sasl-pass"), - }, - }, + Channels: testChannelsConfigWithTokens(), Tools: ToolsConfig{ FilterSensitiveData: true, FilterMinLength: 8, @@ -1634,9 +2183,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { Skills: SkillsToolsConfig{ Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")}, Registries: SkillsRegistriesConfig{ - ClawHub: ClawHubRegistryConfig{ - AuthToken: *NewSecureString("clawhub-auth-token"), - }, + &SkillRegistryConfig{Name: "clawhub", AuthToken: *NewSecureString("clawhub-auth-token")}, }, }, }, @@ -1712,3 +2259,209 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// 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 bc4ab0649..8e2494ae5 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -6,7 +6,7 @@ package config import ( - "os" + "encoding/json" "path/filepath" "github.com/sipeed/picoclaw/pkg" @@ -14,19 +14,15 @@ import ( // 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(EnvHome); picoclawHome != "" { - homePath = picoclawHome - } else { - userHome, _ := os.UserHomeDir() - homePath = filepath.Join(userHome, pkg.DefaultPicoClawHome) - } - workspacePath := filepath.Join(homePath, pkg.WorkspaceName) + 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, @@ -39,121 +35,19 @@ func DefaultConfig() *Config { SummarizeTokenPercent: 75, SteeringMode: "one-at-a-time", ToolFeedback: ToolFeedbackConfig{ - Enabled: false, - MaxArgsLength: 300, + Enabled: false, + MaxArgsLength: 300, + SeparateMessages: false, }, - SplitOnMarker: false, + SplitOnMarker: false, + MaxLLMRetries: 2, + LLMRetryBackoffSecs: 2, }, }, - Bindings: []AgentBinding{}, Session: SessionConfig{ - DMScope: "per-channel-peer", - }, - Channels: ChannelsConfig{ - WhatsApp: WhatsAppConfig{ - Enabled: false, - BridgeURL: "ws://localhost:3001", - UseNative: false, - SessionStorePath: "", - AllowFrom: FlexibleStringSlice{}, - }, - Telegram: TelegramConfig{ - Enabled: false, - AllowFrom: FlexibleStringSlice{}, - Typing: TypingConfig{Enabled: true}, - Placeholder: PlaceholderConfig{ - Enabled: true, - Text: FlexibleStringSlice{"Thinking... 💭"}, - }, - Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200}, - UseMarkdownV2: false, - }, - Feishu: FeishuConfig{ - Enabled: false, - AppID: "", - AllowFrom: FlexibleStringSlice{}, - }, - Discord: DiscordConfig{ - Enabled: false, - AllowFrom: FlexibleStringSlice{}, - MentionOnly: false, - }, - MaixCam: MaixCamConfig{ - Enabled: false, - Host: "0.0.0.0", - Port: 18790, - AllowFrom: FlexibleStringSlice{}, - }, - QQ: QQConfig{ - Enabled: false, - AppID: "", - AllowFrom: FlexibleStringSlice{}, - MaxMessageLength: 2000, - MaxBase64FileSizeMiB: 0, - }, - DingTalk: DingTalkConfig{ - Enabled: false, - ClientID: "", - AllowFrom: FlexibleStringSlice{}, - }, - Slack: SlackConfig{ - Enabled: false, - AllowFrom: FlexibleStringSlice{}, - }, - Matrix: MatrixConfig{ - Enabled: false, - Homeserver: "https://matrix.org", - UserID: "", - DeviceID: "", - JoinOnInvite: true, - AllowFrom: FlexibleStringSlice{}, - GroupTrigger: GroupTriggerConfig{ - MentionOnly: true, - }, - Placeholder: PlaceholderConfig{ - Enabled: true, - Text: FlexibleStringSlice{"Thinking... 💭"}, - }, - CryptoDatabasePath: "", - CryptoPassphrase: "", - }, - LINE: LINEConfig{ - Enabled: false, - 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", - ReconnectInterval: 5, - AllowFrom: FlexibleStringSlice{}, - }, - WeCom: WeComConfig{ - Enabled: false, - BotID: "", - WebSocketURL: "wss://openws.work.weixin.qq.com", - SendThinkingMessage: true, - AllowFrom: FlexibleStringSlice{}, - }, - Weixin: WeixinConfig{ - Enabled: false, - BaseURL: "https://ilinkai.weixin.qq.com/", - CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c", - AllowFrom: FlexibleStringSlice{}, - Proxy: "", - }, - Pico: PicoConfig{ - Enabled: false, - PingInterval: 30, - ReadTimeout: 60, - WriteTimeout: 10, - MaxConnections: 100, - AllowFrom: FlexibleStringSlice{}, - }, + Dimensions: []string{"chat"}, }, + Channels: defaultChannels(), Hooks: HooksConfig{ Enabled: true, Defaults: HookDefaultsConfig{ @@ -170,122 +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", }, // OpenAI - https://platform.openai.com/api-keys { ModelName: "gpt-5.4", - Model: "openai/gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", APIBase: "https://api.openai.com/v1", }, // 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", }, // DeepSeek - https://platform.deepseek.com/ { ModelName: "deepseek-chat", - Model: "deepseek/deepseek-chat", + Provider: "deepseek", + Model: "deepseek-chat", APIBase: "https://api.deepseek.com/v1", }, + // 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", }, // 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", }, // 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", }, // 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", }, // OpenRouter (100+ models) - https://openrouter.ai/keys { ModelName: "openrouter-auto", - Model: "openrouter/auto", + Provider: "openrouter", + Model: "auto", APIBase: "https://openrouter.ai/api/v1", }, { ModelName: "openrouter-gpt-5.4", - Model: "openrouter/openai/gpt-5.4", + Provider: "openrouter", + Model: "openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", }, // 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", }, // 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", }, // Vivgrid - https://vivgrid.com { ModelName: "vivgrid-auto", - Model: "vivgrid/auto", + Provider: "vivgrid", + Model: "auto", APIBase: "https://api.vivgrid.com/v1", }, // Volcengine (火山引擎) - https://console.volcengine.com/ark { ModelName: "ark-code-latest", - Model: "volcengine/ark-code-latest", + Provider: "volcengine", + Model: "ark-code-latest", APIBase: "https://ark.cn-beijing.volces.com/api/v3", }, { ModelName: "doubao-pro", - Model: "volcengine/doubao-pro-32k", + Provider: "volcengine", + Model: "doubao-pro-32k", APIBase: "https://ark.cn-beijing.volces.com/api/v3", }, // ShengsuanYun (神算云) { ModelName: "deepseek-v3", - Model: "shengsuanyun/deepseek-v3", + Provider: "shengsuanyun", + Model: "deepseek-v3", APIBase: "https://api.shengsuanyun.com/v1", }, // 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.4", - Model: "github-copilot/gpt-5.4", + Provider: "github-copilot", + Model: "gpt-5.4", APIBase: "http://localhost:4321", AuthMethod: "oauth", }, @@ -293,33 +213,38 @@ func DefaultConfig() *Config { // Ollama (local) - https://ollama.com { ModelName: "llama3", - Model: "ollama/llama3", + Provider: "ollama", + Model: "llama3", APIBase: "http://localhost:11434/v1", }, // 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", }, // 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", }, { ModelName: "kimi-k2.5", - Model: "avian/moonshotai/kimi-k2.5", + Provider: "avian", + Model: "moonshotai/kimi-k2.5", APIBase: "https://api.avian.io/v1", }, // Minimax - https://api.minimaxi.com/ { ModelName: "MiniMax-M2.5", - Model: "minimax/MiniMax-M2.5", + Provider: "minimax", + Model: "MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", ExtraBody: map[string]any{"reasoning_split": true}, }, @@ -327,37 +252,52 @@ func DefaultConfig() *Config { // LongCat - https://longcat.chat/platform { ModelName: "LongCat-Flash-Thinking", - Model: "longcat/LongCat-Flash-Thinking", + Provider: "longcat", + Model: "LongCat-Flash-Thinking", APIBase: "https://api.longcat.chat/openai", }, // ModelScope (魔搭社区) - https://modelscope.cn/my/tokens { ModelName: "modelscope-qwen", - Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", + 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", }, + // 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", - Model: "azure/my-gpt5-deployment", + Provider: "azure", + Model: "my-gpt5-deployment", APIBase: "https://your-resource.openai.azure.com", }, }, Gateway: GatewayConfig{ - Host: "127.0.0.1", + Host: "localhost", Port: 18790, HotReload: false, - LogLevel: "warn", + LogLevel: DefaultGatewayLogLevel, + }, + Events: EventsConfig{ + Logging: defaultEventLoggingConfig(), }, Tools: ToolsConfig{ FilterSensitiveData: true, @@ -373,6 +313,7 @@ func DefaultConfig() *Config { ToolConfig: ToolConfig{ Enabled: true, }, + Provider: "auto", PreferNative: true, Proxy: "", FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default @@ -385,10 +326,14 @@ func DefaultConfig() *Config { Enabled: false, MaxResults: 5, }, - DuckDuckGo: DuckDuckGoConfig{ + Sogou: SogouConfig{ Enabled: true, MaxResults: 5, }, + DuckDuckGo: DuckDuckGoConfig{ + Enabled: false, + MaxResults: 5, + }, Perplexity: PerplexityConfig{ Enabled: false, MaxResults: 5, @@ -430,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, @@ -444,6 +397,9 @@ func DefaultConfig() *Config { SendFile: ToolConfig{ Enabled: true, }, + SendTTS: ToolConfig{ + Enabled: false, + }, MCP: MCPConfig{ ToolConfig: ToolConfig{ Enabled: false, @@ -455,7 +411,8 @@ func DefaultConfig() *Config { UseBM25: true, UseRegex: false, }, - Servers: map[string]MCPServerConfig{}, + MaxInlineTextChars: DefaultMCPMaxInlineTextChars, + Servers: map[string]MCPServerConfig{}, }, AppendFile: ToolConfig{ Enabled: true, @@ -480,8 +437,12 @@ func DefaultConfig() *Config { }, ReadFile: ReadFileToolConfig{ Enabled: true, + Mode: ReadFileModeBytes, MaxReadFileSize: 64 * 1024, // 64KB }, + Serial: ToolConfig{ + Enabled: false, // Hardware tool - requires host serial ports + }, Spawn: ToolConfig{ Enabled: true, }, @@ -511,7 +472,9 @@ func DefaultConfig() *Config { }, Voice: VoiceConfig{ ModelName: "", + TTSModelName: "", EchoTranscription: false, + ElevenLabsAPIKey: "", }, BuildInfo: BuildInfo{ Version: Version, @@ -521,3 +484,99 @@ func DefaultConfig() *Config { }, } } + +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 index b04ff19f5..5a2590299 100644 --- a/pkg/config/envkeys.go +++ b/pkg/config/envkeys.go @@ -5,6 +5,13 @@ 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 @@ -32,6 +39,19 @@ const ( EnvBinary = "PICOCLAW_BINARY" // EnvGatewayHost overrides the host address for the gateway server. - // Default: "127.0.0.1" + // 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/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 fee800a76..96914819e 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -7,13 +7,14 @@ package config import ( "encoding/json" - "slices" + "fmt" + "os" "strings" -) -type migratable interface { - Migrate() (*Config, error) -} + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/logger" +) // buildModelWithProtocol constructs a model string with protocol prefix. // If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is. @@ -26,489 +27,57 @@ func buildModelWithProtocol(protocol, model string) string { return protocol + "/" + model } -// v0ConvertProvidersToModelList converts the old providersConfigV0 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 v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 { - if cfg == nil { - return nil - } - - // 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 providersConfigV0) (modelConfigV0, bool) - } - - // Get user's configured provider and model - userProvider := strings.ToLower(cfg.Agents.Defaults.Provider) - userModel := cfg.Agents.Defaults.GetModelName() - - p := cfg.Providers - - var result []modelConfigV0 - - // 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 providersConfigV0) (modelConfigV0, bool) { - if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - ModelName: "openai", - Model: "openai/gpt-5.4", - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Groq.APIKey == "" && p.Groq.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - ModelName: "github-copilot", - Model: "github-copilot/gpt-5.4", - APIBase: p.GitHubCopilot.APIBase, - ConnectMode: p.GitHubCopilot.ConnectMode, - }, true - }, - }, - { - providerNames: []string{"antigravity"}, - protocol: "antigravity", - buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { - if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 providersConfigV0) (modelConfigV0, bool) { - if p.Avian.APIKey == "" && p.Avian.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - 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 - }, - }, - { - providerNames: []string{"longcat"}, - protocol: "longcat", - buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { - if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - ModelName: "longcat", - Model: "longcat/LongCat-Flash-Thinking", - APIKey: p.LongCat.APIKey, - APIBase: p.LongCat.APIBase, - Proxy: p.LongCat.Proxy, - RequestTimeout: p.LongCat.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"modelscope"}, - protocol: "modelscope", - buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { - if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" { - return modelConfigV0{}, false - } - return modelConfigV0{ - ModelName: "modelscope", - Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", - APIKey: p.ModelScope.APIKey, - APIBase: p.ModelScope.APIBase, - Proxy: p.ModelScope.Proxy, - RequestTimeout: p.ModelScope.RequestTimeout, - }, true - }, - }, - } - - // Process each provider migration - for _, m := range migrations { - mc, ok := m.buildConfig(p) - if !ok { - continue - } - - // 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 - } - - result = append(result, mc) - } - - return result +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"` } -// loadConfigV0 loads a legacy config (no version field) -func loadConfigV0(data []byte) (migratable, error) { - var v0 configV0 - if err := json.Unmarshal(data, &v0); err != nil { - return nil, err +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 } - - v0.migrateChannelConfigs() - - // Auto-migrate: if only legacy providers config exists, convert to model_list - if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() { - newModelList := v0ConvertProvidersToModelList(&v0) - // Convert []ModelConfig to []modelConfigV0 - v0.ModelList = make([]modelConfigV0, len(newModelList)) - for i, m := range newModelList { - v0.ModelList[i] = modelConfigV0{ - ModelName: m.ModelName, - Model: m.Model, - APIBase: m.APIBase, - Proxy: m.Proxy, - Fallbacks: m.Fallbacks, - AuthMethod: m.AuthMethod, - ConnectMode: m.ConnectMode, - Workspace: m.Workspace, - RPM: m.RPM, - MaxTokensField: m.MaxTokensField, - RequestTimeout: m.RequestTimeout, - ThinkingLevel: m.ThinkingLevel, - APIKey: m.APIKey, - APIKeys: m.APIKeys, - } - } + defaults, ok := agents["defaults"].(map[string]any) + if !ok { + return } - - return &v0, nil + 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) @@ -522,15 +91,410 @@ func loadConfig(data []byte) (*Config, error) { // 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 { + if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil { return nil, err } if len(tmp.ModelList) > 0 { cfg.ModelList = nil } - if err := json.Unmarshal(data, cfg); err != nil { + if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil { return nil, err } return cfg, nil } + +func mergeAPIKeys(apiKey string, apiKeys []string) []string { + seen := make(map[string]struct{}) + var all []string + + if k := strings.TrimSpace(apiKey); k != "" { + if _, exists := seen[k]; !exists { + seen[k] = struct{}{} + all = append(all, k) + } + } + + for _, k := range apiKeys { + if trimmed := strings.TrimSpace(k); trimmed != "" { + if _, exists := seen[trimmed]; !exists { + seen[trimmed] = struct{}{} + all = append(all, trimmed) + } + } + } + + 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 + } + } + } + } + + // 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 index bc8160967..49d341eb7 100644 --- a/pkg/config/migration_integration_test.go +++ b/pkg/config/migration_integration_test.go @@ -10,6 +10,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/require" ) // TestMigration_Integration_LegacyConfigWithoutWorkspace tests the issue reported: @@ -74,6 +76,8 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) { 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( @@ -100,11 +104,14 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) { } // Verify other config sections are preserved - if !cfg.Channels.Telegram.Enabled { + var tgCfg TelegramSettings + bc := cfg.Channels.Get("telegram") + if bc == nil || !bc.Enabled { t.Error("Telegram.Enabled should be true") } - if cfg.Channels.Telegram.Token.String() != "test-token" { - t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token.String(), "test-token") + 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) @@ -356,19 +363,21 @@ func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) { } // Discord: mention_only should be migrated to group_trigger.mention_only - if cfg.Channels.Discord.GroupTrigger.MentionOnly != true { + 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 - if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 { - t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(cfg.Channels.OneBot.GroupTrigger.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 cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" { - t.Errorf("Prefixes[0] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[0], "/") + if oneBotBC.GroupTrigger.Prefixes[0] != "/" { + t.Errorf("Prefixes[0] = %q, want %q", oneBotBC.GroupTrigger.Prefixes[0], "/") } - if cfg.Channels.OneBot.GroupTrigger.Prefixes[1] != "!" { - t.Errorf("Prefixes[1] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[1], "!") + if oneBotBC.GroupTrigger.Prefixes[1] != "!" { + t.Errorf("Prefixes[1] = %q, want %q", oneBotBC.GroupTrigger.Prefixes[1], "!") } } } @@ -578,6 +587,7 @@ func TestMigration_PreservesExistingSecurityConfig(t *testing.T) { // 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", @@ -641,20 +651,38 @@ web: 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 - if cfg.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" { + 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", - cfg.Channels.Telegram.Token.String(), "existing-telegram-token-from-env") + tgCfg1.Token.String(), "existing-telegram-token-from-env") } // Discord token should be preserved (even though legacy config didn't have it) - if cfg.Channels.Discord.Token.String() != "existing-discord-token-from-env" { + 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", - cfg.Channels.Discord.Token.String(), "existing-discord-token-from-env") + 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") @@ -668,16 +696,415 @@ web: // 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) } - if reloadedSec.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" { + 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") } - if reloadedSec.Channels.Discord.Token.String() != "existing-discord-token-from-env" { + 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 aeabe9730..8bd3b3d26 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -6,560 +6,14 @@ package config import ( - "strings" + "os" + "path/filepath" "testing" + + "github.com/stretchr/testify/require" ) -func TestConvertProvidersToModelList_OpenAI(t *testing.T) { - cfg := &configV0{ - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{ - providerConfigV0: providerConfigV0{ - APIKey: "sk-test-key", - APIBase: "https://custom.api.com/v1", - }, - }, - }, - } - - result := v0ConvertProvidersToModelList(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.4" { - t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4") - } - 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 := &configV0{ - Providers: providersConfigV0{ - Anthropic: providerConfigV0{ - APIBase: "https://custom.anthropic.com", - }, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Providers: providersConfigV0{ - LiteLLM: providerConfigV0{ - APIBase: "http://localhost:4000/v1", - }, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, - Groq: providerConfigV0{APIKey: "groq-key"}, - Zhipu: providerConfigV0{APIKey: "zhipu-key"}, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Providers: providersConfigV0{}, - } - - result := v0ConvertProvidersToModelList(cfg) - - if len(result) != 0 { - t.Errorf("len(result) = %d, want 0", len(result)) - } -} - -func TestConvertProvidersToModelList_Nil(t *testing.T) { - result := v0ConvertProvidersToModelList(nil) - - if result != nil { - t.Errorf("result = %v, want nil", result) - } -} - -func TestConvertProvidersToModelList_AllProviders(t *testing.T) { - // This test verifies that when providers have at least one configured field, - // they are converted. GitHubCopilot has ConnectMode set, Antigravity has AuthMethod. - // Other providers have no configuration, so they won't be converted. - cfg := &configV0{ - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}}, - LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, - Anthropic: providerConfigV0{APIKey: "key2"}, - OpenRouter: providerConfigV0{APIKey: "key3"}, - Groq: providerConfigV0{APIKey: "key4"}, - Zhipu: providerConfigV0{APIKey: "key5"}, - VLLM: providerConfigV0{APIKey: "key6"}, - Gemini: providerConfigV0{APIKey: "key7"}, - Nvidia: providerConfigV0{APIKey: "key8"}, - Ollama: providerConfigV0{APIKey: "key9"}, - Moonshot: providerConfigV0{APIKey: "key10"}, - ShengSuanYun: providerConfigV0{APIKey: "key11"}, - DeepSeek: providerConfigV0{APIKey: "key12"}, - Cerebras: providerConfigV0{APIKey: "key13"}, - Vivgrid: providerConfigV0{APIKey: "key14"}, - VolcEngine: providerConfigV0{APIKey: "key15"}, - GitHubCopilot: providerConfigV0{ConnectMode: "grpc"}, - Antigravity: providerConfigV0{AuthMethod: "oauth"}, - Qwen: providerConfigV0{APIKey: "key17"}, - Mistral: providerConfigV0{APIKey: "key18"}, - Avian: providerConfigV0{APIKey: "key19"}, - LongCat: providerConfigV0{APIKey: "key-longcat"}, - ModelScope: providerConfigV0{APIKey: "key-modelscope"}, - }, - } - - result := v0ConvertProvidersToModelList(cfg) - - // All 23 providers should be converted - if len(result) != 23 { - t.Errorf("len(result) = %d, want 23", len(result)) - } -} - -func TestConvertProvidersToModelList_Proxy(t *testing.T) { - cfg := &configV0{ - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{ - providerConfigV0: providerConfigV0{ - APIKey: "key", - Proxy: "http://proxy:8080", - }, - }, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Providers: providersConfigV0{ - Ollama: providerConfigV0{ - APIBase: "http://localhost:11434", - RequestTimeout: 300, - }, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{ - providerConfigV0: providerConfigV0{ - AuthMethod: "oauth", - }, - }, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "deepseek", - Model: "deepseek-reasoner", - }, - }, - Providers: providersConfigV0{ - DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "openai", - Model: "gpt-4-turbo", - }, - }, - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}}, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "claude", // alternative name - Model: "claude-opus-4-20250514", - }, - }, - Providers: providersConfigV0{ - Anthropic: providerConfigV0{APIKey: "sk-ant"}, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "qwen", - Model: "qwen-plus", - }, - }, - Providers: providersConfigV0{ - Qwen: providerConfigV0{APIKey: "sk-qwen"}, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "deepseek", - Model: "", // no model specified - }, - }, - Providers: providersConfigV0{ - DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "deepseek", - Model: "deepseek-reasoner", - }, - }, - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}}, - DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, - }, - } - - result := v0ConvertProvidersToModelList(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.4" { - t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4") - } - 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 providerConfigV0 - }{ - {"gpt", "openai/gpt-4-custom", providerConfigV0{APIKey: "key"}}, - {"claude", "anthropic/claude-custom", providerConfigV0{APIKey: "key"}}, - {"doubao", "volcengine/doubao-custom", providerConfigV0{APIKey: "key"}}, - {"tongyi", "qwen/qwen-custom", providerConfigV0{APIKey: "key"}}, - {"kimi", "moonshot/kimi-custom", providerConfigV0{APIKey: "key"}}, - } - - for _, tt := range tests { - t.Run(tt.providerAlias, func(t *testing.T) { - cfg := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: tt.providerAlias, - Model: strings.TrimPrefix( - tt.expectedModel, - tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], - ), - }, - }, - Providers: providersConfigV0{}, - } - - // Set the appropriate provider config - switch tt.providerAlias { - case "gpt": - cfg.Providers.OpenAI = openAIProviderConfigV0{providerConfigV0: 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 := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "", // Not set - Model: "glm-4.7", - }, - }, - Providers: providersConfigV0{ - Zhipu: providerConfigV0{ - APIKey: "test-zhipu-key", - }, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "", // Not set - Model: "some-model", - }, - }, - Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, - Zhipu: providerConfigV0{APIKey: "zhipu-key"}, - }, - } - - result := v0ConvertProvidersToModelList(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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - Provider: "", - Model: "", - }, - }, - Providers: providersConfigV0{ - Zhipu: providerConfigV0{APIKey: "zhipu-key"}, - }, - } - - result := v0ConvertProvidersToModelList(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.4") @@ -586,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 := &configV0{ - Agents: agentsConfigV0{ - Defaults: agentDefaultsV0{ - 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: providersConfigV0{ - OpenRouter: providerConfigV0{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 := v0ConvertProvidersToModelList(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 6e88f4783..d22eb290f 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -144,42 +144,6 @@ func TestGetModelConfig_Concurrent(t *testing.T) { } } -func TestAgentDefaultsV0_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 agentDefaultsV0 - 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 TestModelConfig_Validate(t *testing.T) { tests := []struct { name string @@ -194,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{ @@ -213,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 { diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index e58c6dc9e..073cb7826 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -187,14 +187,16 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) { func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { modelCfg := &ModelConfig{ - ModelName: "gpt-4", - Model: "openai/gpt-4o", - APIBase: "https://api.example.com", - Proxy: "http://proxy:8080", - RPM: 60, - MaxTokensField: "max_completion_tokens", - RequestTimeout: 30, - ThinkingLevel: "high", + 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} @@ -206,6 +208,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { 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) } @@ -221,15 +226,24 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { 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) { @@ -345,7 +359,7 @@ func TestMergeAPIKeys(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := MergeAPIKeys(tt.apiKey, tt.apiKeys) + result := mergeAPIKeys(tt.apiKey, tt.apiKeys) if len(result) != len(tt.expected) { t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result)) } diff --git a/pkg/config/security.go b/pkg/config/security.go index 79dd26e14..9f0d1339c 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -7,20 +7,16 @@ package config import ( "bytes" - "encoding/json" "fmt" "os" "path/filepath" "reflect" - "runtime" "strings" "sync" "gopkg.in/yaml.v3" - "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" - "github.com/sipeed/picoclaw/pkg/logger" ) const ( @@ -34,11 +30,12 @@ func securityPath(configPath string) string { } // loadSecurityConfig loads the security configuration from security.yml -// Returns an empty SecurityConfig if the file doesn't exist +// 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) { @@ -47,10 +44,149 @@ func loadSecurityConfig(cfg *Config, securityPath string) error { return fmt.Errorf("failed to read security config: %w", err) } - if err := yaml.Unmarshal(data, cfg); err != nil { + // 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 } @@ -66,7 +202,6 @@ func saveSecurityConfig(securityPath string, sec *Config) error { return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600) } -// SensitiveDataCache caches the compiled regex for filtering sensitive data. // SensitiveDataCache caches the strings.Replacer for filtering sensitive data. // Computed once on first access via sync.Once. type SensitiveDataCache struct { @@ -126,9 +261,25 @@ func collectSensitive(v reflect.Value, values *[]string) { 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{}) { - result := v.Addr().MethodByName("String").Call(nil) + // 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) @@ -178,234 +329,3 @@ func collectSensitive(v reflect.Value, values *[]string) { } } } - -const ( - notHere = `"[NOT_HERE]"` -) - -// SecureStrings is a slice of SecureString -type SecureStrings []*SecureString - -// 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 - } - *s = v - 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 true - } - } - return false -} - -// 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 -} diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 6ca8637f4..8fc2f167c 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -43,17 +43,16 @@ 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 (not ref: references) - // These values should take precedence over .security.yml + // Create config.json with direct security values using the current schema. configPath := filepath.Join(tmpDir, "config.json") configContent := `{ - "version": 1, + "version": 2, "model_list": [ { "model_name": "test-model", "model": "openai/test-model", "api_base": "https://api.openai.com/v1", - "api_key": "sk-from-config-json-direct" + "api_keys": ["sk-from-config-json-direct"] } ], "channels": { @@ -108,7 +107,13 @@ skills: assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKey()) // Verify channel token from config.json takes precedence - assert.Equal(t, "token-from-security-yml", cfg.Channels.Telegram.Token.String()) + 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()) @@ -332,8 +337,9 @@ web: skills: github: token: "file://github_token.txt" - clawhub: - auth_token: "file://clawhub_auth_token.txt" + registries: + clawhub: + auth_token: "file://clawhub_auth_token.txt" ` err = os.WriteFile(securityPath, []byte(securityContent), 0o600) require.NoError(t, err) @@ -350,68 +356,95 @@ skills: 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 - assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token.String()) - t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String()) + tgSec := decodeChannel("telegram") + assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", secureStr(tgSec.(*TelegramSettings).Token)) + t.Logf("Telegram Token(): %s", secureStr(tgSec.(*TelegramSettings).Token)) // Feishu - assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String()) - assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey.String()) - assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken.String()) - t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String()) - t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String()) - t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String()) + 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 - assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token.String()) - t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token.String()) + 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 - assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret.String()) - t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret.String()) + dtSec := decodeChannel("dingtalk") + assert.Equal(t, "dingtalk_test_client_secret", secureStr(dtSec.(*DingTalkSettings).ClientSecret)) + t.Logf("DingTalk ClientSecret(): %s", secureStr(dtSec.(*DingTalkSettings).ClientSecret)) // Slack - assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken.String()) - assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken.String()) - t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken.String()) - t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken.String()) + 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 - assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken.String()) - t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken.String()) + matSec := decodeChannel("matrix") + assert.Equal(t, "matrix_test_access_token", secureStr(matSec.(*MatrixSettings).AccessToken)) + t.Logf("Matrix AccessToken(): %s", secureStr(matSec.(*MatrixSettings).AccessToken)) // LINE - assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String()) - assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken.String()) - t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String()) - t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.String()) + 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 - assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken.String()) - t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken.String()) + obSec := decodeChannel("onebot") + assert.Equal(t, "onebot_test_access_token", secureStr(obSec.(*OneBotSettings).AccessToken)) + t.Logf("OneBot AccessToken(): %s", secureStr(obSec.(*OneBotSettings).AccessToken)) // WeCom - assert.Equal(t, "test_wecom_bot_id", cfg.Channels.WeCom.BotID) - assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret.String()) - t.Logf("WeCom BotID: %s", cfg.Channels.WeCom.BotID) - t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret.String()) + 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 - assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token.String()) - t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token.String()) + picoSec := decodeChannel("pico") + assert.Equal(t, "pico_test_token", secureStr(picoSec.(*PicoSettings).Token)) + t.Logf("Pico Token(): %s", secureStr(picoSec.(*PicoSettings).Token)) // IRC - assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password.String()) - assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword.String()) - assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword.String()) - t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password.String()) - t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword.String()) - t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword.String()) + 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 - assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret.String()) - t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret.String()) + 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()) @@ -431,9 +464,172 @@ skills: 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()) - assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String()) - t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken.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 index 834ba3606..23daf3231 100644 --- a/pkg/config/security_test.go +++ b/pkg/config/security_test.go @@ -15,13 +15,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" - - "github.com/sipeed/picoclaw/pkg/credential" ) func TestSecurityConfig(t *testing.T) { t.Run("LoadNonExistent", func(t *testing.T) { - sec := &Config{} + sec := &Config{Channels: make(ChannelsConfig)} err := loadSecurityConfig(sec, "/nonexistent/.security.yml") require.NoError(t, err) assert.NotNil(t, sec) @@ -77,6 +75,7 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) { secPath := filepath.Join(tmpDir, SecurityConfigFile) original := &Config{ + Version: CurrentVersion, ModelList: SecureModelList{ { ModelName: "model1", @@ -105,29 +104,38 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) { }, }, }, - Channels: ChannelsConfig{ - Telegram: TelegramConfig{ - Enabled: true, - Token: *NewSecureString("telegram_token"), - }, - Feishu: FeishuConfig{ - Enabled: true, - AppID: "feishu_app_id", - AppSecret: *NewSecureString("feishu_app_secret"), - }, - Discord: DiscordConfig{ - Enabled: true, - Token: *NewSecureString("discord_token"), - }, - QQ: QQConfig{ - Enabled: true, - AppSecret: *NewSecureString("qq_app_secret"), - }, - PicoClient: PicoClientConfig{ - Enabled: true, - Token: *NewSecureString("pico_client_token"), - }, - }, + 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) { @@ -140,8 +148,8 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) { marshal, err := json.Marshal(original) require.NoError(t, err) t.Logf("json: %s", string(marshal)) - assert.Contains(t, string(marshal), "\"api_keys\"") - assert.Contains(t, string(marshal), notHere) + assert.NotContains(t, string(marshal), "\"api_keys\"") + assert.NotContains(t, string(marshal), notHere) err = json.Unmarshal(marshal, cfg2) require.NoError(t, err) @@ -163,7 +171,24 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) { file, err := os.ReadFile(secPath) assert.NoError(t, err) t.Logf("%s", string(file)) - yamlOutput := `channels: + + // 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: @@ -190,8 +215,6 @@ skills: github: token: github_token ` - assert.Equal(t, yamlOutput, string(file)) - err = os.WriteFile(secPath, []byte(yamlOutput), 0o600) require.NoError(t, err) }) @@ -218,143 +241,32 @@ skills: 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") - err2 := env.Parse(cfg2) - require.NoError(t, err2) - assert.Equal(t, "qq_app_secret_env", cfg2.Channels.QQ.AppSecret.raw) - assert.Equal(t, "brave_key_env", cfg2.Tools.Web.Brave.APIKeys[0].raw) - assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw) + + 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) }) } - -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()) -} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index c1a224013..6a8728943 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -25,10 +25,8 @@ type CronSchedule struct { type CronPayload struct { Kind string `json:"kind"` - Type string `json:"type"` Message string `json:"message"` Command string `json:"command,omitempty"` - Deliver bool `json:"deliver"` Channel string `json:"channel,omitempty"` To string `json:"to,omitempty"` } @@ -410,7 +408,6 @@ func (cs *CronService) AddJob( name string, schedule CronSchedule, message string, - deliver bool, channel, to string, ) (*CronJob, error) { cs.mu.Lock() @@ -429,7 +426,6 @@ func (cs *CronService) AddJob( Payload: CronPayload{ Kind: "agent_turn", Message: message, - Deliver: deliver, Channel: channel, To: to, }, diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go index c55e62174..6dff3b387 100644 --- a/pkg/cron/service_test.go +++ b/pkg/cron/service_test.go @@ -20,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) } @@ -52,7 +52,7 @@ func TestCronService_CRUD(t *testing.T) { // Test AddJob at := time.Now().Add(time.Hour).UnixMilli() - job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to") + job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to") if err != nil || job.ID == "" { t.Fatalf("AddJob failed: %v", err) } @@ -134,7 +134,7 @@ func TestCronService_ExecutionFlow(t *testing.T) { // 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}, "", false, "", "") + job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", "", "") // Check for job execution with a timeout success := false @@ -167,7 +167,7 @@ func TestCronService_PersistenceIntegrity(t *testing.T) { // write a job and persist cs1 := NewCronService(tmpFile, nil) at := int64(2000000000000) - cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "") + cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", "ch1", "") // check file exists if _, err := os.Stat(tmpFile); os.IsNotExist(err) { @@ -213,7 +213,7 @@ func TestCronService_ConcurrentAccess(t *testing.T) { 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}, "", false, "", "") + cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", "", "") time.Sleep(100 * time.Microsecond) } }(i) 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/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/gateway/channel_matrix.go b/pkg/gateway/channel_matrix.go index a46addae1..b6adbe498 100644 --- a/pkg/gateway/channel_matrix.go +++ b/pkg/gateway/channel_matrix.go @@ -1,4 +1,4 @@ -//go:build !mipsle && !netbsd && !(freebsd && arm) +//go:build !mipsle && !netbsd && !(freebsd && arm) && !android package gateway 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 index c35b3e744..4fd06d836 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -3,15 +3,20 @@ 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" @@ -24,7 +29,9 @@ import ( _ "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" @@ -32,14 +39,16 @@ import ( "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" - "github.com/sipeed/picoclaw/pkg/voice" ) const ( @@ -59,14 +68,37 @@ type services struct { 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, @@ -82,7 +114,8 @@ func (p *startupBlockedProvider) GetDefaultModel() string { } // Run starts the gateway runtime using the configuration loaded from configPath. -func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error { +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 { @@ -91,22 +124,70 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error defer panicFunc() if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil { - panic(fmt.Sprintf("error enabling file logging: %v", err)) + 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) } - logger.SetLevelFromString(cfg.Gateway.LogLevel) - - if debug { - logger.SetLevel(logger.DEBUG) - fmt.Println("🔍 Debug mode enabled") + 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) @@ -118,6 +199,8 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error 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() @@ -133,10 +216,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error "skills_available": skillsInfo["available"], }) - runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus) + 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) @@ -157,7 +242,9 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error runningServices.HealthServer.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger) - fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + 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()) @@ -180,14 +267,14 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error select { case <-sigChan: logger.Info("Shutting down...") - shutdownGateway(runningServices, agentLoop, provider, true) + 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) + err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug) if err != nil { logger.Errorf("Config reload failed: %v", err) } @@ -204,7 +291,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error runningServices.reloading.Store(false) continue } - err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) + err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug) if err != nil { logger.Errorf("Manual reload failed: %v", err) } else { @@ -214,6 +301,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error } } +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, @@ -222,9 +316,21 @@ func executeReload( runningServices *services, msgBus *bus.MessageBus, allowEmptyStartup bool, -) error { + debug bool, +) (err error) { + startedAt := time.Now() + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReloadStarted, startedAt, nil) defer runningServices.reloading.Store(false) - return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup) + 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( @@ -248,6 +354,8 @@ func setupAndStartServices( cfg *config.Config, agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, + authToken string, + listenResult netbind.OpenResult, ) (*services, error) { runningServices := &services{} @@ -290,7 +398,12 @@ func setupAndStartServices( fms.Start() } - runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) + 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() @@ -301,11 +414,14 @@ func setupAndStartServices( agentLoop.SetChannelManager(runningServices.ChannelManager) agentLoop.SetMediaStore(runningServices.MediaStore) - if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { + 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) @@ -313,18 +429,39 @@ func setupAndStartServices( fmt.Println("⚠ Warning: No channels enabled") } - addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) - runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) + 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:%d/health, /ready and /reload (POST)\n", - cfg.Gateway.Host, - cfg.Gateway.Port, + "✓ Health endpoints available at http://%s/health, /ready and /reload (POST)\n", + healthAddr, ) stateManager := state.NewManager(cfg.WorkspacePath()) @@ -350,6 +487,9 @@ func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Dura if !isReload && runningServices.ChannelManager != nil { runningServices.ChannelManager.StopAll(shutdownCtx) } + if runningServices.VoiceAgentCancel != nil { + runningServices.VoiceAgentCancel() + } if runningServices.DeviceService != nil { runningServices.DeviceService.Stop() } @@ -370,14 +510,21 @@ 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() @@ -392,6 +539,7 @@ func handleConfigReload( runningServices *services, msgBus *bus.MessageBus, allowEmptyStartup bool, + debug bool, ) error { logger.Info("🔄 Config file changed, reloading...") @@ -440,6 +588,15 @@ func handleConfigReload( } 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 } @@ -516,14 +673,25 @@ func restartServices( fmt.Println(" ✓ Device event service restarted") } - transcriber := voice.DetectTranscriber(cfg) + 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 } 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 387cb0756..22346490c 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -2,11 +2,13 @@ package health import ( "context" + "crypto/subtle" "encoding/json" - "fmt" "maps" + "net" "net/http" "os" + "strconv" "sync" "time" ) @@ -18,6 +20,7 @@ type Server struct { checks map[string]Check startTime time.Time reloadFunc func() error + authToken string // optional bearer token for protected endpoints } type Check struct { @@ -30,23 +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"` - Pid int `json:"pid"` } -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, @@ -123,6 +127,21 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) { 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() @@ -154,7 +173,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { resp := StatusResponse{ Status: "ok", Uptime: uptime.String(), - Pid: os.Getpid(), + PID: os.Getpid(), } json.NewEncoder(w).Encode(resp) @@ -220,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 index 6e0b5e66b..31dbc37c0 100644 --- a/pkg/health/server_test.go +++ b/pkg/health/server_test.go @@ -15,6 +15,7 @@ func newTestServer() *Server { ready: false, checks: make(map[string]Check), startTime: time.Now(), + authToken: "test", } return s } @@ -37,9 +38,6 @@ func TestHealthHandler_ReturnsOK(t *testing.T) { if resp.Status != "ok" { t.Errorf("status = %q, want %q", resp.Status, "ok") } - if resp.Pid == 0 { - t.Error("pid should not be 0") - } if resp.Uptime == "" { t.Error("uptime should not be empty") } @@ -168,6 +166,7 @@ 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) @@ -186,6 +185,7 @@ func TestReloadHandler_Success(t *testing.T) { }) req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") w := httptest.NewRecorder() s.reloadHandler(w, req) @@ -205,6 +205,7 @@ func TestReloadHandler_Error(t *testing.T) { }) req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") w := httptest.NewRecorder() s.reloadHandler(w, req) @@ -292,7 +293,7 @@ func TestRegisterOnMux(t *testing.T) { } func TestNewServer(t *testing.T) { - s := NewServer("127.0.0.1", 0) + s := NewServer("127.0.0.1", 0, "") if s == nil { t.Fatal("NewServer returned nil") } @@ -304,8 +305,18 @@ func TestNewServer(t *testing.T) { } } +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) + s := NewServer("127.0.0.1", 0, "") ctx, cancel := context.WithCancel(context.Background()) diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 5dda78ea9..e5b28ec11 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -339,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/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 1244addb2..6d2e31791 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/rs/zerolog" + "golang.org/x/term" ) type LogLevel = zerolog.Level @@ -21,6 +22,8 @@ const ( WARN = zerolog.WarnLevel ERROR = zerolog.ErrorLevel FATAL = zerolog.FatalLevel + + Component = "component" ) var ( @@ -32,28 +35,47 @@ var ( FATAL: "FATAL", } - currentLevel = INFO - logger zerolog.Logger - fileLogger zerolog.Logger - logFile *os.File - 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 ) func init() { once.Do(func() { zerolog.SetGlobalLevel(zerolog.InfoLevel) - consoleWriter := zerolog.ConsoleWriter{ + 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, } - logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() - fileLogger = zerolog.Logger{} + writers = append(writers, consoleWriter) + + logger = zerolog.New(io.MultiWriter(writers...)).With().Timestamp().Caller().Logger() }) } @@ -104,7 +126,15 @@ func SetConsoleLevel(level LogLevel) { func DisableConsole() { mu.Lock() defer mu.Unlock() - logger = zerolog.New(io.Discard).With().Timestamp().Caller().Logger() + 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 { @@ -162,7 +192,14 @@ func EnableFileLogging(filePath string) error { } logFile = newFile - fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger() + + 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 } @@ -174,7 +211,10 @@ func DisableFileLogging() { logFile.Close() logFile = nil } - fileLogger = zerolog.Logger{} + if len(writers) > 1 { + writers = writers[:1] + logger = logger.Output(io.MultiWriter(writers...)) + } } func ConfigureFromEnv() { @@ -193,7 +233,28 @@ func ConfigureFromEnv() { } } -func getCallerSkip() int { +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 { @@ -217,10 +278,10 @@ func getCallerSkip() int { continue } - return i - 1 + return i - 1, getPackageNameFromFile(file) } - return 3 + return 3, locUnknown } //nolint:zerologlint @@ -246,33 +307,19 @@ func logMessage(level LogLevel, component string, message string, fields map[str return } - skip := getCallerSkip() + skip, pkg := getCallerSkip() event := getEvent(logger, level) - if component != "" { - event.Str("component", component) + if component == "" { + component = pkg } + event.Str(Component, component) + appendFields(event, fields) + event.CallerSkipFrame(skip).Msg(message) - - // Also log to file if enabled - if fileLogger.GetLevel() != zerolog.NoLevel { - fileEvent := getEvent(fileLogger, level) - - if component != "" { - fileEvent.Str("component", component) - } - // fileEvent.Str("caller", fmt.Sprintf("%s:%d (%s)", callerFile, callerLine, callerFunc)) - - appendFields(fileEvent, fields) - fileEvent.CallerSkipFrame(skip).Msg(message) - } - - if level == FATAL { - os.Exit(1) - } } func appendFields(event *zerolog.Event, fields map[string]any) { diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 1eca72607..7a7712de0 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -406,3 +406,28 @@ 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 index e53e4351a..0a9125dda 100644 --- a/pkg/logger/panic.go +++ b/pkg/logger/panic.go @@ -2,12 +2,15 @@ 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) @@ -16,21 +19,36 @@ func InitPanic(filePath string) (func(), error) { if writer == nil { return nil, fmt.Errorf("failed to create log file: %s", filePath) } + if panicWriter != nil { + _ = panicWriter.Close() + } + panicWriter = writer return func() { - defer writer.Close() + defer func() { + writer.Close() + panicWriter = nil + }() if err := recover(); err != nil { - 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, - ) - - writer.Write([]byte(logMsg)) + 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/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 323df0312..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,14 +351,14 @@ 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. @@ -316,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{ @@ -324,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 @@ -341,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 @@ -365,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, ) @@ -377,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 @@ -391,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 @@ -481,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/memory/jsonl.go b/pkg/memory/jsonl.go index afe374166..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. @@ -98,25 +105,31 @@ func sanitizeKey(key string) string { // 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/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 65a87adc4..f1179c3a9 100644 --- a/pkg/migrate/internal/common.go +++ b/pkg/migrate/internal/common.go @@ -1,12 +1,10 @@ package internal import ( - "fmt" "io" "os" "path/filepath" - "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" ) @@ -14,14 +12,7 @@ func ResolveTargetHome(override string) (string, error) { if override != "" { return ExpandHome(override), nil } - if envHome := os.Getenv(config.EnvHome); envHome != "" { - return ExpandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, pkg.DefaultPicoClawHome), nil + return config.GetHome(), nil } func ExpandHome(path string) string { diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 4436c1861..4b8fec229 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -1018,113 +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: func() config.TelegramConfig { - tc := config.TelegramConfig{ - Enabled: c.Telegram.Enabled, - Proxy: c.Telegram.Proxy, - } - if c.Telegram.Token != "" { - tc.Token = *config.NewSecureString(c.Telegram.Token) - } - return tc - }(), - Feishu: func() config.FeishuConfig { - fc := config.FeishuConfig{ - Enabled: c.Feishu.Enabled, - AppID: c.Feishu.AppID, - } - if c.Feishu.AppSecret != "" { - fc.AppSecret = *config.NewSecureString(c.Feishu.AppSecret) - } - if c.Feishu.EncryptKey != "" { - fc.EncryptKey = *config.NewSecureString(c.Feishu.EncryptKey) - } - if c.Feishu.VerificationToken != "" { - fc.VerificationToken = *config.NewSecureString(c.Feishu.VerificationToken) - } - return fc - }(), - Discord: func() config.DiscordConfig { - dc := config.DiscordConfig{ - Enabled: c.Discord.Enabled, - MentionOnly: c.Discord.MentionOnly, - } - if c.Discord.Token != "" { - dc.Token = *config.NewSecureString(c.Discord.Token) - } - return dc - }(), - MaixCam: config.MaixCamConfig{ - Enabled: c.MaixCam.Enabled, - Host: c.MaixCam.Host, - Port: c.MaixCam.Port, - }, - QQ: func() config.QQConfig { - qc := config.QQConfig{ - Enabled: c.QQ.Enabled, - AppID: c.QQ.AppID, - } - if c.QQ.AppSecret != "" { - qc.AppSecret = *config.NewSecureString(c.QQ.AppSecret) - } - return qc - }(), - DingTalk: func() config.DingTalkConfig { - dt := config.DingTalkConfig{ - Enabled: c.DingTalk.Enabled, - ClientID: c.DingTalk.ClientID, - } - if c.DingTalk.ClientSecret != "" { - dt.ClientSecret = *config.NewSecureString(c.DingTalk.ClientSecret) - } - return dt - }(), - Slack: func() config.SlackConfig { - sc := config.SlackConfig{ - Enabled: c.Slack.Enabled, - } - if c.Slack.BotToken != "" { - sc.BotToken = *config.NewSecureString(c.Slack.BotToken) - } - if c.Slack.AppToken != "" { - sc.AppToken = *config.NewSecureString(c.Slack.AppToken) - } - return sc - }(), - Matrix: func() config.MatrixConfig { - mc := config.MatrixConfig{ - Enabled: c.Matrix.Enabled, - Homeserver: c.Matrix.Homeserver, - UserID: c.Matrix.UserID, - AllowFrom: c.Matrix.AllowFrom, - JoinOnInvite: true, - } - if c.Matrix.AccessToken != "" { - mc.AccessToken = *config.NewSecureString(c.Matrix.AccessToken) - } - return mc - }(), - LINE: func() config.LINEConfig { - lc := config.LINEConfig{ - Enabled: c.LINE.Enabled, - WebhookHost: c.LINE.WebhookHost, - WebhookPort: c.LINE.WebhookPort, - WebhookPath: c.LINE.WebhookPath, - } - if c.LINE.ChannelSecret != "" { - lc.ChannelSecret = *config.NewSecureString(c.LINE.ChannelSecret) - } - if c.LINE.ChannelAccessToken != "" { - lc.ChannelAccessToken = *config.NewSecureString(c.LINE.ChannelAccessToken) - } - return lc - }(), + 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 { diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 7fe112223..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) { @@ -708,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.String() != "test-token" { - t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token.String()) + 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/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 d4ceaab2c..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), @@ -385,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 index 6a1c473dd..672fb9324 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -41,24 +42,26 @@ type Provider struct { apiKey string apiBase string httpClient *http.Client + userAgent string } // NewProvider creates a new Anthropic Messages API provider. -func NewProvider(apiKey, apiBase string) *Provider { - return NewProviderWithTimeout(apiKey, apiBase, 0) +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 string, timeoutSeconds int) *Provider { - baseURL := normalizeBaseURL(apiBase) +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, + apiKey: apiKey, + apiBase: baseURL, + userAgent: userAgent, httpClient: &http.Client{ Timeout: timeout, }, @@ -105,6 +108,9 @@ func (p *Provider) Chat( 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) @@ -156,7 +162,7 @@ func buildRequestBody( options map[string]any, ) (map[string]any, error) { // max_tokens is required and guaranteed by agent loop - maxTokens, ok := asInt(options["max_tokens"]) + maxTokens, ok := common.AsInt(options["max_tokens"]) if !ok { return nil, fmt.Errorf("max_tokens is required in options") } @@ -168,7 +174,7 @@ func buildRequestBody( } // Set temperature from options - if temp, ok := asFloat(options["temperature"]); ok { + if temp, ok := common.AsFloat(options["temperature"]); ok { result["temperature"] = temp } @@ -356,61 +362,6 @@ func parseResponseBody(body []byte) (*LLMResponse, error) { }, nil } -// normalizeBaseURL ensures the base URL is properly formatted. -// It removes /v1 suffix if present (to avoid duplication) and always appends /v1. -// This handles edge cases like "https://api.example.com/v1/proxy" correctly. -func normalizeBaseURL(apiBase string) string { - base := strings.TrimSpace(apiBase) - if base == "" { - return defaultBaseURL - } - - // Remove trailing slashes - base = strings.TrimRight(base, "/") - - // Remove /v1 suffix if present (will be re-added) - // This prevents duplication for URLs like "https://api.example.com/v1/proxy" - if before, ok := strings.CutSuffix(base, "/v1"); ok { - base = before - } - - // Ensure we don't have an empty string after cutting - if base == "" { - return defaultBaseURL - } - - // Add /v1 suffix (required by Anthropic Messages API) - return base + "/v1" -} - -// Helper functions for type conversion - -func asInt(v any) (int, bool) { - switch val := v.(type) { - case int: - return val, true - case float64: - return int(val), true - case int64: - return int(val), true - default: - return 0, false - } -} - -func asFloat(v any) (float64, bool) { - switch val := v.(type) { - case float64: - return val, true - case int: - return float64(val), true - case int64: - return float64(val), true - default: - return 0, false - } -} - // Anthropic API response structures type anthropicMessageResponse struct { diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index 39bc48117..6401d84bd 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -372,46 +372,8 @@ func TestParseResponseBody(t *testing.T) { } } -func TestNormalizeBaseURL(t *testing.T) { - tests := []struct { - name string - apiBase string - expected string - }{ - { - name: "empty string defaults to official API", - apiBase: "", - expected: "https://api.anthropic.com/v1", - }, - { - name: "URL without /v1 gets it appended", - apiBase: "https://api.example.com/anthropic", - expected: "https://api.example.com/anthropic/v1", - }, - { - name: "URL with /v1 remains unchanged", - apiBase: "https://api.example.com/v1", - expected: "https://api.example.com/v1", - }, - { - name: "URL with trailing slash gets cleaned", - apiBase: "https://api.example.com/anthropic/", - expected: "https://api.example.com/anthropic/v1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeBaseURL(tt.apiBase) - if got != tt.expected { - t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.apiBase, got, tt.expected) - } - }) - } -} - func TestNewProvider(t *testing.T) { - provider := NewProvider("test-key", "https://api.example.com") + provider := NewProvider("test-key", "https://api.example.com", "") if provider == nil { t.Fatal("NewProvider() returned nil") } @@ -424,7 +386,7 @@ func TestNewProvider(t *testing.T) { } func TestGetDefaultModel(t *testing.T) { - provider := NewProvider("test-key", "") + provider := NewProvider("test-key", "", "") got := provider.GetDefaultModel() expected := "claude-sonnet-4.6" if got != expected { @@ -743,7 +705,7 @@ func TestProviderChatErrors(t *testing.T) { 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") + provider := NewProvider(tt.apiKey, "https://api.example.com", "") _, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil) if err == nil { 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 index 9d29a90cd..7de703248 100644 --- a/pkg/providers/azure/provider.go +++ b/pkg/providers/azure/provider.go @@ -26,6 +26,7 @@ type ( const ( defaultRequestTimeout = common.DefaultRequestTimeout + responsesAPIPath = "openai/v1/responses" ) // Provider implements the LLM provider interface for Azure OpenAI endpoints. @@ -35,6 +36,7 @@ type Provider struct { apiKey string apiBase string httpClient *http.Client + userAgent string } // Option configures the Azure Provider. @@ -49,11 +51,19 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +// 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 string, opts ...Option) *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), } @@ -67,9 +77,9 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { } // NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds. -func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider { +func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider { return NewProvider( - apiKey, apiBase, proxy, + apiKey, apiBase, proxy, userAgent, WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), ) } @@ -87,7 +97,7 @@ func (p *Provider) Chat( return nil, fmt.Errorf("Azure API base not configured") } - requestURL, err := url.JoinPath(p.apiBase, "openai/v1/responses") + requestURL, err := url.JoinPath(p.apiBase, responsesAPIPath) if err != nil { return nil, fmt.Errorf("failed to build Azure request URL: %w", err) } @@ -140,6 +150,9 @@ func (p *Provider) Chat( 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 { diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go index e57d68057..816ae97dc 100644 --- a/pkg/providers/azure/provider_test.go +++ b/pkg/providers/azure/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -45,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + 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) @@ -68,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) { })) defer server.Close() - p := NewProvider("test-azure-key", server.URL, "") + 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) @@ -91,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + 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) @@ -111,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, @@ -143,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + 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) @@ -160,13 +161,49 @@ func TestProviderChat_AzureHTTPError(t *testing.T) { })) defer server.Close() - p := NewProvider("bad-key", server.URL, "") + 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{ @@ -192,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + 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) @@ -233,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + 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) @@ -250,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) { } func TestProvider_AzureEmptyAPIBase(t *testing.T) { - p := NewProvider("test-key", "", "") + 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") @@ -258,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) { } func TestProvider_AzureRequestTimeoutDefault(t *testing.T) { - p := NewProvider("test-key", "https://example.com", "") + 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)) + 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) + 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) } @@ -306,7 +343,7 @@ func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) { }, } - p := NewProvider("test-key", server.URL, "") + 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", @@ -356,7 +393,7 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) { }, } - p := NewProvider("test-key", server.URL, "") + 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) diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go index 15c4f664e..3798c5fd8 100644 --- a/pkg/providers/bedrock/provider_bedrock.go +++ b/pkg/providers/bedrock/provider_bedrock.go @@ -206,6 +206,13 @@ func (p *Provider) Chat( // 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) } @@ -580,3 +587,30 @@ func parseResponse(output *bedrockruntime.ConverseOutput) (*LLMResponse, error) 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 index 754d112ee..38a5e26da 100644 --- a/pkg/providers/bedrock/provider_bedrock_test.go +++ b/pkg/providers/bedrock/provider_bedrock_test.go @@ -8,6 +8,7 @@ package bedrock import ( + "fmt" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -539,3 +540,68 @@ func TestParseResponse_ToolCallWithNilInput(t *testing.T) { 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/claude_cli_provider.go b/pkg/providers/cli/claude_cli_provider.go similarity index 96% rename from pkg/providers/claude_cli_provider.go rename to pkg/providers/cli/claude_cli_provider.go index 40b581490..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,7 +51,9 @@ func (p *ClaudeCliProvider) Chat( cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { + // 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 { 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 bc9960f0c..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.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 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.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 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 99% rename from pkg/providers/codex_cli_credentials.go rename to pkg/providers/cli/codex_cli_credentials.go index c5b25f040..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" 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 98% rename from pkg/providers/codex_cli_provider_test.go rename to pkg/providers/cli/codex_cli_provider_test.go index 0f66e25f4..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") @@ -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 index 90142fb8b..5e03bc0c2 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -70,11 +70,23 @@ func NewHTTPClient(proxy string) *http.Client { // 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"` + 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. @@ -84,12 +96,13 @@ type openaiMessage struct { 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: m.ToolCalls, + ToolCalls: toolCalls, ToolCallID: m.ToolCallID, }) continue @@ -114,7 +127,7 @@ func SerializeMessages(messages []Message) []any { continue } - if format, data, ok := parseDataAudioURL(mediaURL); ok { + if format, data, ok := ParseDataAudioURL(mediaURL); ok { parts = append(parts, map[string]any{ "type": "input_audio", "input_audio": map[string]any{ @@ -132,8 +145,8 @@ func SerializeMessages(messages []Message) []any { if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent @@ -143,7 +156,57 @@ func SerializeMessages(messages []Message) []any { return out } -func parseDataAudioURL(mediaURL string) (format, data string, ok bool) { +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 } @@ -178,13 +241,15 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ID string `json:"id"` Type string `json:"type"` Function *struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` + 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"` @@ -210,9 +275,11 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { arguments := make(map[string]any) name := "" - // Extract thought_signature from Gemini/Google-specific extra content thoughtSignature := "" - if tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { thoughtSignature = tc.ExtraContent.Google.ThoughtSignature } @@ -228,11 +295,20 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ThoughtSignature: thoughtSignature, } - if thoughtSignature != "" { - toolCall.ExtraContent = &ExtraContent{ - Google: &GoogleExtra{ + 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 } } diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index 79a637d48..3cf2f4285 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -162,6 +162,104 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { } } +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) { @@ -234,6 +332,27 @@ func TestParseResponse_WithReasoningContent(t *testing.T) { } } +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 { @@ -254,6 +373,22 @@ func TestDecodeToolCallArguments_ObjectJSON(t *testing.T) { } } +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") @@ -262,6 +397,22 @@ func TestDecodeToolCallArguments_StringJSON(t *testing.T) { } } +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 { @@ -509,6 +660,37 @@ func TestAsFloat(t *testing.T) { } } +// --- 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) { @@ -594,3 +776,27 @@ func TestParseResponse_WithThoughtSignature(t *testing.T) { 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__". 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 e7691aa93..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"), @@ -134,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{ @@ -170,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 { @@ -204,6 +278,9 @@ func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, timeoutPatterns) { return FailoverTimeout } + if matchesAny(msg, networkPatterns) { + return FailoverNetwork + } if matchesAny(msg, authPatterns) { return FailoverAuth } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 46b180835..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", @@ -286,6 +422,7 @@ func TestFailoverError_IsRetriable(t *testing.T) { {FailoverAuth, true}, {FailoverRateLimit, true}, {FailoverBilling, true}, + {FailoverNetwork, true}, {FailoverTimeout, true}, {FailoverOverloaded, true}, {FailoverFormat, false}, 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_provider.go b/pkg/providers/factory_provider.go index 962e6ae19..aa99d6d38 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -17,6 +17,51 @@ import ( "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") @@ -41,27 +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 protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini), +// 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 model ID (without protocol prefix), and any error. +// 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") @@ -71,17 +141,23 @@ 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 == "" { @@ -91,14 +167,18 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, - ), modelID, nil + cfg.CustomHeaders, + ) + provider.SetProviderName(protocol) + return finalizeProviderFromConfig(provider, modelID, cfg) case "azure", "azure-openai": // Azure OpenAI uses deployment-based URLs, api-key header auth, @@ -111,12 +191,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err "api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)", ) } - return azure.NewProviderWithTimeout( + return finalizeProviderFromConfig(azure.NewProviderWithTimeout( cfg.APIKey(), cfg.APIBase, cfg.Proxy, + userAgent, cfg.RequestTimeout, - ), modelID, nil + ), modelID, cfg) case "bedrock": // AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.) @@ -152,29 +233,51 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if err != nil { return nil, "", fmt.Errorf("creating bedrock provider: %w", err) } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) - case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "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", "mimo": + "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( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, - ), modelID, nil + 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 @@ -192,23 +295,27 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if _, ok := extraBody["reasoning_split"]; !ok { extraBody["reasoning_split"] = true } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, extraBody, - ), modelID, nil + 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 @@ -218,14 +325,18 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, - ), modelID, nil + cfg.CustomHeaders, + ) + provider.SetProviderName(protocol) + return finalizeProviderFromConfig(provider, modelID, cfg) case "anthropic-messages": // Anthropic Messages API with native format (HTTP-based, no SDK) @@ -236,11 +347,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model) } - return anthropicmessages.NewProviderWithTimeout( + return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, + userAgent, cfg.RequestTimeout, - ), modelID, nil + ), modelID, cfg) case "coding-plan-anthropic", "alibaba-coding-anthropic": // Alibaba Coding Plan with Anthropic-compatible API @@ -251,28 +363,29 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) } - return anthropicmessages.NewProviderWithTimeout( + return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, + userAgent, cfg.RequestTimeout, - ), modelID, nil + ), 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 @@ -287,71 +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 "novita": - return "https://api.novita.ai/openai" - 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 "qwen-intl", "qwen-international", "dashscope-intl": - return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" - case "qwen-us", "dashscope-us": - return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" - case "coding-plan", "alibaba-coding", "qwen-coding": - return "https://coding-intl.dashscope.aliyuncs.com/v1" - case "coding-plan-anthropic", "alibaba-coding-anthropic": - return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" - case "vllm": - return "http://localhost:8000/v1" - case "mistral": - return "https://api.mistral.ai/v1" - case "avian": - return "https://api.avian.io/v1" - case "minimax": - return "https://api.minimaxi.com/v1" - case "longcat": - return "https://api.longcat.chat/openai" - case "modelscope": - return "https://api-inference.modelscope.cn/v1" - case "mimo": - return "https://api.xiaomimimo.com/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 f1fe02cc2..eb9b3d600 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -13,74 +13,116 @@ import ( "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", - model: "azure/my-gpt5-deployment", + 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) } }) } @@ -106,12 +148,57 @@ 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"}, @@ -121,6 +208,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, + {"lmstudio", "lmstudio"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, {"mimo", "mimo"}, @@ -153,6 +241,18 @@ 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", @@ -173,6 +273,85 @@ 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", @@ -276,6 +455,28 @@ func TestCreateProviderFromConfig_Mimo(t *testing.T) { } } +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") @@ -319,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", @@ -355,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", @@ -369,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ - ModelName: "test-unknown", - Model: "unknown-protocol/model", + ModelName: "test-unknown-provider", + Provider: "unknown-protocol", + Model: "model", } cfg.SetAPIKey("test-key") @@ -380,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 { @@ -530,8 +843,9 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "qwen-max" { - t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + 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) @@ -564,8 +878,9 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "qwen-max" { - t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + 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) @@ -598,8 +913,9 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "claude-sonnet-4-20250514" { - t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514") + 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 @@ -636,6 +952,71 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } +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 @@ -731,6 +1112,150 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { } } +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") @@ -805,3 +1330,42 @@ func TestCreateProviderFromConfig_BedrockWithEndpointURL(t *testing.T) { // 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/fallback.go b/pkg/providers/fallback.go index 549ec7837..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,9 +130,9 @@ func (fc *FallbackChain) Execute( return nil, context.Canceled } - // Check cooldown (per provider/model, not just provider). - // This allows multi-key failover where different keys use different model names. - cooldownKey := ModelKey(candidate.Provider, candidate.Model) + // 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{ @@ -136,6 +149,33 @@ func (fc *FallbackChain) Execute( 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) @@ -229,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 index 9ed8fa73c..10481ec61 100644 --- a/pkg/providers/fallback_multikey_test.go +++ b/pkg/providers/fallback_multikey_test.go @@ -25,7 +25,7 @@ func TestMultiKeyFailover(t *testing.T) { // Create fallback chain cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: first call fails with 429, second succeeds callCount := 0 @@ -82,7 +82,7 @@ func TestMultiKeyFailoverAllFail(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: all calls fail with rate limit callCount := 0 @@ -127,7 +127,7 @@ func TestMultiKeyFailoverCooldown(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Put the first model in cooldown (using ModelKey now, not just provider) cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model) @@ -183,7 +183,7 @@ func TestMultiKeyFailoverWithFormatError(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: first call fails with format error (bad request) callCount := 0 @@ -263,7 +263,7 @@ func TestMultiKeyWithModelFallback(t *testing.T) { } cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: first two fail, third succeeds (model fallback) callCount := 0 @@ -337,7 +337,7 @@ func TestMultiKeyFailoverMixedErrors(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: different errors for each key callCount := 0 diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 1a1118e33..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,7 +155,7 @@ 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/gpt-4 in cooldown (using ModelKey now) ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) @@ -193,7 +193,7 @@ func TestFallback_CooldownSkip(t *testing.T) { func TestFallback_AllInCooldown(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) // Put all models in cooldown (using ModelKey now) ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) @@ -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,9 +268,78 @@ func TestFallback_UnclassifiedError(t *testing.T) { } } +func assertFallbackErrorFallsBack( + t *testing.T, + primaryProvider string, + primaryModel string, + initialErr error, + successContent string, + expectedReason FailoverReason, +) { + t.Helper() + + 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 { + 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) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} modelKey := ModelKey("openai", "gpt-4") @@ -293,11 +362,78 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) { } } +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")) @@ -311,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"), @@ -335,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"), @@ -359,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"), @@ -384,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 82% rename from pkg/providers/http_provider.go rename to pkg/providers/httpapi/http_provider.go index f2ff52f1d..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,13 +24,14 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil) + 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( @@ -40,6 +41,8 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), openai_compat.WithExtraBody(extraBody), + openai_compat.WithCustomHeaders(customHeaders), + openai_compat.WithUserAgent(userAgent), ), } } @@ -74,3 +77,10 @@ func (p *HTTPProvider) GetDefaultModel() string { 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/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 be9f63bc6..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,8 @@ 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": @@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string { 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 040c511ba..9a164bf48 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -72,7 +72,12 @@ 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"}, @@ -131,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 98% rename from pkg/providers/codex_provider.go rename to pkg/providers/oauth/codex_provider.go index d968215cc..0b125997b 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "context" @@ -240,7 +240,7 @@ func buildCodexParams( return params } -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 99% rename from pkg/providers/codex_provider_test.go rename to pkg/providers/oauth/codex_provider_test.go index ad5748e0c..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" 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 90bc683b8..be3e77a43 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -8,12 +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" ) @@ -33,21 +35,48 @@ 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 = 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) { p.maxTokensField = maxTokensField } } +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 { @@ -62,6 +91,18 @@ func WithExtraBody(extraBody map[string]any) Option { } } +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 { p := &Provider{ apiKey: apiKey, @@ -103,7 +144,7 @@ func (p *Provider) buildRequestBody( requestBody := map[string]any{ "model": model, - "messages": common.SerializeMessages(messages), + "messages": common.SerializeMessages(p.prepareMessagesForRequest(messages)), } // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. @@ -149,13 +190,129 @@ func (p *Provider) buildRequestBody( // Merge extra body fields configured per-provider/model. // These are injected last so they take precedence over defaults. - for k, v := range p.extraBody { - requestBody[k] = v - } + 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, @@ -180,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 { @@ -226,9 +387,13 @@ func (p *Provider) ChatStream( 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. @@ -397,13 +562,11 @@ func normalizeModel(model, apiBase string) string { } prefix := strings.ToLower(before) - switch prefix { - case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", - "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita": + if _, ok := stripModelPrefixProviders[prefix]; ok { return after - default: - return model } + + return model } func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any { @@ -424,7 +587,9 @@ func (p *Provider) SupportsNativeSearch() bool { return isNativeSearchHost(p.apiBase) } -func isNativeSearchHost(apiBase string) bool { +// 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 @@ -433,15 +598,14 @@ func isNativeSearchHost(apiBase string) bool { 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 { - u, err := url.Parse(apiBase) - if err != nil { - return false - } - host := u.Hostname() - return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") + return isNativeOpenAIOrAzureEndpoint(apiBase) } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index ab632ccf3..4f68fb393 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -202,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) { @@ -225,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"}, @@ -238,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"]) @@ -247,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"], + ) } } @@ -432,7 +812,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin } } -func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(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) { @@ -474,6 +854,16 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(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", @@ -579,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") } @@ -694,6 +1090,111 @@ func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) { } } +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) { diff --git a/pkg/providers/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go index 29133a51e..17b731ed4 100644 --- a/pkg/providers/openai_responses_common/responses_common.go +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -118,7 +119,7 @@ func BuildMultipartContent(text string, media []string) responses.ResponseInputM }, }) } else if strings.HasPrefix(mediaURL, "data:audio/") { - if format, data, ok := ParseDataAudioURL(mediaURL); ok { + if format, data, ok := common.ParseDataAudioURL(mediaURL); ok { parts = append(parts, responses.ResponseInputContentUnionParam{ OfInputFile: &responses.ResponseInputFileParam{ FileData: openai.Opt(data), @@ -132,25 +133,6 @@ func BuildMultipartContent(text string, media []string) responses.ResponseInputM return parts } -// 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 -} - // 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) { @@ -268,8 +250,13 @@ func parseResponse(apiResp *responses.Response) *protocoltypes.LLMResponse { if len(toolCalls) > 0 { finishReason = "tool_calls" } - if apiResp.Status == "incomplete" { + switch apiResp.Status { + case responses.ResponseStatusIncomplete: finishReason = "length" + case responses.ResponseStatusFailed: + finishReason = "error" + case responses.ResponseStatusCancelled: + finishReason = "canceled" } var usage *protocoltypes.UsageInfo diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go index be10e8427..ace91edf0 100644 --- a/pkg/providers/openai_responses_common/responses_common_test.go +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -2,9 +2,12 @@ package openai_responses_common import ( "encoding/json" + "fmt" "strings" "testing" + "github.com/openai/openai-go/v3/responses" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -298,10 +301,10 @@ func TestTranslateTools_DescriptionOmittedWhenEmpty(t *testing.T) { // --- ParseResponseBody tests --- func TestParseResponseBody_TextOutput(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_123", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "message", @@ -315,7 +318,7 @@ func TestParseResponseBody_TextOutput(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -333,10 +336,10 @@ func TestParseResponseBody_TextOutput(t *testing.T) { } func TestParseResponseBody_FunctionCall(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_456", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "function_call", @@ -352,7 +355,7 @@ func TestParseResponseBody_FunctionCall(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -373,10 +376,10 @@ func TestParseResponseBody_FunctionCall(t *testing.T) { } func TestParseResponseBody_Reasoning(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_789", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "reasoning", @@ -395,7 +398,7 @@ func TestParseResponseBody_Reasoning(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 10} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -410,10 +413,10 @@ func TestParseResponseBody_Reasoning(t *testing.T) { } func TestParseResponseBody_Refusal(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_ref", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "message", @@ -427,7 +430,7 @@ func TestParseResponseBody_Refusal(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -439,10 +442,10 @@ func TestParseResponseBody_Refusal(t *testing.T) { } func TestParseResponseBody_IncompleteStatus(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_inc", "object": "response", - "status": "incomplete", + "status": "%s", "output": [ { "type": "message", @@ -452,7 +455,7 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { "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 { @@ -464,59 +467,42 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { } func TestParseResponseBody_FailedStatus(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_fail", "object": "response", - "status": "failed", + "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) } - // failed/canceled statuses are not specially mapped; they fall through to "stop" - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") + if result.FinishReason != "error" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "error") } } -// --- ParseDataAudioURL tests --- +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))) -func TestParseDataAudioURL_Valid(t *testing.T) { - format, data, ok := ParseDataAudioURL("data:audio/mp3;base64,SGVsbG8=") - if !ok { - t.Fatal("expected ok=true") + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("error: %v", err) } - if format != "mp3" { - t.Errorf("format = %q, want %q", format, "mp3") - } - if data != "SGVsbG8=" { - t.Errorf("data = %q, want %q", data, "SGVsbG8=") - } -} - -func TestParseDataAudioURL_NotAudio(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:image/png;base64,abc") - if ok { - t.Error("expected ok=false for non-audio URL") - } -} - -func TestParseDataAudioURL_MalformedNoComma(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:audio/mp3;base64") - if ok { - t.Error("expected ok=false for malformed URL") - } -} - -func TestParseDataAudioURL_EmptyData(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:audio/mp3;base64,") - if ok { - t.Error("expected ok=false for empty data") + if result.FinishReason != "canceled" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "canceled") } } 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 f98ae9243..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 { @@ -74,6 +75,7 @@ const ( FailoverAuth FailoverReason = "auth" FailoverRateLimit FailoverReason = "rate_limit" FailoverBilling FailoverReason = "billing" + FailoverNetwork FailoverReason = "network" FailoverTimeout FailoverReason = "timeout" FailoverFormat FailoverReason = "format" FailoverContextOverflow FailoverReason = "context_overflow" 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 fdfc899f9..729e880fe 100644 --- a/pkg/routing/route_test.go +++ b/pkg/routing/route_test.go @@ -3,10 +3,11 @@ 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{ @@ -15,20 +16,20 @@ func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *co }, 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::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::". -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 := "\n" + for _, pid := range parentIDs { + parents += fmt.Sprintf(" \n", pid) + } + parents += " \n" + parentsSection = parents + } + return fmt.Sprintf( + "\n \n %s\n \n%s", + 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, "`, + 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, "") { + 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 section with parent ID + if !contains(xmlContent, "") { + t.Errorf("condensed summary XML missing 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 section + if contains(xmlContent, "") { + t.Errorf("leaf summary XML should not have 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 not parentId + 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 per spec + if !contains(xml, ``) { + t.Errorf("expected , got: %s", xml) + } + if !contains(xml, ``) { + t.Errorf("expected , got: %s", xml) + } + // Must NOT use old tag + if contains(xml, "") { + t.Errorf("should not use 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: ". +- Target length: about %d tokens or less. + + +%s + + + +%s +`, 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: ". +- Target length: about %d tokens or less. + + +%s +`, 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: ". +- Target length: about %d tokens or less. + + +%s + + + +%s +`, 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 ) + for _, msg := range result.Messages { + if strings.Contains(msg.Content, "= 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 index 7f470de15..68ef2d753 100644 --- a/pkg/session/jsonl_backend.go +++ b/pkg/session/jsonl_backend.go @@ -2,7 +2,9 @@ package session import ( "context" + "encoding/json" "log" + "strings" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" @@ -15,24 +17,123 @@ 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) @@ -42,6 +143,7 @@ func (b *JSONLBackend) GetHistory(key string) []providers.Message { } 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) @@ -51,18 +153,21 @@ func (b *JSONLBackend) GetSummary(key string) string { } 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) } @@ -72,6 +177,7 @@ func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { // 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) } @@ -79,3 +185,8 @@ func (b *JSONLBackend) Save(key string) error { 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 index 40fa019cb..0b79ad84d 100644 --- a/pkg/session/jsonl_backend_test.go +++ b/pkg/session/jsonl_backend_test.go @@ -4,8 +4,10 @@ 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" ) @@ -177,3 +179,126 @@ func TestJSONLBackend_SummarizeFlow(t *testing.T) { 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 ef720b7c5..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 { @@ -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,6 +150,16 @@ 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. // Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so // composite IDs (e.g. Telegram forum "chatID/threadID") do not create @@ -186,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{} } @@ -260,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 } @@ -280,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/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 index 1d1a2f967..2ba2a974d 100644 --- a/pkg/session/session_store.go +++ b/pkg/session/session_store.go @@ -27,6 +27,8 @@ type SessionStore interface { 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 bd4bed8fb..677a57f18 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -5,11 +5,13 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "net/url" "os" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -19,6 +21,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 @@ -88,6 +119,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 f6cdee3a6..2f97ca8bf 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "os" @@ -12,6 +13,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -32,110 +34,434 @@ type GitHubRef struct { SubPath string // Path within the repository } +type gitHubTarget struct { + Ref GitHubRef + Endpoints gitHubEndpoints +} + type SkillInstaller struct { - workspace string - client *http.Client - githubToken string - proxy string + 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, - githubToken: githubToken, - proxy: proxy, + 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 GitHubRef{}, fmt.Errorf("invalid URL: %w", err) + return gitHubTarget{}, fmt.Errorf("invalid URL: %w", err) } - parts := strings.Split(strings.Trim(u.Path, "/"), "/") + 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 GitHubRef{}, fmt.Errorf("invalid GitHub URL") + 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: "main", + 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 = parts[i+1] - ref.SubPath = strings.Join(parts[i+2:], "/") + ref.Ref, ref.SubPath = splitGitHubTreeOrBlobRefPath(parts[i+1:], defaultRef) } break } } - return ref, nil + 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 GitHubRef{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo) + return gitHubTarget{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo) } ref := GitHubRef{ Owner: parts[0], RepoName: parts[1], - Ref: "main", + Ref: defaultRef, } if len(parts) > 2 { ref.SubPath = strings.Join(parts[2:], "/") } - return ref, nil + 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 { - ref, err := parseGitHubRef(repo) + skillName, err := githubInstallDirNameWithBaseURL(repo, si.githubBaseURL) if err != nil { return err } - - skillName := ref.RepoName - if ref.SubPath != "" { - skillName = filepath.Base(ref.SubPath) - } skillDirectory := filepath.Join(si.workspace, "skills", skillName) - if _, err := os.Stat(skillDirectory); err == nil { + 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 + } + } // Build GitHub API URL apiPath := path.Join(ref.Owner, ref.RepoName, "contents") - if ref.SubPath != "" { - apiPath = path.Join(apiPath, ref.SubPath) + if apiSubPath != "" { + apiPath = path.Join(apiPath, apiSubPath) } - apiURL := fmt.Sprintf("https://api.github.com/repos/%s?ref=%s", apiPath, ref.Ref) + apiURL := fmt.Sprintf("%s/repos/%s?ref=%s", target.Endpoints.APIBaseURL, apiPath, url.QueryEscape(ref.Ref)) if err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil { // Fallback to raw download - return si.downloadRaw(ctx, ref.Owner, ref.RepoName, ref.Ref, ref.SubPath, skillDirectory) + 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") } - if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil { - return fmt.Errorf("SKILL.md not found in repository") - } - return nil + return &InstallResult{Version: ref.Ref}, nil } // downloadDir recursively downloads a directory from GitHub API @@ -188,12 +514,19 @@ func (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, loca } // downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com -func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, subPath, localDir string) error { +func (si *SkillInstaller) downloadRaw( + ctx context.Context, + rawBaseURL, owner, repo, ref, subPath, localDir string, +) error { urlPath := path.Join(owner, repo, ref) if subPath != "" { - urlPath = path.Join(urlPath, subPath) + if isSkillMarkdownPath(subPath) { + urlPath = strings.TrimSuffix(path.Join(urlPath, subPath), "/SKILL.md") + } else { + urlPath = path.Join(urlPath, subPath) + } } - url := fmt.Sprintf("https://raw.githubusercontent.com/%s/SKILL.md", urlPath) + url := fmt.Sprintf("%s/%s/SKILL.md", strings.TrimRight(rawBaseURL, "/"), urlPath) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -213,12 +546,10 @@ func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, sub localPath := filepath.Join(localDir, "SKILL.md") - // Atomic move from temp to final location. - if err := os.Rename(tmpPath, localPath); err != nil { + if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil { return fmt.Errorf("failed to write skill file: %w", err) } - - return os.Chmod(localPath, 0o600) + return nil } func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error { @@ -238,12 +569,10 @@ func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath strin return err } - // Atomic move from temp to final location. - if err := os.Rename(tmpPath, localPath); err != nil { + if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil { return fmt.Errorf("failed to move downloaded file: %w", err) } - - return os.Chmod(localPath, 0o600) + return nil } // shouldDownload determines if a file should be downloaded diff --git a/pkg/skills/installer_test.go b/pkg/skills/installer_test.go index 759cfc489..9691a5312 100644 --- a/pkg/skills/installer_test.go +++ b/pkg/skills/installer_test.go @@ -89,6 +89,12 @@ func TestParseGitHubRef(t *testing.T) { 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 { @@ -127,6 +133,268 @@ func TestParseGitHubRef(t *testing.T) { } } +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 @@ -197,6 +465,16 @@ func TestNewSkillInstaller(t *testing.T) { 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) } @@ -234,6 +512,24 @@ func TestNewSkillInstaller_WithProxy(t *testing.T) { } } +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") 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 45ae72253..6c8e28a4e 100644 --- a/pkg/skills/registry.go +++ b/pkg/skills/registry.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "path" + "strings" "sync" "time" ) @@ -42,11 +44,25 @@ type InstallResult struct { Summary string } +// 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. @@ -57,10 +73,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 } @@ -85,6 +122,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{ @@ -100,8 +160,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/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 60d9d5e5a..a9547eba9 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -6,6 +6,8 @@ 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" @@ -18,7 +20,7 @@ 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, response string) + PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) } // CronTool provides scheduling capabilities for the agent @@ -92,7 +94,7 @@ 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", @@ -114,15 +116,6 @@ func (t *CronTool) Parameters() map[string]any { "type": "string", "description": "Job ID (for remove/enable/disable)", }, - "type": map[string]any{ - "type": "string", - "enum": []string{"message", "directive"}, - "description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.", - }, - "deliver": map[string]any{ - "type": "boolean", - "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false", - }, }, "required": []string{"action"}, } @@ -199,18 +192,6 @@ 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 false so scheduled tasks execute through the agent - deliver := false - if d, ok := args["deliver"].(bool); ok { - deliver = d - } - - // Validate type parameter (server-side whitelist, not just LLM schema hint) - msgType, _ := args["type"].(string) - if msgType != "" && msgType != "message" && msgType != "directive" { - return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType)) - } - // 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. @@ -226,7 +207,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult if !t.allowCommand && !commandConfirm { return ErrorResult("command_confirm=true is required when allow_command is disabled") } - deliver = false } // Truncate message for job name (max 30 chars) @@ -236,7 +216,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult messagePreview, schedule, message, - deliver, channel, chatID, ) @@ -250,10 +229,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult job.Payload.Command = command needsUpdate = true } - if msgType != "" { - job.Payload.Type = msgType - needsUpdate = true - } if needsUpdate { t.cronService.UpdateJob(job) } @@ -338,8 +313,7 @@ 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" @@ -362,47 +336,18 @@ 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" } - // Determine message generation strategy - // Type="directive": treat message as instructions for AI agent to execute - // Type="" or "message" (default): static message content - isDirective := job.Payload.Type == "directive" + sessionKey := fmt.Sprintf("agent:cron-%s-%s", job.ID, uuid.New().String()) - // If deliver=true and not directive, send message directly without agent processing - if job.Payload.Deliver && !isDirective { - 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" - } - - // For deliver=false OR directive mode, process through agent - sessionKey := fmt.Sprintf("cron-%s", job.ID) - - // Prepare the prompt based on type - prompt := job.Payload.Message - if isDirective { - // For directive type, prefix to clarify this is an instruction - prompt = fmt.Sprintf( - "Please execute the following directive and provide the result:\n\n%s", - job.Payload.Message, - ) - } - - // Call agent with the prepared prompt + // Call agent with the job message response, err := t.executor.ProcessDirectWithChannel( ctx, - prompt, + job.Payload.Message, sessionKey, channel, chatID, @@ -412,7 +357,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } if response != "" { - t.executor.PublishResponseIfNeeded(ctx, channel, chatID, 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 index 186c6a75e..0e527c98a 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -24,6 +24,7 @@ type stubJobExecutor struct { publishedResp string publishedChan string publishedChatID string + publishedKey string } func (s *stubJobExecutor) ProcessDirectWithChannel( @@ -39,7 +40,7 @@ func (s *stubJobExecutor) ProcessDirectWithChannel( func (s *stubJobExecutor) PublishResponseIfNeeded( _ context.Context, - channel, chatID, response string, + channel, chatID, sessionKey, response string, ) { if s.alreadySent { return @@ -47,6 +48,7 @@ func (s *stubJobExecutor) PublishResponseIfNeeded( s.publishedResp = response s.publishedChan = channel s.publishedChatID = chatID + s.publishedKey = sessionKey } func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { @@ -229,28 +231,6 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { } } -func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) { - tool := newTestCronTool(t) - ctx := WithToolContext(context.Background(), "telegram", "chat-1") - result := tool.Execute(ctx, map[string]any{ - "action": "add", - "message": "send me a poem", - "at_seconds": float64(600), - }) - - if result.IsError { - t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM) - } - - jobs := tool.cronService.ListJobs(false) - if len(jobs) != 1 { - t.Fatalf("expected 1 job, got %d", len(jobs)) - } - if jobs[0].Payload.Deliver { - t.Fatal("expected deliver=false by default for non-command jobs") - } -} - func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Exec.Enabled = false @@ -293,8 +273,8 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { t.Fatalf("ExecuteJob() = %q, want ok", got) } - if executor.lastKey != "cron-job-1" { - t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey) + 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) @@ -305,6 +285,9 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { 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) } @@ -346,93 +329,6 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) { } } -func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) { - executor := &stubJobExecutor{response: "directive result"} - tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) - - originalMsg := "check the weather and summarize" - job := &cron.CronJob{ID: "job-dir-1"} - job.Payload.Channel = "telegram" - job.Payload.To = "chat-1" - job.Payload.Message = originalMsg - job.Payload.Type = "directive" - - if got := tool.ExecuteJob(context.Background(), job); got != "ok" { - t.Fatalf("ExecuteJob() = %q, want ok", got) - } - - wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg - if executor.lastPrompt != wantPrompt { - t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt) - } - if executor.publishedResp != "directive result" { - t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result") - } -} - -func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) { - executor := &stubJobExecutor{response: "agent processed"} - tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) - - job := &cron.CronJob{ID: "job-dir-deliver"} - job.Payload.Channel = "telegram" - job.Payload.To = "chat-1" - job.Payload.Message = "generate daily report" - job.Payload.Type = "directive" - job.Payload.Deliver = true - - if got := tool.ExecuteJob(context.Background(), job); got != "ok" { - t.Fatalf("ExecuteJob() = %q, want ok", got) - } - - if executor.lastPrompt == "" { - t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked") - } - if executor.publishedResp != "agent processed" { - t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed") - } - - // Verify no direct publish happened on the bus (agent path, not direct path) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - select { - case msg := <-tool.msgBus.OutboundChan(): - t.Fatalf("unexpected direct bus message: %+v", msg) - case <-ctx.Done(): - // expected: no direct bus message - } -} - -func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) { - executor := &stubJobExecutor{response: "should not be called"} - tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) - - job := &cron.CronJob{ID: "job-deliver"} - job.Payload.Channel = "telegram" - job.Payload.To = "chat-1" - job.Payload.Message = "hello world" - job.Payload.Deliver = true - - if got := tool.ExecuteJob(context.Background(), job); got != "ok" { - t.Fatalf("ExecuteJob() = %q, want ok", got) - } - - if executor.lastPrompt != "" { - t.Fatal("expected agent NOT to be invoked for deliver=true message type") - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - select { - case msg := <-tool.msgBus.OutboundChan(): - if msg.Content != "hello world" { - t.Fatalf("bus content = %q, want %q", msg.Content, "hello world") - } - case <-ctx.Done(): - t.Fatal("timeout waiting for direct bus message") - } -} - func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) { executor := &stubJobExecutor{ response: "this response must not be published", @@ -454,43 +350,3 @@ func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) { t.Fatalf("unexpected publish on error path: %q", executor.publishedResp) } } - -func TestCronTool_AddJobRejectsInvalidType(t *testing.T) { - tool := newTestCronTool(t) - ctx := WithToolContext(context.Background(), "cli", "direct") - result := tool.Execute(ctx, map[string]any{ - "action": "add", - "message": "test", - "at_seconds": float64(60), - "type": "invalid_type", - }) - - if !result.IsError { - t.Fatal("expected error for invalid type parameter") - } - if !strings.Contains(result.ForLLM, "invalid type") { - t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM) - } -} - -func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) { - for _, msgType := range []string{"", "message", "directive"} { - t.Run("type="+msgType, func(t *testing.T) { - tool := newTestCronTool(t) - ctx := WithToolContext(context.Background(), "cli", "direct") - args := map[string]any{ - "action": "add", - "message": "test", - "at_seconds": float64(60), - } - if msgType != "" { - args["type"] = msgType - } - - result := tool.Execute(ctx, args) - if result.IsError { - t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM) - } - }) - } -} 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/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/filesystem.go b/pkg/tools/fs/filesystem.go similarity index 69% rename from pkg/tools/filesystem.go rename to pkg/tools/fs/filesystem.go index 39d45013d..262d88d99 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/fs/filesystem.go @@ -1,18 +1,22 @@ -package tools +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" @@ -20,7 +24,23 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow -func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) { +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") } @@ -253,6 +273,11 @@ type ReadFileTool struct { maxSize int64 } +type ReadFileLinesTool struct { + fs fileSystem + maxSize int64 +} + func NewReadFileTool( workspace string, restrict bool, @@ -275,14 +300,53 @@ func NewReadFileTool( } } +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", @@ -306,6 +370,28 @@ func (t *ReadFileTool) Parameters() map[string]any { } } +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 { @@ -447,6 +533,302 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe 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) { @@ -483,7 +865,11 @@ type WriteFileTool struct { fs fileSystem } -func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -496,7 +882,7 @@ func (t *WriteFileTool) Name() string { } func (t *WriteFileTool) Description() string { - return "Write content to a file. If the file already exists, you must set overwrite=true to replace it." + 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 { @@ -509,7 +895,7 @@ func (t *WriteFileTool) Parameters() map[string]any { }, "content": map[string]any{ "type": "string", - "description": "Content to write to the file", + "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", @@ -536,7 +922,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR if !overwrite { if _, err := t.fs.Open(path); err == nil { - return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path)) + return ErrorResult( + fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path), + ) } } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/fs/filesystem_test.go similarity index 65% rename from pkg/tools/filesystem_test.go rename to pkg/tools/fs/filesystem_test.go index 0b4dd310b..4387332be 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/fs/filesystem_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test content"), 0o644) - tool := NewReadFileTool("", false, MaxReadFileSize) + tool := NewReadFileBytesTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": testFile, @@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { - tool := NewReadFileTool("", false, MaxReadFileSize) + tool := NewReadFileBytesTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": "/nonexistent_file_12345.txt", @@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + 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, + ) } } @@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { } // Should mention required parameter - if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { + 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) } } @@ -122,6 +128,45 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) { } } +// 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() @@ -297,7 +342,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) { "content": "replaced in sandbox", "overwrite": true, }) - assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM) + 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) @@ -325,7 +375,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { } // Should list files and directories - if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { + 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") { @@ -349,8 +400,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { } // 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) + 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, + ) } } @@ -397,7 +453,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { // 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") && + 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) } @@ -416,10 +473,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { }) // 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) + 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") + 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: @@ -653,7 +720,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { 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")}) + 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) } @@ -726,7 +796,6 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "pagination_test.txt") - // Create a test file with exactly 26 bytes of content fullContent := "abcdefghijklmnopqrstuvwxyz" err := os.WriteFile(testFile, []byte(fullContent), 0o644) if err != nil { @@ -748,15 +817,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) } - // Expect the first 10 characters if !strings.Contains(result1.ForLLM, "abcdefghij") { t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM) } - // Expect the header to indicate the file is truncated if !strings.Contains(result1.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM) } - // Expect the header to suggest the next offset (10) if !strings.Contains(result1.ForLLM, "offset=10") { t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM) } @@ -773,17 +839,14 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) } - // Expect the next 10 characters if !strings.Contains(result2.ForLLM, "klmnopqrst") { t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM) } - // Expect the header to suggest the next offset (20) 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) --- - // We ask for 10 bytes, but only 6 are left in the file args3 := map[string]any{ "path": testFile, "offset": 20, @@ -795,16 +858,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) } - // Expect the last 6 characters if !strings.Contains(result3.ForLLM, "uvwxyz") { t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM) } - // Expect the header to indicate the end of the file if !strings.Contains(result3.ForLLM, "[END OF FILE") { t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM) } - - // Ensure no TRUNCATED message is present in the final chunk if strings.Contains(result3.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM) } @@ -816,7 +875,6 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "short.txt") - // create a file of only 5 bytes err := os.WriteFile(testFile, []byte("12345"), 0o644) if err != nil { t.Fatalf("Failed to write test file: %v", err) @@ -827,19 +885,356 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { args := map[string]any{ "path": testFile, - "offset": int64(100), // Offset beyond the end of the file + "offset": int64(100), } result := tool.Execute(ctx, args) - // It should not be classified as a tool execution error if result.IsError { t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM) } - // Must return EXACTLY the string provided in the code 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 99% rename from pkg/tools/send_file.go rename to pkg/tools/fs/send_file.go index 44198381e..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" diff --git a/pkg/tools/send_file_test.go b/pkg/tools/fs/send_file_test.go similarity index 99% rename from pkg/tools/send_file_test.go rename to pkg/tools/fs/send_file_test.go index f36baf7d0..771393b75 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/fs/send_file_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" 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/mcp_tool.go b/pkg/tools/integration/mcp_tool.go similarity index 67% rename from pkg/tools/mcp_tool.go rename to pkg/tools/integration/mcp_tool.go index 5bffb4e89..8cfc1de5e 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/integration/mcp_tool.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" @@ -6,12 +6,17 @@ import ( "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 @@ -26,18 +31,31 @@ type MCPManager interface { // MCPTool wraps an MCP tool to implement the Tool interface type MCPTool struct { - manager MCPManager - serverName string - tool *mcp.Tool - mediaStore media.MediaStore + 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, + manager: manager, + serverName: serverName, + tool: tool, + maxInlineTextRunes: maxMCPInlineTextRunes, } } @@ -45,6 +63,23 @@ 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: @@ -143,6 +178,14 @@ func (t *MCPTool) Description() string { 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 @@ -210,26 +253,88 @@ func (t *MCPTool) Parameters() map[string]any { // 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 @@ -255,14 +360,19 @@ func extractContentText(content []mcp.Content) string { 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: - text := strings.TrimSpace(sanitizeToolLLMContent(v.Text)) - if text != "" { - llmParts = append(llmParts, text) + 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( @@ -295,10 +405,13 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont case *mcp.ResourceLink: llmParts = append(llmParts, summarizeResourceLink(v)) case *mcp.EmbeddedResource: - ref, note := t.storeEmbeddedResource(ctx, v) + 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) } @@ -307,34 +420,105 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont } } + 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: strings.Join(compactStrings(llmParts), "\n"), + ForLLM: forLLM, Media: mediaRefs, } return result } -func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) { +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.]" + return "", "[MCP returned an embedded resource without data.]", "" } resource := content.Resource if len(resource.Blob) > 0 { - return t.storeBinaryContent( + ref, note := t.storeBinaryContent( ctx, "resource", normalizedMIMEType(resource.MIMEType), resource.Blob, content.Annotations, ) + return ref, note, "" } - if strings.TrimSpace(resource.Text) != "" { - return "", sanitizeToolLLMContent(resource.Text) + rawText := strings.TrimSpace(resource.Text) + if rawText != "" { + return "", sanitizeToolLLMContent(resource.Text), rawText } - return "", summarizeEmbeddedResource(content) + return "", summarizeEmbeddedResource(content), "" } func (t *MCPTool) storeBinaryContent( diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go similarity index 65% rename from pkg/tools/mcp_tool_test.go rename to pkg/tools/integration/mcp_tool_test.go index 8bbac3bc7..7c961e1e1 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/integration/mcp_tool_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" @@ -7,10 +7,13 @@ import ( "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 @@ -104,6 +107,22 @@ func TestMCPTool_Name(t *testing.T) { } } +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 { @@ -282,6 +301,77 @@ func TestMCPTool_Execute_Success(t *testing.T) { } } +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{ @@ -634,3 +724,177 @@ func TestMCPTool_Execute_LargeBase64TextIsOmittedFromContext(t *testing.T) { 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 56% rename from pkg/tools/skills_install.go rename to pkg/tools/integration/skills_install.go index 71bfe730b..1824f2c0a 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 warning if suspicious. @@ -178,17 +260,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(), } @@ -201,3 +293,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/web.go b/pkg/tools/integration/web.go similarity index 72% rename from pkg/tools/web.go rename to pkg/tools/integration/web.go index 342f7458b..75821e40d 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/integration/web.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" @@ -15,6 +15,7 @@ import ( "strings" "sync/atomic" "time" + "unicode" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -23,6 +24,7 @@ import ( 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. @@ -46,7 +48,14 @@ var ( reDDGLink = regexp.MustCompile( `]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`, ) - reDDGSnippet = regexp.MustCompile(`([\s\S]*?)`) + reDDGSnippet = regexp.MustCompile( + `([\s\S]*?)`, + ) + reSogouTitle = regexp.MustCompile( + `]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*`, + ) + reSogouSnippet = regexp.MustCompile(`
\s*(.*?)\s*
`) + reSogouRealURL = regexp.MustCompile(`url=([^&]+)`) ) type APIKeyPool struct { @@ -91,6 +100,39 @@ 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 { @@ -218,6 +260,10 @@ func (p *BraveSearchProvider) Search( 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 != "" { @@ -317,6 +363,10 @@ func (p *TavilySearchProvider) Search( 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" @@ -417,6 +467,104 @@ func (p *TavilySearchProvider) Search( 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 @@ -532,6 +680,10 @@ func (p *PerplexitySearchProvider) Search( 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 @@ -637,6 +789,8 @@ func (p *PerplexitySearchProvider) Search( type SearXNGSearchProvider struct { baseURL string + proxy string + client *http.Client } func (p *SearXNGSearchProvider) Search( @@ -645,6 +799,10 @@ func (p *SearXNGSearchProvider) Search( 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)) @@ -657,7 +815,10 @@ func (p *SearXNGSearchProvider) Search( return "", fmt.Errorf("failed to create request: %w", err) } - client := &http.Client{Timeout: 10 * time.Second} + 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) @@ -719,6 +880,10 @@ func (p *GLMSearchProvider) Search( 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" @@ -808,6 +973,10 @@ func (p *BaiduSearchProvider) Search( 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" @@ -885,11 +1054,13 @@ func (p *BaiduSearchProvider) Search( } type WebSearchTool struct { - provider SearchProvider - maxResults int + provider SearchProvider + maxResults int + providerResolver func(query string) (SearchProvider, int) } type WebSearchToolOptions struct { + Provider string BraveAPIKeys []string BraveMaxResults int BraveEnabled bool @@ -897,6 +1068,8 @@ type WebSearchToolOptions struct { TavilyBaseURL string TavilyMaxResults int TavilyEnabled bool + SogouMaxResults int + SogouEnabled bool DuckDuckGoMaxResults int DuckDuckGoEnabled bool PerplexityAPIKeys []string @@ -917,100 +1090,370 @@ type WebSearchToolOptions struct { Proxy string } -func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { - var provider SearchProvider - maxResults := 10 - // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > Baidu Search > GLM Search - if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 { +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, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) - } - provider = &PerplexitySearchProvider{ - keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys), - proxy: opts.Proxy, - client: client, + 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) } - } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 { + 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, fmt.Errorf("failed to create HTTP client for Brave: %w", err) + return nil, 0, fmt.Errorf("failed to create HTTP client for Brave: %w", err) } - provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client} + maxResults := 10 if opts.BraveMaxResults > 0 { maxResults = min(opts.BraveMaxResults, 10) } - } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" { - provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL} + 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) } - } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 { + 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, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) + return nil, 0, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) } - provider = &TavilySearchProvider{ + 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 } - if opts.TavilyMaxResults > 0 { - maxResults = min(opts.TavilyMaxResults, 10) - } - } else if opts.DuckDuckGoEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) + return nil, 0, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) } - provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} + maxResults := 10 if opts.DuckDuckGoMaxResults > 0 { maxResults = min(opts.DuckDuckGoMaxResults, 10) } - } else if opts.BaiduSearchEnabled && opts.BaiduSearchAPIKey != "" { + 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, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err) + return nil, 0, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err) } - provider = &BaiduSearchProvider{ + 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 } - if opts.BaiduSearchMaxResults > 0 { - maxResults = min(opts.BaiduSearchMaxResults, 10) - } - } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) + return nil, 0, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) } searchEngine := opts.GLMSearchEngine if searchEngine == "" { searchEngine = "search_std" } - provider = &GLMSearchProvider{ + 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 } - if opts.GLMSearchMaxResults > 0 { - maxResults = min(opts.GLMSearchMaxResults, 10) + } + return false +} + +func containsLatinLetter(text string) bool { + for _, r := range text { + if unicode.IsLetter(r) && unicode.In(r, unicode.Latin) { + return true } - } else { + } + 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, + provider: provider, + maxResults: maxResults, + providerResolver: resolver, }, nil } @@ -1053,13 +1496,22 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR } query = strings.TrimSpace(query) - count64, err := getInt64Arg(args, "count", int64(t.maxResults)) + 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 := t.maxResults + count := maxResults if count64 > 0 && count64 <= 10 { - count = int(count64) + count = min(int(count64), maxResults) } rangeCode, err := normalizeSearchRange("") @@ -1077,7 +1529,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR } } - result, err := t.provider.Search(ctx, query, count, rangeCode) + result, err := provider.Search(ctx, query, count, rangeCode) if err != nil { return ErrorResult(fmt.Sprintf("search failed: %v", err)) } @@ -1102,6 +1554,8 @@ type privateHostWhitelist 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) @@ -1153,6 +1607,7 @@ func NewWebFetchToolWithConfig( 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 { @@ -1232,6 +1687,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe 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 { @@ -1434,6 +1890,9 @@ func newSafeDialContext( 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) { @@ -1482,6 +1941,46 @@ func newSafeDialContext( } } +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 diff --git a/pkg/tools/web_test.go b/pkg/tools/integration/web_test.go similarity index 82% rename from pkg/tools/web_test.go rename to pkg/tools/integration/web_test.go index de6187cfa..ba6b3da45 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" @@ -385,14 +385,14 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { } } -// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing +// 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.Errorf("Expected nil tool when Brave API key is empty") + t.Fatalf("Expected nil tool when only enabled provider is missing credentials") } // Also nil when nothing is enabled @@ -757,6 +757,33 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) { } } +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) @@ -1082,6 +1109,40 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { 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 @@ -1667,3 +1728,270 @@ func TestWebTool_GLMSearch_Priority(t *testing.T) { 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, ` +Result A +
Snippet A
+Result B +
Snippet B
+`) + 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/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/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 1e6263dc8..a68746b82 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -278,6 +278,7 @@ func (r *ToolRegistry) ExecuteWithContext( 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{ @@ -401,6 +402,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", @@ -409,11 +411,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() diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 2633411ff..5ce79e227 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -39,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 @@ -216,6 +225,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{ @@ -378,6 +414,47 @@ 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")) diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go index f41c80d90..c5884c9de 100644 --- a/pkg/tools/search_tool.go +++ b/pkg/tools/search_tool.go @@ -34,6 +34,14 @@ 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", @@ -95,6 +103,14 @@ 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", diff --git a/pkg/tools/session.go b/pkg/tools/session.go index 141dd4b5e..8c7584254 100644 --- a/pkg/tools/session.go +++ b/pkg/tools/session.go @@ -242,11 +242,3 @@ func (sm *SessionManager) List() []SessionInfo { func generateSessionID() string { return uuid.New().String()[:8] } - -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/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 95% rename from pkg/tools/result.go rename to pkg/tools/shared/result.go index c81213125..e4b16f7b3 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/shared/result.go @@ -1,4 +1,4 @@ -package tools +package toolshared import ( "encoding/json" @@ -8,8 +8,8 @@ import ( ) 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." + 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. @@ -73,14 +73,14 @@ func (tr *ToolResult) ContentForLLM() string { } if tr.ResponseHandled { if content == "" { - return handledToolLLMNote + return HandledToolLLMNote } - if !strings.Contains(content, handledToolLLMNote) { - content += "\n" + handledToolLLMNote + if !strings.Contains(content, HandledToolLLMNote) { + content += "\n" + HandledToolLLMNote } } if len(tr.ArtifactTags) > 0 { - artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote + artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + ArtifactPathsLLMNote if content == "" { content = artifactNote } else if !strings.Contains(content, artifactNote) { diff --git a/pkg/tools/types.go b/pkg/tools/shared/types.go similarity index 91% rename from pkg/tools/types.go rename to pkg/tools/shared/types.go index 4d1a18d5a..8a74d30f3 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/shared/types.go @@ -1,4 +1,4 @@ -package tools +package toolshared import "context" @@ -77,3 +77,11 @@ type ExecResponse struct { 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 6ee1cb993..a570ac9ec 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -20,6 +20,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/isolation" ) var ( @@ -52,7 +53,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). @@ -120,7 +121,7 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex func NewExecToolWithConfig( workingDir string, restrict bool, - config *config.Config, + cfg *config.Config, allowPaths ...[]*regexp.Regexp, ) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) @@ -131,8 +132,8 @@ func NewExecToolWithConfig( 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 { @@ -163,8 +164,8 @@ func NewExecToolWithConfig( } var timeout time.Duration - if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { - timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second + if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second } return &ExecTool{ @@ -378,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *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)) } @@ -521,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn session.stdinWriter = stdinWriter } - if err := cmd.Start(); err != nil { + // 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() } 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 d019d511a..a9a373856 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -92,11 +92,12 @@ func (t *SpawnTool) execute( label, _ := args["label"].(string) agentID, _ := args["agent_id"].(string) + targetAgentID := strings.TrimSpace(agentID) // Check allowlist if targeting a specific agent - if agentID != "" && t.allowlistCheck != nil { - if !t.allowlistCheck(agentID) { - return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID)) + if targetAgentID != "" && t.allowlistCheck != nil { + if !t.allowlistCheck(targetAgentID) { + return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", targetAgentID)) } } @@ -123,12 +124,14 @@ Task: %s`, // 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 + Model: t.defaultModel, + Tools: nil, // Will inherit from parent via context + SystemPrompt: systemPrompt, + MaxTokens: t.maxTokens, + Temperature: t.temperature, + Async: true, // Async execution + Critical: true, // Background spawn should survive parent turn completion + TargetAgentID: targetAgentID, }) if err != nil { result = ErrorResult(fmt.Sprintf("Spawn failed: %v", err)).WithError(err) diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go index fda6bbd89..c91c79578 100644 --- a/pkg/tools/spawn_test.go +++ b/pkg/tools/spawn_test.go @@ -6,10 +6,18 @@ import ( "testing" ) -// mockSpawner implements SubTurnSpawner for testing -type mockSpawner struct{} +// mockSpawner implements SubTurnSpawner for testing. +type mockSpawner struct { + lastConfig SubTurnConfig + done chan struct{} +} func (m *mockSpawner) SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error) { + m.lastConfig = cfg + if m.done != nil { + close(m.done) + } + // Extract task from system prompt for response task := cfg.SystemPrompt if strings.Contains(task, "Task: ") { @@ -62,12 +70,14 @@ func TestSpawnTool_Execute_ValidTask(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSpawnTool(manager) - tool.SetSpawner(&mockSpawner{}) + spawner := &mockSpawner{done: make(chan struct{})} + tool.SetSpawner(spawner) ctx := context.Background() args := map[string]any{ - "task": "Write a haiku about coding", - "label": "haiku-task", + "task": "Write a haiku about coding", + "label": "haiku-task", + "agent_id": "research", } result := tool.Execute(ctx, args) @@ -80,6 +90,13 @@ func TestSpawnTool_Execute_ValidTask(t *testing.T) { if !result.Async { t.Error("SpawnTool should return async result") } + <-spawner.done + if spawner.lastConfig.TargetAgentID != "research" { + t.Errorf("TargetAgentID = %q, want research", spawner.lastConfig.TargetAgentID) + } + if !spawner.lastConfig.Critical { + t.Error("SpawnTool should mark background subturns as critical") + } } func TestSpawnTool_Execute_NilManager(t *testing.T) { diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 9a1a8b802..feeabe536 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -30,6 +30,7 @@ type SubTurnConfig struct { 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 { @@ -67,6 +68,12 @@ type SubagentManager struct { 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( @@ -90,6 +97,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) { 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() @@ -177,6 +195,7 @@ func (sm *SubagentManager) runTask( temperature := sm.temperature hasMaxTokens := sm.hasMaxTokens hasTemperature := sm.hasTemperature + mediaResolver := sm.mediaResolver sm.mu.RUnlock() var result *ToolResult @@ -223,6 +242,7 @@ After completing the task, provide a clear summary of what was done.` Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, + MediaResolver: mediaResolver, }, messages, task.OriginChannel, task.OriginChatID) if err == nil { diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 387813e94..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{ @@ -161,11 +185,15 @@ func RunToolLoop( for _, r := range results { 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/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 index 95c63f0e3..f8b9f6882 100644 --- a/pkg/utils/bm25.go +++ b/pkg/utils/bm25.go @@ -29,18 +29,18 @@ const ( DefaultBM25B = 0.75 ) -// BM25Engine is a query-time BM25 search engine over a generic corpus. +// 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 is stateless between queries: no caching, no invalidation logic. -// All indexing work is performed inside Search() on every call, making it -// safe to use on corpora that change frequently. +// 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. @@ -51,6 +51,17 @@ type bm25Config struct { 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 } @@ -74,12 +85,14 @@ func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Optio for _, o := range opts { o(&cfg) } - return &BM25Engine[T]{ + 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. @@ -91,9 +104,8 @@ type BM25Result[T any] struct { // 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(N×L) for indexing + O(|Q|×avgPostingLen) for scoring, -// where N = corpus size, L = average document length, Q = query terms. -// Top-k extraction uses a fixed-size min-heap: O(candidates × log k). +// 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]{} @@ -104,78 +116,24 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { return []BM25Result[T]{} } - N := len(e.corpus) - if N == 0 { + if len(e.corpus) == 0 || e.index == nil { return []BM25Result[T]{} } - // Step 1: build per-document tf + raw doc lengths - type docEntry struct { - tf map[string]uint32 - rawLen int - } - - entries := make([]docEntry, N) - df := make(map[string]int, 64) - totalLen := 0 - - for i, doc := range e.corpus { - tokens := bm25Tokenize(e.textFunc(doc)) - totalLen += len(tokens) - - tf := make(map[string]uint32, len(tokens)) - for _, t := range tokens { - tf[t]++ - } - // df: each term counts once per document (iterate the map, keys are unique) - for t := range tf { - df[t]++ - } - - entries[i] = docEntry{tf: tf, rawLen: len(tokens)} - } - - avgDocLen := float64(totalLen) / float64(N) - - // Step 2: pre-compute IDF and per-doc length normalization - // IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 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[i] = k1 * (1 - b + b * |doc_i| / avgDocLen) - // Stored as float32 — sufficient precision for ranking. - docLenNorm := make([]float32, N) - for i, entry := range entries { - docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen)) - } - - // Step 3: build inverted index (posting lists) - // Iterate the tf map directly — map keys are already unique, no seen-set needed. - posting := make(map[string][]int32, len(df)) - for i, entry := range entries { - for term := range entry.tf { - posting[term] = append(posting[term], int32(i)) - } - } - // 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 := idf[term] + termIDF, ok := e.index.idf[term] if !ok { continue // term not in vocabulary → zero contribution } - for _, docID := range posting[term] { - freq := float32(entries[docID].tf[term]) + 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 + docLenNorm[docID]) + tfNorm := freq * float32(e.k1+1) / (freq + e.index.docLenNorm[docID]) scores[docID] += termIDF * tfNorm } } @@ -212,6 +170,65 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { 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)) diff --git a/pkg/utils/bm25_test.go b/pkg/utils/bm25_test.go index 4bc85b246..216fe733d 100644 --- a/pkg/utils/bm25_test.go +++ b/pkg/utils/bm25_test.go @@ -1,7 +1,9 @@ package utils import ( + "fmt" "reflect" + "strings" "testing" ) @@ -173,3 +175,61 @@ func TestBM25Search_SortingStability(t *testing.T) { } } } + +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/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/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/elevenlabs_transcriber_test.go b/pkg/voice/elevenlabs_transcriber_test.go deleted file mode 100644 index 78be8958a..000000000 --- a/pkg/voice/elevenlabs_transcriber_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package voice - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" -) - -// Ensure ElevenLabsTranscriber satisfies the Transcriber interface at compile time. -var _ Transcriber = (*ElevenLabsTranscriber)(nil) - -func TestElevenLabsTranscriberName(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test") - 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")) - } - 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") - 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") - 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") - _, 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/pkg/voice/groq_transcriber.go b/pkg/voice/groq_transcriber.go deleted file mode 100644 index b42e598f7..000000000 --- a/pkg/voice/groq_transcriber.go +++ /dev/null @@ -1,151 +0,0 @@ -package voice - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -type GroqTranscriber struct { - apiKey string - apiBase string - httpClient *http.Client -} - -func NewGroqTranscriber(apiKey string) *GroqTranscriber { - logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""}) - - apiBase := "https://api.groq.com/openai/v1" - return &GroqTranscriber{ - apiKey: apiKey, - apiBase: apiBase, - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, - } -} - -func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { - logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath}) - - audioFile, err := os.Open(audioFilePath) - if err != nil { - logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) - return nil, fmt.Errorf("failed to open audio file: %w", err) - } - defer audioFile.Close() - - fileInfo, err := audioFile.Stat() - if err != nil { - logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) - return nil, fmt.Errorf("failed to get file info: %w", err) - } - - logger.DebugCF("voice", "Audio file details", map[string]any{ - "size_bytes": fileInfo.Size(), - "file_name": filepath.Base(audioFilePath), - }) - - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - - part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) - if err != nil { - logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to create form file: %w", err) - } - - copied, err := io.Copy(part, audioFile) - if 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.Close(); err != nil { - logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to close multipart writer: %w", err) - } - - url := t.apiBase + "/audio/transcriptions" - req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) - if err != nil { - logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+t.apiKey) - - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{ - "url": url, - "request_size_bytes": requestBody.Len(), - "file_size_bytes": fileInfo.Size(), - }) - - resp, err := t.httpClient.Do(req) - if err != nil { - logger.ErrorCF("voice", "Failed to send 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 response", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - logger.ErrorCF("voice", "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)) - } - - logger.DebugCF("voice", "Received response from Groq API", map[string]any{ - "status_code": resp.StatusCode, - "response_size_bytes": len(body), - }) - - var result TranscriptionResponse - if err := json.Unmarshal(body, &result); err != nil { - logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - logger.InfoCF("voice", "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" -} diff --git a/pkg/voice/groq_transcriber_test.go b/pkg/voice/groq_transcriber_test.go deleted file mode 100644 index fdcaa7580..000000000 --- a/pkg/voice/groq_transcriber_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package voice - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" -) - -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 TestGroqTranscribe(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/pkg/voice/transcriber.go b/pkg/voice/transcriber.go deleted file mode 100644 index f56fdeedd..000000000 --- a/pkg/voice/transcriber.go +++ /dev/null @@ -1,68 +0,0 @@ -package voice - -import ( - "context" - "strings" - - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/providers" -) - -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(model string) bool { - protocol, _ := providers.ExtractProtocol(model) - - switch protocol { - case "openai", "azure", "azure-openai", - "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", - "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding": - // 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 - } -} - -// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or -// nil if no supported transcription provider is configured. -func DetectTranscriber(cfg *config.Config) Transcriber { - if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" { - modelCfg, err := cfg.GetModelConfig(modelName) - if err != nil { - return nil - } - if supportsAudioTranscription(modelCfg.Model) { - return NewAudioModelTranscriber(modelCfg) - } - } - - // ElevenLabs voice config (supports Scribe STT). - if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" { - return NewElevenLabsTranscriber(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 -} diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 76cc72938..df2100aec 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then exit 1 fi +LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}" +EXECUTABLE="picoclaw-${EXECUTABLE}" echo "executable: $EXECUTABLE" APP_NAME="PicoClaw Launcher" @@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES" # Copy executable echo "Copying executable..." -if [ -f "./web/build/${APP_EXECUTABLE}" ]; then - cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/" +if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then + cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}" else - echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first." - echo "Run: make build in web dir" + echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first." + echo "Run: make build-launcher" exit 1 fi -if [ -f "./build/picoclaw" ]; then - cp "./build/picoclaw" "${APP_MACOS}/" +if [ -f "./build/${EXECUTABLE}" ]; then + cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw" else - echo "Error: ./build/picoclaw not found. Please build the main file first." + echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first." echo "Run: make build" exit 1 fi @@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF' NSSupportsAutomaticGraphicsSwitching - LSRequiresCarbon - LSUIElement - 1 + + LSMinimumSystemVersion + 10.11 EOF 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 \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/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 ..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 ..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 ..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..md, for example $(suggest_noncanonical_translation_name "$path")" + fi + + if is_noncanonical_translation_name "$path"; then + error \ + "$path" \ + "translation files must use lowercase ..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/web/Makefile b/web/Makefile index 06717f2b9..254c439e9 100644 --- a/web/Makefile +++ b/web/Makefile @@ -1,25 +1,74 @@ -.PHONY: dev dev-frontend dev-backend build test lint clean +.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \ + build-android-arm64 build-android-bundle # Go variables -GO?=CGO_ENABLED=0 go +GO?=go WEB_GO?=$(GO) -GOFLAGS?=-v -tags stdjson +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 -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 $(WEB_GO) version | awk '{print $$3}') +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 -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) @@ -51,46 +100,110 @@ else ifeq ($(UNAME_S),Darwin) endif else ifeq ($(UNAME_S),Windows) PLATFORM=windows - ARCH=$(UNAME_M) - LDFLAGS=-H=windowsgui $(LDFLAGS) + 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: - @if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \ - echo "Build artifacts not found, building..."; \ - $(MAKE) build; \ +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 & $(MAKE) dev-frontend + @$(MAKE) dev-backend BACKEND_ARGS='-no-browser' & $(MAKE) dev-frontend # Start frontend dev server (Vite, with proxy to backend) dev-frontend: - cd frontend && pnpm dev + cd $(FRONTEND_DIR) && pnpm dev # Start backend dev server dev-backend: - cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" . + cd $(BACKEND_DIR) && PICOCLAW_BINARY="$(PICOCLAW_BINARY)" ${WEB_GO} run -ldflags "$(LAUNCHER_LDFLAGS)" . $(BACKEND_ARGS) # Build frontend and embed into Go binary -build: - cd frontend && pnpm build:backend - ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/ +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 && ${WEB_GO} test ./... - cd frontend && pnpm lint + cd $(BACKEND_DIR) && ${WEB_GO} test ./... + cd $(FRONTEND_DIR) && pnpm lint # Lint and format lint: - cd backend && ${WEB_GO} vet ./... - cd frontend && pnpm check + cd $(BACKEND_DIR) && ${WEB_GO} vet ./... + cd $(FRONTEND_DIR) && pnpm check # Clean build artifacts clean: - rm -rf frontend/dist backend/dist $(BUILD_DIR) - mkdir -p backend/dist && touch backend/dist/.gitkeep +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 index 6ec247bae..2a57524e0 100644 --- a/web/README.md +++ b/web/README.md @@ -1,51 +1,367 @@ -# Picoclaw Web +# PicoClaw Web -This directory contains the standalone web service for `picoclaw`. -It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine. +`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 -The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment. +This directory is a small monorepo: -* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable. -* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface. +- `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. -## Getting Started +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+ with pnpm +- Go `1.25+` +- Node.js 20.19+ or 22.13+ +- `pnpm` -### Development +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. -Run both the frontend dev server and the Go backend simultaneously: +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 ``` -Or run them separately: +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 # Vite dev server -make dev-backend # Go backend +make dev-frontend +make dev-backend ``` -### Build +Notes: -Build the frontend and embed it into a single Go binary: +- `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 ``` -The output binary is `backend/picoclaw-web`. +This: -### Other Commands +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 test # Run backend tests and frontend lint -make lint # Run go vet and prettier/eslint -make clean # Remove all build artifacts +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-- +``` + +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/api/auth.go b/web/backend/api/auth.go index b9b4d5f66..da07b76c0 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -1,8 +1,10 @@ package api import ( + "context" "crypto/subtle" "encoding/json" + "fmt" "io" "net/http" "strings" @@ -10,58 +12,83 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -// LauncherAuthRouteOpts configures dashboard token login handlers. -type LauncherAuthRouteOpts struct { - DashboardToken string - SessionCookie string - SecureCookie func(*http.Request) bool - // TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets). - TokenHelp LauncherAuthTokenHelp +// 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) } -// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token. -type LauncherAuthTokenHelp struct { - EnvVarName string `json:"env_var_name"` - LogFileAbs string `json:"log_file,omitempty"` - TrayCopyMenu bool `json:"tray_copy_menu"` - ConsoleStdout bool `json:"console_stdout"` +// 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 { - Token string `json:"token"` + Password string `json:"password"` +} + +type launcherAuthSetupBody struct { + Password string `json:"password"` + Confirm string `json:"confirm"` } type launcherAuthStatusResponse struct { - Authenticated bool `json:"authenticated"` - TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"` + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` } -// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status. +// 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{ - token: opts.DashboardToken, sessionCookie: opts.SessionCookie, secureCookie: secure, - tokenHelp: opts.TokenHelp, + 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 { - token string sessionCookie string secureCookie func(*http.Request) bool - tokenHelp LauncherAuthTokenHelp + 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 @@ -76,10 +103,29 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques _, _ = w.Write([]byte(`{"error":"too many login attempts"}`)) return } - in := strings.TrimSpace(body.Token) - if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 { + 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 token"}`)) + _, _ = w.Write([]byte(`{"error":"invalid password"}`)) return } @@ -120,23 +166,105 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - ok := false + authed := false if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { - ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 + authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 } - if ok { - _, _ = w.Write([]byte(`{"authenticated":true}`)) + initialized, initErr := h.isStoreInitialized(r.Context()) + if initErr != nil { + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) return } resp := launcherAuthStatusResponse{ - Authenticated: false, - TokenHelp: &h.tokenHelp, + Authenticated: authed, + Initialized: initialized, } enc, err := json.Marshal(resp) if err != nil { w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"internal error"}`)) + 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_test.go b/web/backend/api/auth_test.go index d2624a440..f7f6037a0 100644 --- a/web/backend/api/auth_test.go +++ b/web/backend/api/auth_test.go @@ -2,7 +2,9 @@ package api import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -12,23 +14,43 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -func TestLauncherAuthLoginAndStatus(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = 0x55 +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 } - const tok = "dashboard-test-token-9" - sess := middleware.SessionCookieValue(key, tok) + 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{ - DashboardToken: tok, - SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{ - EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", - LogFileAbs: "/tmp/launcher.log", - TrayCopyMenu: true, - ConsoleStdout: false, - }, + SessionCookie: sess, + PasswordStore: store, }) t.Run("status_unauthenticated", func(t *testing.T) { @@ -38,23 +60,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { t.Fatalf("status code = %d", rec.Code) } var body struct { - Authenticated bool `json:"authenticated"` - TokenHelp *LauncherAuthTokenHelp `json:"token_help"` + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } - if body.Authenticated || body.TokenHelp == nil { - t.Fatalf("unexpected body: %+v", body) - } - if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" { - t.Fatalf("token_help = %+v", body.TokenHelp) + 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(`{"token":"`+tok+`"}`)) + 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) @@ -84,14 +103,152 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { }) } -func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") +func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"}, + 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() @@ -118,18 +275,15 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { } func TestLauncherAuthLoginRateLimit(t *testing.T) { - key := make([]byte, 32) - const tok = "rate-limit-tok-xxxxxxxx" - sess := middleware.SessionCookieValue(key, tok) + store := &fakePasswordStore{initialized: true, password: "correct-password"} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + SessionCookie: "session-cookie-value", + PasswordStore: store, }) - // 11 failing logins by wrong token; each consumes allow() slot after valid JSON. - wrongBody := `{"token":"wrong"}` + // 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)) @@ -181,13 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) { } func TestLauncherAuthLogoutEmptyBody(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) @@ -200,13 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) { } func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index dd4c9af3d..82cd54b72 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "net/http" + + "github.com/sipeed/picoclaw/pkg/config" ) type channelCatalogItem struct { @@ -30,9 +32,17 @@ var channelCatalog = []channelCatalogItem{ {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. @@ -44,3 +54,150 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques "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 index 0add7594d..afcd3f74e 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "reflect" "regexp" "strings" @@ -20,6 +21,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { 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 @@ -47,8 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } 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(body, &cfg); err != nil { + if err = json.Unmarshal(normalizedBody, &cfg); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } @@ -63,6 +86,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { 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") @@ -74,13 +98,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } - logger.Infof("configuration updated successfully") - 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"}) } @@ -124,7 +149,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { 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) @@ -139,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { // 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) @@ -159,6 +187,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { 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") @@ -175,6 +204,9 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { return } + h.applyRuntimeLogLevel() + logger.Infof("configuration updated successfully") + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } @@ -259,26 +291,54 @@ func validateConfig(cfg *config.Config) []string { } // Pico channel: token required when enabled - if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" { - errs = append(errs, "channels.pico.token is required when pico channel is 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 - if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token.String() == "" { - errs = append(errs, "channels.telegram.token is required when telegram channel is 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 - if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token.String() == "" { - errs = append(errs, "channels.discord.token is required when discord channel is 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") + } + } + } } - if cfg.Channels.WeCom.Enabled { - if cfg.Channels.WeCom.BotID == "" { - errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled") - } - if cfg.Channels.WeCom.Secret.String() == "" { - errs = append(errs, "channels.wecom.secret is required when wecom 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") + } + } + } } } @@ -325,3 +385,374 @@ func mergeMap(dst, src map[string]any) { } } } + +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 index 644284849..8377c2eca 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -6,11 +6,42 @@ import ( "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() @@ -20,7 +51,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ -"version": 1, +"version": 3, "agents": { "defaults": { "workspace": "~/.picoclaw/workspace" @@ -143,6 +174,409 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes } } +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()) { @@ -166,8 +600,14 @@ func setupPicoEnabledEnv(t *testing.T) (string, func()) { APIKeys: config.SimpleSecureStrings("sk-default"), }} cfg.Agents.Defaults.ModelName = "custom-default" - cfg.Channels.Pico.Enabled = true - cfg.Channels.Pico.Token = *config.NewSecureString("test-pico-token") + 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 { @@ -251,6 +691,162 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { } } +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() @@ -443,3 +1039,190 @@ func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) { 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 index 2621b722b..45f7e6912 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -2,6 +2,7 @@ package api import ( "bufio" + "bytes" "encoding/json" "errors" "fmt" @@ -10,7 +11,9 @@ import ( "net/http" "os" "os/exec" + "reflect" "runtime" + "sort" "strconv" "strings" "sync" @@ -20,6 +23,8 @@ import ( "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" ) @@ -33,16 +38,72 @@ var gateway = struct { 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) { @@ -50,16 +111,31 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, return client.Get(url) } -// getGatewayHealth checks the gateway health endpoint and returns the status response +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) { - port := 18790 - if cfg != nil && cfg.Gateway.Port != 0 { - port = cfg.Gateway.Port + // 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)) } - probeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) - url := "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health" + url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health" return getGatewayHealthByURL(url, timeout) } @@ -79,6 +155,150 @@ func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusRes 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) @@ -92,30 +312,33 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { // 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 if gateway is already running via health endpoint - cfg, cfgErr := config.LoadConfig(h.configPath) - if cfgErr == nil && cfg != nil { - healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) - if err == nil && statusCode == http.StatusOK { - // Gateway is already running, attach to the existing process - pid := healthResp.Pid - gateway.mu.Lock() - defer gateway.mu.Unlock() - 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 - } - _, err = h.startGatewayLocked("starting", pid) - if err != nil { - logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) - } + // 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() @@ -159,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { 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 @@ -211,6 +437,10 @@ func computeConfigSignature(cfg *config.Config) string { } 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") @@ -254,9 +484,175 @@ func computeConfigSignature(cfg *config.Config) string { 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+":") + 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+":") + 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 @@ -283,7 +679,13 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { return true } - return cmd.Process.Signal(syscall.Signal(0)) == nil + 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) { @@ -327,6 +729,15 @@ func gatewayStatusWithoutHealthLocked() string { 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" { @@ -383,6 +794,11 @@ func stopGatewayLocked() (int, error) { } 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 @@ -400,6 +816,7 @@ func stopGatewayLocked() (int, error) { gateway.cmd = nil gateway.owned = false gateway.bootDefaultModel = "" + gateway.pidData = nil setGatewayRuntimeStatusLocked("stopped") return pid, nil @@ -452,6 +869,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int 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 } @@ -461,8 +879,10 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int // Start new process // Locate the picoclaw executable execPath := utils.FindPicoclawBinary() + logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) - cmd = exec.Command(execPath, "gateway", "-E") + 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 @@ -470,8 +890,9 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int if h.configPath != "" { cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath) } - if host := h.gatewayHostOverride(); host != "" { - cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host) + gatewayHostOverride := h.gatewayHostOverride() + if gatewayHostOverride != "" { + cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride) } stdoutPipe, err := cmd.StdoutPipe() @@ -488,10 +909,21 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - if _, err := h.EnsurePicoChannel(""); err != nil { + 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) @@ -529,8 +961,9 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.mu.Unlock() }() - // Start a goroutine to probe health and update the runtime state once ready. + // 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() @@ -539,19 +972,46 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int 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 } - healthResp, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second) - if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid { - // Verify the health endpoint returns the expected pid + _, 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() - return + if !healthConfirmed { + healthConfirmed = true + logger.InfoC("gateway", "Gateway health endpoint reachable; waiting for pid file") + } + continue } } }() @@ -563,49 +1023,47 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int // // POST /api/gateway/start func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { - // Prevent duplicate starts by checking health endpoint - cfg, cfgErr := config.LoadConfig(h.configPath) - if cfgErr == nil && cfg != nil { - healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) - if err == nil && statusCode == http.StatusOK { - // Gateway is already running, attach to the existing process - pid := healthResp.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) + // 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() - if err != nil { - 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 - } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ - "status": "ok", - "pid": pid, + "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() @@ -692,9 +1150,22 @@ func (h *Handler) RestartGateway() (int, error) { 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 { @@ -805,66 +1276,42 @@ func (h *Handler) gatewayStatusData() map[string]any { } } - // Probe health endpoint to get pid and status - healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) - if err != nil { + // Primary detection: read PID file and check if process is alive. + pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), cfg) + if pidData != nil { gateway.mu.Lock() - data["gateway_status"] = gatewayStatusWithoutHealthLocked() - gateway.mu.Unlock() - logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err)) - } else { - if statusCode != http.StatusOK { - logger.WarnC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode)) - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("error") - gateway.mu.Unlock() - data["gateway_status"] = "error" - data["status_code"] = statusCode - } else { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("running") - if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != healthResp.Pid { - oldPid := "none" - if gateway.cmd != nil && gateway.cmd.Process != nil { - oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid) - } - logger.InfoC( - "gateway", - fmt.Sprintf( - "Detected new gateway PID (old: %s, new: %d), attempting to attach", - oldPid, - healthResp.Pid, - ), - ) - - if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil { - // Failed to find the process, treat as error - setGatewayRuntimeStatusLocked("error") - data["gateway_status"] = "error" - data["pid"] = healthResp.Pid - logger.ErrorC( - "gateway", - fmt.Sprintf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err), - ) - } else { - // Successfully attached, update response data - bootDefaultModel := gateway.bootDefaultModel - if bootDefaultModel != "" { - data["boot_default_model"] = bootDefaultModel - } - data["gateway_status"] = "running" - data["pid"] = healthResp.Pid - } - } - - bootDefaultModel := gateway.bootDefaultModel - if bootDefaultModel != "" { - data["boot_default_model"] = bootDefaultModel - } - data["gateway_status"] = "running" - data["pid"] = healthResp.Pid - gateway.mu.Unlock() + 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) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 6190f0c7c..03af7a9d3 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -8,9 +8,15 @@ import ( "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 } @@ -24,8 +30,11 @@ func (h *Handler) effectiveLauncherPublic() bool { } func (h *Handler) gatewayHostOverride() string { + if h.serverHostExplicit { + return strings.TrimSpace(h.serverHostInput) + } if h.effectiveLauncherPublic() { - return "0.0.0.0" + return "*" } return "" } @@ -41,10 +50,11 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { } func gatewayProbeHost(bindHost string) string { - if bindHost == "" || bindHost == "0.0.0.0" { - return "127.0.0.1" + plan, err := netbind.BuildPlan(bindHost, netbind.DefaultLoopback) + if err != nil || strings.TrimSpace(plan.ProbeHost) == "" { + return netbind.ResolveAdaptiveLoopbackHost() } - return bindHost + return plan.ProbeHost } func (h *Handler) gatewayProxyURL() *url.URL { @@ -72,11 +82,25 @@ func requestHostName(r *http.Request) string { if strings.TrimSpace(r.Host) != "" { return r.Host } - return "127.0.0.1" + 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 := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "wss" @@ -95,7 +119,7 @@ func requestWSScheme(r *http.Request) string { // requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE). func requestHTTPScheme(r *http.Request) string { - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "https" @@ -107,6 +131,7 @@ func requestHTTPScheme(r *http.Request) string { if r.TLS != nil { return "https" } + return "http" } @@ -128,6 +153,14 @@ func forwardedHostFirst(r *http.Request) string { // 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 "" @@ -136,7 +169,7 @@ func forwardedRFC7239Host(r *http.Request) string { for _, part := range strings.Split(first, ";") { part = strings.TrimSpace(part) low := strings.ToLower(part) - if !strings.HasPrefix(low, "host=") { + if !strings.HasPrefix(low, key+"=") { continue } val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:]) @@ -167,13 +200,21 @@ 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 strconv.Itoa(serverListenPort) + return "80" } // joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser. @@ -190,13 +231,12 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in func (h *Handler) picoWebUIAddr(r *http.Request) string { wsPort := h.serverPort if wsPort == 0 { - wsPort = 18800 // default web server port + wsPort = 18800 } if fwdHost := forwardedHostFirst(r); fwdHost != "" { return joinClientVisibleHostPort(r, fwdHost, wsPort) } - host := requestHostName(r) - return net.JoinHostPort(host, strconv.Itoa(wsPort)) + return joinClientVisibleHostPort(r, requestHostName(r), wsPort) } func (h *Handler) buildWsURL(r *http.Request) string { diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index 7150b6fee..54d1010d2 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -3,6 +3,7 @@ package api import ( "crypto/tls" "errors" + "net" "net/http" "net/http/httptest" "path/filepath" @@ -10,6 +11,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/netbind" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) @@ -26,8 +28,8 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { h := NewHandler(configPath) h.SetServerOptions(18800, true, true, nil) - if got := h.gatewayHostOverride(); got != "0.0.0.0" { - t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") + if got := h.gatewayHostOverride(); got != "*" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "*") } } @@ -48,7 +50,7 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + 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" { @@ -64,8 +66,36 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { } func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { - if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" { - t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1") + 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") } } @@ -137,8 +167,9 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) { _ = statusCode _ = err - if requestedURL != "http://127.0.0.1:18791/health" { - t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health") + want := "http://" + net.JoinHostPort(netbind.ResolveAdaptiveLoopbackHost(), "18791") + "/health" + if requestedURL != want { + t.Fatalf("health url = %q, want %q", requestedURL, want) } } @@ -150,12 +181,12 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + 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:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") + 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") } } @@ -167,12 +198,12 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + 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:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") + 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") } } @@ -193,7 +224,7 @@ func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil) + 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") @@ -218,13 +249,30 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + 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:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") + 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", + ) } } @@ -233,10 +281,50 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { h := NewHandler(configPath) h.SetServerOptions(18800, false, false, nil) - req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", 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 index 42f0ab66c..f383089a6 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -37,6 +38,36 @@ func startLongRunningProcess(t *testing.T) *exec.Cmd { 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, @@ -65,17 +96,24 @@ 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") @@ -83,6 +121,226 @@ func resetGatewayTestState(t *testing.T) { }) } +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) @@ -99,6 +357,143 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) { } } +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() @@ -165,6 +560,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { } } +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() @@ -430,7 +836,7 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) } } -func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { +func TestGatewayStatusKeepsPidDataWhileTrackedProcessAliveWhenPidFileUnavailable(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") @@ -446,6 +852,173 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { _ = 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() @@ -454,6 +1027,13 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { 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) @@ -470,9 +1050,6 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { if got := body["gateway_status"]; got != "running" { t.Fatalf("gateway_status = %#v, want %q", got, "running") } - if got := body["pid"]; got != float64(cmd.Process.Pid) { - t.Fatalf("pid = %#v, want %d", got, cmd.Process.Pid) - } if got := body["gateway_restart_required"]; got != false { t.Fatalf("gateway_restart_required = %#v, want false", got) } @@ -480,6 +1057,7 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { 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() @@ -498,14 +1076,23 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) { mux := http.NewServeMux() h.RegisterRoutes(mux) - process, err := os.FindProcess(os.Getpid()) - if err != nil { - t.Fatalf("FindProcess() 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: "127.0.0.1", + Port: 18790, + }) bootSignature := computeConfigSignature(cfg) gateway.mu.Lock() - gateway.cmd = &exec.Cmd{Process: process} + gateway.cmd = cmd gateway.bootDefaultModel = cfg.ModelList[0].ModelName gateway.bootConfigSignature = bootSignature setGatewayRuntimeStatusLocked("running") @@ -614,6 +1201,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) { } } +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) diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index e149d5671..92911157c 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -61,11 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ return } - cfg := launcherconfig.Config{ - Port: payload.Port, - Public: payload.Public, - AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), + 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 diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 0d6af823c..68ab1be42 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -41,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { 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() @@ -50,7 +59,9 @@ func TestPutLauncherConfigPersists(t *testing.T) { req := httptest.NewRequest( http.MethodPut, "/api/system/launcher-config", - strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`), + strings.NewReader( + `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`, + ), ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) @@ -59,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - path := launcherconfig.PathForAppConfig(configPath) cfg, err := launcherconfig.Load(path, launcherconfig.Default()) if err != nil { t.Fatalf("launcherconfig.Load() error = %v", err) @@ -67,6 +77,12 @@ func TestPutLauncherConfigPersists(t *testing.T) { 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) } diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index aeef85119..302231d80 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -1,37 +1,107 @@ 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 +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 provider, ok := oauthProviderForModel(m.Model); ok { - cred, err := oauthGetCredential(provider) - if err != nil || cred == nil { - return false - } - return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" + 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 } @@ -42,16 +112,51 @@ func hasModelConfiguration(m *config.ModelConfig) bool { return apiKey != "" } -// isModelConfigured reports whether a model is currently available to use. -// Local models must be reachable; remote/API-key models only need saved config. -func isModelConfigured(m *config.ModelConfig) bool { - if !hasModelConfiguration(m) { +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 } - if requiresRuntimeProbe(m) { - return probeLocalModelAvailability(m) +} + +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 } - return true +} + +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 { @@ -60,10 +165,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { return true } - switch modelProtocol(m.Model) { + protocol := modelProtocol(m) + + switch protocol { case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": return true - case "ollama", "vllm": + } + + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { apiBase := strings.TrimSpace(m.APIBase) return apiBase == "" || hasLocalAPIBase(apiBase) } @@ -76,17 +185,47 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { } 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.Model) + protocol, modelID := splitModel(m) switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) - case "vllm": + case "vllm", "lmstudio": return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) - case "claude-cli", "claudecli", "codex-cli", "codexcli": - return true + case "claude-cli", "claudecli": + return probeCommandAvailableFunc("claude") + case "codex-cli", "codexcli": + return probeCommandAvailableFunc("codex") default: if hasLocalAPIBase(apiBase) { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) @@ -95,16 +234,211 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool { } } +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< 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) } - switch modelProtocol(m.Model) { - case "ollama": - return "http://localhost:11434/v1" - case "vllm": - return "http://localhost:8000/v1" + protocol := modelProtocol(m) + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { + return providers.DefaultAPIBaseForProtocol(protocol) + } + + switch protocol { case "github-copilot", "copilot": return "localhost:4321" default: @@ -134,8 +468,8 @@ func normalizeModelProbeAPIBase(raw string) string { return u.String() } -func oauthProviderForModel(model string) (string, bool) { - switch modelProtocol(model) { +func oauthProviderForModel(m *config.ModelConfig) (string, bool) { + switch modelProtocol(m) { case "openai": return oauthProviderOpenAI, true case "anthropic": @@ -147,18 +481,14 @@ func oauthProviderForModel(model string) (string, bool) { } } -func modelProtocol(model string) string { - protocol, _ := splitModel(model) +func modelProtocol(m *config.ModelConfig) string { + protocol, _ := splitModel(m) return protocol } -func splitModel(model string) (protocol, modelID string) { - model = strings.ToLower(strings.TrimSpace(model)) - protocol, _, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:]) +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 { @@ -189,7 +519,11 @@ func probeTCPService(raw string) bool { return false } - conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout) + ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout) + defer cancel() + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", hostPort) if err != nil { return false } @@ -244,7 +578,10 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool { } func getJSON(rawURL string, out any, apiKey string) error { - req, err := http.NewRequest(http.MethodGet, rawURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return err } @@ -252,7 +589,7 @@ func getJSON(rawURL string, out any, apiKey string) error { req.Header.Set("Authorization", "Bearer "+apiKey) } - client := &http.Client{Timeout: modelProbeTimeout} + client := &http.Client{} resp, err := client.Do(req) if err != nil { return err @@ -318,10 +655,29 @@ func ollamaModelMatches(candidate, want string) bool { if candidate == "" || want == "" { return false } - if strings.EqualFold(candidate, want) { - return true + + candidateBase, candidateTag := splitOllamaModel(candidate) + wantBase, wantTag := splitOllamaModel(want) + if candidateBase == "" || wantBase == "" { + return false } - base, _, _ := strings.Cut(candidate, ":") - return strings.EqualFold(base, want) + 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 index df942a9e9..d5463a856 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -3,7 +3,10 @@ package api import ( "net/http" "net/http/httptest" + "sync" + "sync/atomic" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" ) @@ -35,3 +38,357 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T 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 index 38a55948b..8a66918f9 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -6,10 +6,13 @@ import ( "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. @@ -26,23 +29,201 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { 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"` - ExtraBody map[string]any `json:"extra_body,omitempty"` + 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 - Configured bool `json:"configured"` - IsDefault bool `json:"is_default"` - IsVirtual bool `json:"is_virtual"` + 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. @@ -55,47 +236,59 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { 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() - configured := make([]bool, len(cfg.ModelList)) + 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() - configured[i] = isModelConfigured(m) + 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, - Model: m.Model, - 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, - ExtraBody: m.ExtraBody, - Configured: configured[i], - IsDefault: m.ModelName == defaultModel, - IsVirtual: m.IsVirtual(), + 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, + "models": models, + "total": len(models), + "default_model": defaultModel, + "provider_options": modelProviderOptionsForResponse(), }) } @@ -121,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { + normalizeIncomingModelConfig(&mc.ModelConfig) + + if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil { http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) return } @@ -137,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { } 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) @@ -170,6 +366,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } 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"` @@ -181,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { - http.Error(w, fmt.Sprintf("Validation error: %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) @@ -212,8 +409,61 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } 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) @@ -313,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) 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 diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 97f153a80..0b1f04848 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) func resetModelProbeHooks(t *testing.T) { @@ -20,14 +21,47 @@ func resetModelProbeHooks(t *testing.T) { 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 TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) { +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) @@ -90,7 +124,8 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes }, } cfg.Agents.Defaults.ModelName = "openai-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -109,29 +144,47 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } - got := make(map[string]bool, len(resp.Models)) + gotAvailable := make(map[string]bool, len(resp.Models)) + gotStatus := make(map[string]string, len(resp.Models)) for _, model := range resp.Models { - got[model.ModelName] = model.Configured + gotAvailable[model.ModelName] = model.Available + gotStatus[model.ModelName] = model.Status } - if got["openai-oauth"] { - t.Fatalf("openai oauth model configured = true, want false without stored credential") + if gotAvailable["openai-oauth"] { + t.Fatalf("openai oauth model available = true, want false without stored credential") } - if !got["vllm-local"] { - t.Fatalf("vllm local model configured = false, want true when local probe succeeds") + if !gotAvailable["vllm-local"] { + t.Fatalf("vllm local model available = false, want true when local probe succeeds") } - if !got["ollama-default"] { - t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") + if !gotAvailable["ollama-default"] { + t.Fatalf("ollama default model available = false, want true when default local probe succeeds") } - if !got["vllm-remote"] { - t.Fatalf("remote vllm model configured = false, want true with api_key") + if !gotAvailable["vllm-remote"] { + t.Fatalf("remote vllm model available = false, want true with api_key") } - if !got["copilot-gpt-5.4"] { - t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") + 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) @@ -144,7 +197,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes } } -func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) { +func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) @@ -160,14 +213,91 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "claude-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } - if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + 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) } @@ -187,14 +317,158 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + 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].Configured { - t.Fatalf("oauth model configured = false, want true with stored credential") + 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") } } @@ -297,6 +571,59 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { 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"` } @@ -306,11 +633,58 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } - if !resp.Models[0].Configured { - t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") + + 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 api base = %q, want %q", 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|") } } @@ -352,6 +726,1350 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) { } } +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. @@ -392,6 +2110,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) { } } +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 diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 213b53836..116e304b1 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { found := false for i := range cfg.ModelList { - if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { + if modelBelongsToProvider(provider, cfg.ModelList[i]) { cfg.ModelList[i].AuthMethod = authMethod found = true } @@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { return oauthSaveConfig(h.configPath, cfg) } -func modelBelongsToProvider(provider, model string) bool { - lower := strings.ToLower(strings.TrimSpace(model)) +func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) switch provider { case oauthProviderOpenAI: - return lower == "openai" || strings.HasPrefix(lower, "openai/") + return protocol == "openai" case oauthProviderAnthropic: - return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/") + return protocol == "anthropic" case oauthProviderGoogleAntigravity: - return lower == "antigravity" || - lower == "google-antigravity" || - strings.HasPrefix(lower, "antigravity/") || - strings.HasPrefix(lower, "google-antigravity/") + return protocol == "antigravity" || protocol == "google-antigravity" default: return false } @@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon case oauthProviderOpenAI: return &config.ModelConfig{ ModelName: "gpt-5.4", - Model: "openai/gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", AuthMethod: authMethod, } case oauthProviderAnthropic: return &config.ModelConfig{ ModelName: "claude-sonnet-4.6", - Model: "anthropic/claude-sonnet-4.6", + Provider: "anthropic", + Model: "claude-sonnet-4.6", AuthMethod: authMethod, } case oauthProviderGoogleAntigravity: return &config.ModelConfig{ ModelName: "gemini-flash", - Model: "antigravity/gemini-3-flash", + Provider: "antigravity", + Model: "gemini-3-flash", AuthMethod: authMethod, } default: diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go index 5aaff8d8f..9468c8873 100644 --- a/web/backend/api/oauth_test.go +++ b/web/backend/api/oauth_test.go @@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { } } +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() diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index a3f1a4ffb..8eeff4041 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -10,11 +10,13 @@ import ( "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/token", h.handleGetPicoToken) + mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) @@ -22,48 +24,191 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { // 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() *httputil.ReverseProxy { - wsProxy := httputil.NewSingleHostReverseProxy(h.gatewayProxyURL()) - wsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) +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 } -// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. -// The reverse proxy forwards the incoming upgrade handshake as-is. -func (h *Handler) handleWebSocketProxy() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - proxy := h.createWsProxy() - proxy.ServeHTTP(w, r) +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) + }, } } -// handleGetPicoToken returns the current WS token and URL for the frontend. +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/token -func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { +// 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 } - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": cfg.Channels.Pico.Token.String(), - "ws_url": wsURL, - "enabled": cfg.Channels.Pico.Enabled, - }) + h.writePicoInfoResponse(w, r, cfg, nil) } -// handleRegenPicoToken generates a new Pico WebSocket token and saves it. +// 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) { @@ -74,30 +219,30 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { } token := generateSecureToken() - cfg.Channels.Pico.SetToken(token) + 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 } - wsURL := h.buildWsURL(r) + gateway.mu.Lock() + gateway.picoToken = token + gateway.mu.Unlock() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": token, - "ws_url": wsURL, - }) + 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. -// -// callerOrigin is the Origin header from the setup request. If non-empty and -// no origins are configured yet, it's written as the allowed origin so the -// WebSocket handshake works for whatever host the caller is on (LAN, custom -// port, etc.). Pass "" when there's no request context. -func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { +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) @@ -105,20 +250,24 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { changed := false - if !cfg.Channels.Pico.Enabled { - cfg.Channels.Pico.Enabled = true + 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 cfg.Channels.Pico.Token.String() == "" { - cfg.Channels.Pico.SetToken(generateSecureToken()) - changed = true - } - - // Seed origins from the request instead of hardcoding ports. - if len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != "" { - cfg.Channels.Pico.AllowOrigins = []string{callerOrigin} - 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 { @@ -134,27 +283,20 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { - changed, err := h.EnsurePicoChannel(r.Header.Get("Origin")) + 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 } - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": cfg.Channels.Pico.Token.String(), - "ws_url": wsURL, - "enabled": true, - "changed": changed, - }) + h.writePicoInfoResponse(w, r, cfg, &changed) } // generateSecureToken creates a random 32-character hex string. @@ -162,7 +304,7 @@ 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("pico_%x", time.Now().UnixNano()) + 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 index aa377975d..6f7cefd4d 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -9,16 +9,24 @@ import ( "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("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -31,10 +39,16 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if !cfg.Channels.Pico.Enabled { + 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 cfg.Channels.Pico.Token.String() == "" { + if picoCfg.Token.String() == "" { t.Error("expected a non-empty token after setup") } } @@ -43,7 +57,7 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -52,16 +66,22 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if cfg.Channels.Pico.AllowTokenQuery { + 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_DoesNotSetWildcardOrigins(t *testing.T) { +func TestEnsurePicoChannel_LeavesAllowOriginsEmptyByDefault(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -70,18 +90,22 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - for _, origin := range cfg.Channels.Pico.AllowOrigins { - if origin == "*" { - t.Error("setup must not set wildcard origin '*'") - } + 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_NoOriginWithoutCaller(t *testing.T) { +func TestEnsurePicoChannel_NoOriginConfigurationRequired(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -90,29 +114,14 @@ func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - // Without a caller origin, allow_origins stays empty (CheckOrigin - // allows all when the list is empty, so the channel still works). - if len(cfg.Channels.Pico.AllowOrigins) != 0 { - t.Errorf("allow_origins = %v, want empty when no caller origin", cfg.Channels.Pico.AllowOrigins) - } -} - -func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - lanOrigin := "http://192.168.1.9:18800" - if _, err := h.EnsurePicoChannel(lanOrigin); err != nil { - t.Fatalf("EnsurePicoChannel() error = %v", err) - } - - cfg, err := config.LoadConfig(configPath) + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() if err != nil { - t.Fatalf("LoadConfig() error = %v", err) + t.Fatalf("GetDecoded() error = %v", err) } - - if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != lanOrigin { - t.Errorf("allow_origins = %v, want [%s]", cfg.Channels.Pico.AllowOrigins, lanOrigin) + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } @@ -121,17 +130,23 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { // Pre-configure with custom user settings cfg := config.DefaultConfig() - cfg.Channels.Pico.Enabled = true - cfg.Channels.Pico.SetToken("user-custom-token") - cfg.Channels.Pico.AllowTokenQuery = true - cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"} - if err := config.SaveConfig(configPath, cfg); err != nil { + 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("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -144,14 +159,20 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if cfg.Channels.Pico.Token.String() != "user-custom-token" { - t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token.String(), "user-custom-token") + bc = cfg.Channels["pico"] + decoded, err = bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) } - if !cfg.Channels.Pico.AllowTokenQuery { + 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(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "https://myapp.example.com" { - t.Errorf("allow_origins = %v, want [https://myapp.example.com]", cfg.Channels.Pico.AllowOrigins) + if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != "https://myapp.example.com" { + t.Errorf("allow_origins = %v, want [https://myapp.example.com]", picoCfg.AllowOrigins) } } @@ -169,7 +190,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -182,10 +203,16 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if !cfg.Channels.Pico.Enabled { + 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 cfg.Channels.Pico.Token.String() == "" { + 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 { @@ -203,7 +230,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { } h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -212,10 +239,16 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if !cfg.Channels.Pico.Enabled { + 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 cfg.Channels.Pico.Token.String() == "" { + if picoCfg.Token.String() == "" { t.Error("expected a non-empty token after launcher startup setup") } } @@ -224,18 +257,22 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - origin := "http://localhost:18800" - // First call sets things up - if _, err := h.EnsurePicoChannel(origin); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("first EnsurePicoChannel() error = %v", err) } cfg1, _ := config.LoadConfig(configPath) - token1 := cfg1.Channels.Pico.Token.String() + 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(origin) + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("second EnsurePicoChannel() error = %v", err) } @@ -244,12 +281,18 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } cfg2, _ := config.LoadConfig(configPath) - if cfg2.Channels.Pico.Token.String() != token1 { + 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_IncludesRequestOrigin(t *testing.T) { +func TestHandlePicoSetup_DoesNotPersistRequestOrigin(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -268,8 +311,14 @@ func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "http://10.0.0.5:3000" { - t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", cfg.Channels.Pico.AllowOrigins) + 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) } } @@ -291,8 +340,8 @@ func TestHandlePicoSetup_Response(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - if resp["token"] == nil || resp["token"] == "" { - t.Error("response should contain a non-empty token") + 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") @@ -303,9 +352,107 @@ func TestHandlePicoSetup_Response(t *testing.T) { 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() @@ -334,8 +481,30 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { 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 + }) - req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "pico" + req1 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec1 := httptest.NewRecorder() handler(rec1, req1) @@ -351,7 +520,7 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { t.Fatalf("SaveConfig() error = %v", err) } - req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + req2 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec2 := httptest.NewRecorder() handler(rec2, req2) @@ -363,6 +532,428 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { } } +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() @@ -378,3 +969,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int { 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 index ce652d4c4..76f63607e 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "sync" "github.com/sipeed/picoclaw/web/backend/launcherconfig" @@ -13,7 +14,10 @@ type Handler struct { 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 @@ -40,9 +44,25 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a 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 @@ -76,6 +96,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // 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) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 42d451a05..cc18ee6e1 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -13,7 +13,11 @@ import ( "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. @@ -42,52 +46,58 @@ type sessionListItem struct { Updated string `json:"updated"` } -type sessionMetaFile 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"` +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"` } -// picoSessionPrefix is the key prefix used by the gateway's routing for Pico -// channel sessions. The full key format is: -// -// agent:main:pico:direct:pico: -// -// The sanitized filename replaces ':' with '_', so on disk it becomes: -// -// agent_main_pico_direct_pico_.json +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 ( - picoSessionPrefix = "agent:main:pico:direct:pico:" - sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_" - maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB - maxSessionTitleRunes = 60 + 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." ) -// extractPicoSessionID extracts the session UUID from a full session key. -// Returns the UUID and true if the key matches the Pico session pattern. -func extractPicoSessionID(key string) (string, bool) { - if strings.HasPrefix(key, picoSessionPrefix) { - return strings.TrimPrefix(key, picoSessionPrefix), true - } - return "", false +func defaultToolFeedbackMaxArgsLength() int { + defaults := config.AgentDefaults{} + return defaults.GetToolFeedbackMaxArgsLength() } -func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) { - if strings.HasPrefix(key, sanitizedPicoSessionPrefix) { - return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true +// 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 { - return strings.ReplaceAll(key, ":", "_") + key = strings.ReplaceAll(key, ":", "_") + key = strings.ReplaceAll(key, "/", "_") + key = strings.ReplaceAll(key, "\\", "_") + return key } -func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) { - path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json") +func (h *Handler) readLegacySession(path string) (sessionFile, error) { data, err := os.ReadFile(path) if err != nil { return sessionFile{}, err @@ -100,18 +110,18 @@ func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) return sess, nil } -func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) { +func (h *Handler) readSessionMeta(path, sessionKey string) (memory.SessionMeta, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { - return sessionMetaFile{Key: sessionKey}, nil + return memory.SessionMeta{Key: sessionKey}, nil } if err != nil { - return sessionMetaFile{}, err + return memory.SessionMeta{}, err } - var meta sessionMetaFile + var meta memory.SessionMeta if err := json.Unmarshal(data, &meta); err != nil { - return sessionMetaFile{}, err + return memory.SessionMeta{}, err } if meta.Key == "" { meta.Key = sessionKey @@ -146,6 +156,9 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag if err := json.Unmarshal(line, &msg); err != nil { continue } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } msgs = append(msgs, msg) } if err := scanner.Err(); err != nil { @@ -154,8 +167,7 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag return msgs, nil } -func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { - sessionKey := picoSessionPrefix + sessionID +func (h *Handler) readJSONLSession(dir, sessionKey string) (sessionFile, error) { base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) jsonlPath := base + ".jsonl" metaPath := base + ".meta.json" @@ -192,41 +204,237 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { }, nil } -func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { +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 sess.Messages { - if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { - preview = msg.Content + for _, msg := range transcript { + if msg.Role == "user" { + preview = sessionChatMessagePreview(msg) + } + if preview != "" { break } } - title := strings.TrimSpace(sess.Summary) - if title == "" { - title = preview - } - - title = truncateRunes(title, maxSessionTitleRunes) preview = truncateRunes(preview, maxSessionTitleRunes) if preview == "" { preview = "(empty)" } - if title == "" { - title = preview - } - - validMessageCount := 0 - for _, msg := range sess.Messages { - if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { - validMessageCount++ - } - } + title := preview return sessionListItem{ ID: sessionID, Title: title, Preview: preview, - MessageCount: validMessageCount, + MessageCount: len(transcript), Created: sess.Created.Format(time.RFC3339), Updated: sess.Updated.Format(time.RFC3339), } @@ -247,6 +455,306 @@ func truncateRunes(s string, maxLen int) string { 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) { @@ -255,7 +763,19 @@ func (h *Handler) sessionsDir() (string, error) { return "", err } - workspace := cfg.Agents.Defaults.Workspace + 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") @@ -271,21 +791,20 @@ func (h *Handler) sessionsDir() (string, error) { } } - return filepath.Join(workspace, "sessions"), nil + 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, err := h.sessionsDir() + dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } - entries, err := os.ReadDir(dir) - if err != nil { + 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{}) @@ -295,74 +814,29 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { items := []sessionListItem{} seen := make(map[string]struct{}) - for _, entry := range entries { - if entry.IsDir() { - continue + 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)) } + } - name := entry.Name() - var ( - sessionID string - sess sessionFile - loadErr error - ok bool - ) - - switch { - case strings.HasSuffix(name, ".jsonl"): - sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl")) - if !ok { + if legacyRefs, findErr := h.findLegacyPicoSessions(dir); findErr == nil { + for _, ref := range legacyRefs { + if _, exists := seen[ref.ID]; exists { continue } - sess, loadErr = h.readJSONLSession(dir, sessionID) - if loadErr == nil && isEmptySession(sess) { + sess, loadErr := h.readLegacySession(ref.Path) + if loadErr != nil || isEmptySession(sess) { continue } - case strings.HasSuffix(name, ".meta.json"): - continue - case filepath.Ext(name) == ".json": - base := strings.TrimSuffix(name, ".json") - if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { - if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found { - if jsonlSess, jsonlErr := h.readJSONLSession( - dir, - jsonlSessionID, - ); jsonlErr == nil && - !isEmptySession(jsonlSess) { - continue - } - } - } - data, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - continue - } - if err := json.Unmarshal(data, &sess); err != nil { - continue - } - if isEmptySession(sess) { - continue - } - sessionID, ok = extractPicoSessionID(sess.Key) - if !ok { - continue - } - if _, exists := seen[sessionID]; exists { - continue - } - default: - continue + seen[ref.ID] = struct{}{} + items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength)) } - - if loadErr != nil { - continue - } - if _, exists := seen[sessionID]; exists { - continue - } - - seen[sessionID] = struct{}{} - items = append(items, buildSessionListItem(sessionID, sess)) } // Sort by updated descending (most recent first) @@ -410,19 +884,26 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { return } - dir, err := h.sessionsDir() + dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } - sess, err := h.readJSONLSession(dir, sessionID) + 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) { - sess, err = h.readLegacySession(dir, sessionID) + if legacyRef, legacyErr := h.findLegacyPicoSession(dir, sessionID); legacyErr == nil { + sess, err = h.readLegacySession(legacyRef.Path) + } if err == nil && isEmptySession(sess) { err = os.ErrNotExist } @@ -437,22 +918,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { } } - // Convert to a simpler format for the frontend - type chatMessage struct { - Role string `json:"role"` - Content string `json:"content"` - } - - messages := make([]chatMessage, 0, len(sess.Messages)) - for _, msg := range sess.Messages { - // Only include user and assistant messages that have actual content - if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { - messages = append(messages, chatMessage{ - Role: msg.Role, - Content: msg.Content, - }) - } - } + messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -480,21 +946,30 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { return } - base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) - jsonlPath := base + ".jsonl" - metaPath := base + ".meta.json" - legacyPath := base + ".json" - removed := false - for _, path := range []string{jsonlPath, metaPath, legacyPath} { - if err := os.Remove(path); err != nil { - if os.IsNotExist(err) { - continue + 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 } - 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 } - removed = true } if !removed { diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index 21ef5b5b8..760935db7 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -6,12 +6,15 @@ import ( "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 { @@ -29,17 +32,36 @@ func sessionsTestDir(t *testing.T, configPath string) string { 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, err := memory.NewJSONLStore(dir) - if err != nil { - t.Fatalf("NewJSONLStore() error = %v", err) + store, storeErr := memory.NewJSONLStore(dir) + if storeErr != nil { + t.Fatalf("NewJSONLStore() error = %v", storeErr) } - sessionKey := picoSessionPrefix + "history-jsonl" + sessionKey := legacyPicoSessionPrefix + "history-jsonl" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "Explain why the history API is empty after migration.", @@ -87,25 +109,87 @@ func TestHandleListSessions_JSONLStorage(t *testing.T) { if items[0].MessageCount != 2 { t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) } - if items[0].Title != "JSONL-backed session" { - t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session") + 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_TitleUsesTrimmedSummary(t *testing.T) { +func TestHandleListSessions_TransientThoughtDoesNotInflateMessageCount(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) - store, err := memory.NewJSONLStore(dir) + 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("NewJSONLStore() error = %v", err) + 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) } - sessionKey := picoSessionPrefix + "summary-title" + 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", @@ -139,10 +223,7 @@ func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) { if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } - expectedTitle := truncateRunes( - "This summary is intentionally longer than sixty characters so it must be truncated in the history menu.", - maxSessionTitleRunes, - ) + expectedTitle := truncateRunes("fallback preview", maxSessionTitleRunes) if items[0].Title != expectedTitle { t.Fatalf("items[0].Title = %q", items[0].Title) } @@ -161,7 +242,7 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { t.Fatalf("NewJSONLStore() error = %v", err) } - sessionKey := picoSessionPrefix + "detail-jsonl" + sessionKey := legacyPicoSessionPrefix + "detail-jsonl" for _, msg := range []providers.Message{ {Role: "user", Content: "first"}, {Role: "assistant", Content: "second"}, @@ -215,6 +296,1275 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { } } +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() @@ -225,7 +1575,7 @@ func TestHandleDeleteSession_JSONLStorage(t *testing.T) { t.Fatalf("NewJSONLStore() error = %v", err) } - sessionKey := picoSessionPrefix + "delete-jsonl" + sessionKey := legacyPicoSessionPrefix + "delete-jsonl" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "delete me", @@ -262,7 +1612,7 @@ func TestHandleGetSession_LegacyJSONFallback(t *testing.T) { dir := sessionsTestDir(t, configPath) manager := session.NewSessionManager(dir) - sessionKey := picoSessionPrefix + "legacy-json" + sessionKey := legacyPicoSessionPrefix + "legacy-json" manager.AddMessage(sessionKey, "user", "legacy user") manager.AddMessage(sessionKey, "assistant", "legacy assistant") if err := manager.Save(sessionKey); err != nil { @@ -287,7 +1637,7 @@ func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) { defer cleanup() dir := sessionsTestDir(t, configPath) - base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl")) + 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) } @@ -320,3 +1670,82 @@ func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) { 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 index 3c2fb57dd..e89ff7c30 100644 --- a/web/backend/api/skills.go +++ b/web/backend/api/skills.go @@ -1,40 +1,116 @@ 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 []skills.SkillInfo `json:"skills"` + 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 { - Name string `json:"name"` - Path string `json:"path"` - Source string `json:"source"` - Description string `json:"description"` - Content string `json:"content"` + 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) } @@ -46,11 +122,15 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) { return } - loader := newSkillsLoader(cfg.WorkspacePath()) + 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: loader.ListSkills(), + Skills: items, }) } @@ -61,16 +141,18 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { return } - loader := newSkillsLoader(cfg.WorkspacePath()) + 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") - allSkills := loader.ListSkills() - - for _, skill := range allSkills { - if skill.Name != name { + for _, skillItem := range skillItems { + if skillItem.Name != name { continue } - content, err := loadSkillContent(skill.Path) + content, err := loadSkillContent(skillItem.Path) if err != nil { http.Error(w, "Skill content not found", http.StatusNotFound) return @@ -78,11 +160,8 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(skillDetailResponse{ - Name: skill.Name, - Path: skill.Path, - Source: skill.Source, - Description: skill.Description, - Content: content, + skillSupportItem: skillItem, + Content: content, }) return } @@ -90,6 +169,276 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { 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 { @@ -110,54 +459,26 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { } defer uploadedFile.Close() - content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1)) + 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) > 1<<20 { + if len(content) > maxImportedSkillSize { http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest) return } + workspaceSkillWriteMu.Lock() + defer workspaceSkillWriteMu.Unlock() - skillName, err := normalizeImportedSkillName(fileHeader.Filename, content) + importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), statusCode) return } - content = normalizeImportedSkillContent(content, skillName) - - workspace := cfg.WorkspacePath() - skillDir := filepath.Join(workspace, "skills", skillName) - skillFile := filepath.Join(skillDir, "SKILL.md") - if _, err := os.Stat(skillDir); err == nil { - http.Error(w, "skill already exists", http.StatusConflict) - return - } - - if err := os.MkdirAll(skillDir, 0o755); err != nil { - http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError) - return - } - if err := os.WriteFile(skillFile, content, 0o644); err != nil { - http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError) - return - } - - loader := newSkillsLoader(workspace) - for _, skill := range loader.ListSkills() { - if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(skill) - return - } - } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "name": skillName, - "path": skillFile, - }) + json.NewEncoder(w).Encode(importedSkill) } func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { @@ -169,13 +490,17 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { 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" { - http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest) - return + 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) @@ -185,6 +510,10 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { 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) } @@ -197,12 +526,263 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader { ) } +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))) } @@ -259,6 +839,210 @@ func normalizeImportedSkillContent(content []byte, skillName string) []byte { 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 { @@ -309,14 +1093,7 @@ func loadSkillContent(path string) (string, error) { } func globalConfigDir() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".picoclaw") + return config.GetHome() } func builtinSkillsDir() string { diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go index 3289d5b33..977ec693f 100644 --- a/web/backend/api/skills_test.go +++ b/web/backend/api/skills_test.go @@ -1,19 +1,40 @@ 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() @@ -99,8 +120,10 @@ func TestHandleListSkills(t *testing.T) { } 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"]) @@ -111,6 +134,15 @@ func TestHandleListSkills(t *testing.T) { 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) { @@ -162,6 +194,9 @@ func TestHandleGetSkill(t *testing.T) { 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) } @@ -271,6 +306,17 @@ func TestHandleImportSkill(t *testing.T) { 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) @@ -293,6 +339,174 @@ func TestHandleImportSkill(t *testing.T) { } } +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() @@ -334,3 +548,1316 @@ func TestHandleDeleteSkill(t *testing.T) { 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 index 1c685bc90..8a3b8e8ff 100644 --- a/web/backend/api/startup.go +++ b/web/backend/api/startup.go @@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) { } args := []string{"-no-browser"} + if h.debug { + args = append(args, "-d") + } if h.configPath != "" { args = append(args, h.configPath) } diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go index cfa9b4c53..c224d36e2 100644 --- a/web/backend/api/startup_test.go +++ b/web/backend/api/startup_test.go @@ -45,6 +45,29 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) { } } +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") { diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index 9df4a7091..3476e3c53 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -5,8 +5,10 @@ import ( "fmt" "net/http" "runtime" + "strings" "github.com/sipeed/picoclaw/pkg/config" + picotools "github.com/sipeed/picoclaw/pkg/tools" ) type toolCatalogEntry struct { @@ -33,6 +35,39 @@ 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", @@ -136,6 +171,12 @@ var toolCatalog = []toolCatalogEntry{ 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.", @@ -153,6 +194,8 @@ var toolCatalog = []toolCatalogEntry{ 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) { @@ -224,8 +267,12 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem { 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" @@ -254,6 +301,18 @@ func resolveHardwareToolSupport(enabled bool) (string, string) { 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", "" @@ -267,6 +326,13 @@ func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string 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": @@ -316,6 +382,8 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error { 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 { @@ -333,3 +401,274 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error { } 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 index 646cefbe2..a09a49fd6 100644 --- a/web/backend/api/tools_test.go +++ b/web/backend/api/tools_test.go @@ -92,9 +92,36 @@ func TestHandleListTools(t *testing.T) { 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) } @@ -120,6 +147,16 @@ func TestHandleListTools(t *testing.T) { 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"]) + } + } } } @@ -195,4 +232,373 @@ func TestHandleUpdateToolState(t *testing.T) { 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 index 7dcec9f49..74e5d8e83 100644 --- a/web/backend/api/wecom.go +++ b/web/backend/api/wecom.go @@ -216,11 +216,19 @@ func (h *Handler) saveWecomBinding(botID, secret string) error { return fmt.Errorf("load config: %w", err) } - cfg.Channels.WeCom.Enabled = true - cfg.Channels.WeCom.BotID = botID - cfg.Channels.WeCom.SetSecret(secret) - if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" { - cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL + 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 diff --git a/web/backend/api/weixin.go b/web/backend/api/weixin.go index 808b88c41..888789f86 100644 --- a/web/backend/api/weixin.go +++ b/web/backend/api/weixin.go @@ -210,11 +210,26 @@ func (h *Handler) saveWeixinBinding(token, accountID string) error { if err != nil { return fmt.Errorf("load config: %w", err) } - cfg.Channels.Weixin.SetToken(token) - cfg.Channels.Weixin.Enabled = true - if accountID != "" { - cfg.Channels.Weixin.AccountID = accountID + + 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 } diff --git a/web/backend/api/weixin_test.go b/web/backend/api/weixin_test.go index ce54eec16..575de7b9c 100644 --- a/web/backend/api/weixin_test.go +++ b/web/backend/api/weixin_test.go @@ -44,13 +44,19 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := savedCfg.Channels.Weixin.Token.String(); got != "bot-token" { + 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 := savedCfg.Channels.Weixin.AccountID; got != "bot-account" { + if got := wxCfg.AccountID; got != "bot-account" { t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account") } - if !savedCfg.Channels.Weixin.Enabled { + if !bc.Enabled { t.Fatalf("Weixin.Enabled = false, want true") } } diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go index ab564db2c..a06396526 100644 --- a/web/backend/app_runtime.go +++ b/web/backend/app_runtime.go @@ -34,22 +34,30 @@ func shutdownApp() { apiHandler.Shutdown() } - if server != nil { - // Disable keep-alive to allow graceful shutdown - server.SetKeepAlivesEnabled(false) - - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - if err := server.Shutdown(ctx); 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) + 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") } - } else { - logger.Infof("Server shutdown completed successfully") } } } 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/i18n.go b/web/backend/i18n.go index 106df8506..9cda9e5d5 100644 --- a/web/backend/i18n.go +++ b/web/backend/i18n.go @@ -24,8 +24,6 @@ const ( AppTooltip TranslationKey = "AppTooltip" MenuOpen TranslationKey = "MenuOpen" MenuOpenTooltip TranslationKey = "MenuOpenTooltip" - MenuCopyToken TranslationKey = "MenuCopyToken" - MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint" MenuAbout TranslationKey = "MenuAbout" MenuAboutTooltip TranslationKey = "MenuAboutTooltip" MenuVersion TranslationKey = "MenuVersion" @@ -49,8 +47,6 @@ var translations = map[Language]map[TranslationKey]string{ AppTooltip: "%s - Web Console", MenuOpen: "Open Console", MenuOpenTooltip: "Open PicoClaw console in browser", - MenuCopyToken: "Copy dashboard token", - MenuCopyTokenHint: "Copy the current web console access token to the clipboard", MenuAbout: "About", MenuAboutTooltip: "About PicoClaw", MenuVersion: "Version: %s", @@ -68,8 +64,6 @@ var translations = map[Language]map[TranslationKey]string{ AppTooltip: "%s - Web Console", MenuOpen: "打开控制台", MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台", - MenuCopyToken: "复制控制台口令", - MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板", MenuAbout: "关于", MenuAboutTooltip: "关于 PicoClaw", MenuVersion: "版本: %s", diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index b8465ef74..e3595738f 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -1,8 +1,6 @@ package launcherconfig import ( - "crypto/rand" - "encoding/base64" "encoding/json" "fmt" "net" @@ -16,18 +14,19 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 - - // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits). - dashboardSigningKeyBytes = 32 - // dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits). - dashboardTokenEntropyBytes = 32 + // 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"` + 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. @@ -48,34 +47,6 @@ func Validate(cfg Config) error { return nil } -// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this -// process. The signing key is freshly random each call; the token comes from the environment -// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token. -func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) { - signingKey = make([]byte, dashboardSigningKeyBytes) - if _, err = rand.Read(signingKey); err != nil { - return "", nil, false, err - } - - effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN")) - if effectiveToken != "" { - return effectiveToken, signingKey, false, nil - } - tok, genErr := randomDashboardToken() - if genErr != nil { - return "", nil, false, genErr - } - return tok, signingKey, true, nil -} - -func randomDashboardToken() (string, error) { - buf := make([]byte, dashboardTokenEntropyBytes) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buf), nil -} - // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. func NormalizeCIDRs(cidrs []string) []string { if len(cidrs) == 0 { @@ -124,6 +95,8 @@ func Load(path string, fallback Config) (Config, error) { 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 } @@ -133,6 +106,8 @@ func Load(path string, fallback Config) (Config, error) { // 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 } diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index 4e8a54e41..bb13ea115 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -1,11 +1,10 @@ package launcherconfig import ( + "context" "os" "path/filepath" "testing" - - "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestLoadReturnsFallbackWhenMissing(t *testing.T) { @@ -25,9 +24,11 @@ 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"}, + 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 { @@ -40,6 +41,12 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { 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)) } @@ -58,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { } } +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") @@ -77,51 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } -func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - - tok, key, newTok, err := EnsureDashboardSecrets() - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes { - t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key)) - } - mac := middleware.SessionCookieValue(key, tok) - if mac == "" { - t.Fatal("empty session mac") - } - - tok2, key2, newTok2, err := EnsureDashboardSecrets() - if err != nil { - t.Fatalf("EnsureDashboardSecrets() second error = %v", err) - } - if !newTok2 { - t.Fatal("second call without env should generate another random token") - } - if tok2 == tok { - t.Fatal("expected a new random dashboard token") - } - if string(key2) == string(key) { - t.Fatal("expected a new signing key") - } -} - -func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override") - - tok, _, newTok, err := EnsureDashboardSecrets() - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if tok != "env-only-token-override" { - t.Fatalf("token = %q, want env value", tok) - } - if newTok { - t.Fatal("newRandomDashboardToken should be false when env is set") - } -} - 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"} @@ -134,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) { } } } + +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 index c58e97361..fa2448d5c 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -12,21 +12,25 @@ package main import ( + "context" "errors" "flag" "fmt" + "net" "net/http" - "net/url" "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" @@ -43,40 +47,332 @@ const ( var ( appVersion = config.Version - server *http.Server + servers []*http.Server serverAddr string // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). - // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use. browserLaunchURL string apiHandler *api.Handler - // launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode). - launcherDashboardTokenForClipboard string 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") - public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") + 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 - A web-based configuration editor\n\n", appName) + 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 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\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 Allow access from other devices on the network\n", + " %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() @@ -90,12 +386,13 @@ func main() { } defer panicFunc() - // By default, detect terminal to decide console log behavior - // If -console-logs flag is explicitly set, it overrides the detection enableConsole := *console - if !enableConsole { - // Disable console logging by setting level to Fatal (no output) - logger.SetConsoleLevel(logger.FATAL) + 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 { @@ -103,9 +400,9 @@ func main() { } defer logger.DisableFileLogging() } - - 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.SetLevel(logger.DEBUG) + } // Set language from command line or auto-detect if *lang != "" { @@ -126,13 +423,36 @@ func main() { 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 } @@ -153,6 +473,23 @@ func main() { 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 { @@ -162,45 +499,93 @@ func main() { logger.Fatalf("Invalid port %q: %v", effectivePort, err) } - dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets() + 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) } - dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) - launcherDashboardTokenForClipboard = dashboardToken - // Determine listen address - var addr string - if effectivePublic { - addr = "0.0.0.0:" + effectivePort + // 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 { - addr = "127.0.0.1:" + effectivePort + 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() - tokenLogFileAbs := "" - if !enableConsole { - tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile) - } api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ - DashboardToken: dashboardToken, - SessionCookie: dashboardSessionCookie, - TokenHelp: api.LauncherAuthTokenHelp{ - EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", - LogFileAbs: tokenLogFileAbs, - TrayCopyMenu: trayOffersDashboardTokenCopy(), - ConsoleStdout: enableConsole, - }, + SessionCookie: dashboardSessionCookie, + PasswordStore: passwordStore, + StoreError: authStoreErr, }) // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) - if _, err = apiHandler.EnsurePicoChannel(""); err != nil { + 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 @@ -213,7 +598,7 @@ func main() { dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ ExpectedCookie: dashboardSessionCookie, - Token: dashboardToken, + LocalAutoLogin: localAutoLogin, }, accessControlledMux) // Apply middleware stack @@ -225,49 +610,41 @@ func main() { ), ) - // Print startup banner and token (console mode only). - if enableConsole { + // Print startup banner (console mode only). + if enableConsole || debug { + consoleHosts := launcherConsoleHosts(hostInput, effectivePublic) + fmt.Print(utils.Banner) fmt.Println() - fmt.Println(" Open the following URL in your browser:") - fmt.Println() - fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) - if effectivePublic { - if ip := utils.GetLocalIP(); ip != "" { - fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) + 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() - if newDashTok { - fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken) - } else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { - fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken) - } - fmt.Println() - } - - if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { - logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN") - } - if !enableConsole && newDashTok { - logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) } // Log startup info to file - logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort)) - if effectivePublic { - if ip := utils.GetLocalIP(); ip != "" { - logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort)) + 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://localhost:%s", effectivePort) - if dashboardToken != "" { - browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) - } else { - browserLaunchURL = serverAddr - } + 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. @@ -277,14 +654,19 @@ func main() { apiHandler.TryAutoStartGateway() }() - // Start the Server in a goroutine - server = &http.Server{Addr: addr, Handler: handler} - go func() { - logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr)) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatalf("Server failed to start: %v", err) - } - }() + // 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() 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/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index 7e92fca22..fd59958a9 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -1,41 +1,88 @@ package middleware import ( - "crypto/hmac" - "crypto/sha256" + "crypto/rand" "crypto/subtle" - "encoding/hex" + "encoding/base64" + "errors" "net/http" + "net/url" "path" "strings" + "sync" "time" ) -// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login. +// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login. const LauncherDashboardCookieName = "picoclaw_launcher_auth" -// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days). -const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600 +// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days). +const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600 -const launcherSessionMACLabel = "picoclaw-launcher-v1" +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" +) -// SessionCookieValue is the expected cookie value for the given signing key and dashboard token. -func SessionCookieValue(signingKey []byte, dashboardToken string) string { - mac := hmac.New(sha256.New, signingKey) - _, _ = mac.Write([]byte(launcherSessionMACLabel)) - _, _ = mac.Write([]byte{0}) - _, _ = mac.Write([]byte(dashboardToken)) - return hex.EncodeToString(mac.Sum(nil)) +// 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 - Token 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 { @@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } -// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login. +// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login. func SetLauncherDashboardSessionCookie( w http.ResponseWriter, r *http.Request, @@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, }) } -// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer -// before calling next. Public paths are login page and /api/auth/* handlers. +// 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 handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled { + if p == LauncherDashboardLocalAutoLoginPath { + handleLauncherLocalAutoLogin(w, r, cfg) return } if isPublicLauncherDashboardPath(r.Method, p) { @@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h // canonicalAuthPath matches path cleaning used for routing decisions so // prefixes like /assets/../ cannot bypass auth (CVE-class traversal). -// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session -// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects. -// Invalid token is rejected like any other unauthenticated browser request. -func tryLauncherQueryTokenLogin( - w http.ResponseWriter, - r *http.Request, - canonicalPath string, - cfg LauncherDashboardAuthConfig, -) bool { - if r.Method != http.MethodGet { - return false +func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) { + if validLauncherDashboardAuth(r, cfg) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return } - if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") { - return false + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte("method not allowed")) + return } - qToken := strings.TrimSpace(r.URL.Query().Get("token")) - if qToken == "" { - return false + if r.Method == http.MethodHead { + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) + return } - if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 { - rejectLauncherDashboardAuth(w, r, canonicalPath) - return true + 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 } - SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) - http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther) - return true + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) } -func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string { - if canonicalPath == "/launcher-login" { - return "/" +func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool { + if a == nil || a.grant == nil { + return false } - q := r.URL.Query() - q.Del("token") - enc := q.Encode() - if enc != "" { - return canonicalPath + "?" + enc + 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 canonicalPath + 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 { @@ -173,6 +260,8 @@ func isPublicLauncherDashboardPath(method, p string) bool { return method == http.MethodPost case "/api/auth/status": return method == http.MethodGet + case "/api/auth/setup": + return method == http.MethodPost } return false } @@ -183,7 +272,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool { if method != http.MethodGet && method != http.MethodHead { return false } - if p == "/launcher-login" { + if p == "/launcher-login" || p == "/launcher-setup" { return true } if strings.HasPrefix(p, "/assets/") { @@ -204,18 +293,14 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig return true } } - auth := r.Header.Get("Authorization") - const prefix = "Bearer " - if strings.HasPrefix(auth, prefix) { - token := strings.TrimSpace(auth[len(prefix):]) - if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 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) diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go index 1b919bf96..871b6f607 100644 --- a/web/backend/middleware/launcher_dashboard_auth_test.go +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -4,26 +4,37 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) -func TestSessionCookieValue_Deterministic(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = byte(i) +func TestNewLauncherDashboardSessionCookie(t *testing.T) { + a, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err) } - a := SessionCookieValue(key, "tok-a") - b := SessionCookieValue(key, "tok-a") - if a != b || a == "" { - t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b) + b, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err) } - c := SessionCookieValue(key, "tok-b") - if c == a { - t.Fatal("SessionCookieValue should differ for different tokens") + 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", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) @@ -34,12 +45,15 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { 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) @@ -50,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { } } -func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) { - const tok = "secret" - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok} +func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusTeapot) + t.Fatal("next handler should not run without session cookie") }) h := LauncherDashboardAuth(cfg, next) rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil) + req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil) h.ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther) + 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")) } - if got := rec.Header().Get("Location"); got != "/" { - t.Fatalf("Location = %q, want %q", got, "/") +} + +func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) { + const cookieVal = "session-cookie-value" + autoLogin := mustLocalAutoLogin(t, time.Minute) + cfg := LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, } - if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName { - t.Fatalf("expected one session cookie, got %#v", c) + 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(), + ) } - rec1b := httptest.NewRecorder() - req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil) - h.ServeHTTP(rec1b, req1b) - if rec1b.Code != http.StatusSeeOther { - t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code) - } - if got := rec1b.Header().Get("Location"); got != "/config?keep=1" { - t.Fatalf("Location = %q, want /config?keep=1", got) + 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(), + ) } - recBad := httptest.NewRecorder() - reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil) - h.ServeHTTP(recBad, reqBad) - if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" { - t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location")) + 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(), + ) } - rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil) - h.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusUnauthorized { - t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized) + 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) } - rec3 := httptest.NewRecorder() - req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil) - h.ServeHTTP(rec3, req3) - if rec3.Code != http.StatusFound { - t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code) + 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) } - recLogin := httptest.NewRecorder() - reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil) - h.ServeHTTP(recLogin, reqLogin) - if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" { - t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location")) + 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", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("next handler should not run without auth") }) @@ -131,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { } } -func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = 0xab - } - token := "dashboard-secret-9" - cookieVal := SessionCookieValue(key, token) - cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token} +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) }) @@ -153,10 +237,29 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { } rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/", nil) - req2.Header.Set("Authorization", "Bearer "+token) + req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req2.Header.Set("Authorization", "Bearer dashboard-secret-9") h.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusOK { - t.Fatalf("bearer auth: status = %d", rec2.Code) + 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 index 5e0dfeb90..f9eb3149d 100644 --- a/web/backend/middleware/middleware.go +++ b/web/backend/middleware/middleware.go @@ -1,7 +1,9 @@ package middleware import ( + "bufio" "fmt" + "net" "net/http" "runtime/debug" "time" @@ -44,6 +46,15 @@ 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) { @@ -60,6 +71,7 @@ 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) } diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go index 5ac066614..6cb14669d 100644 --- a/web/backend/middleware/referrer_policy.go +++ b/web/backend/middleware/referrer_policy.go @@ -2,8 +2,8 @@ package middleware import "net/http" -// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive -// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header. +// 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") diff --git a/web/backend/systray.go b/web/backend/systray.go index 744ea4611..41fea1fbe 100644 --- a/web/backend/systray.go +++ b/web/backend/systray.go @@ -1,4 +1,4 @@ -//go:build (!darwin && !freebsd) || cgo +//go:build !android && ((!darwin && !freebsd) || cgo) package main @@ -6,7 +6,6 @@ import ( "fmt" "fyne.io/systray" - "github.com/atotto/clipboard" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/utils" @@ -24,7 +23,6 @@ func onReady() { // Create menu items mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip)) - mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint)) mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip)) // Add version info under About menu @@ -52,17 +50,6 @@ func onReady() { logger.Errorf("Failed to open browser: %v", err) } - case <-mCopyTok.ClickedCh: - if launcherDashboardTokenForClipboard == "" { - logger.WarnC("web", "Dashboard token is empty; cannot copy") - continue - } - if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil { - logger.Errorf("Failed to copy dashboard token: %v", err) - } else { - logger.InfoC("web", "Dashboard token copied to clipboard") - } - case <-mVersion.ClickedCh: // Version info - do nothing, just shows current version diff --git a/web/backend/systray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go index 9e75e112a..41514feef 100644 --- a/web/backend/systray_stub_nocgo.go +++ b/web/backend/systray_stub_nocgo.go @@ -1,4 +1,4 @@ -//go:build (darwin || freebsd) && !cgo +//go:build (darwin || freebsd || android) && !cgo package main diff --git a/web/backend/tray_offers_copy.go b/web/backend/tray_offers_copy.go deleted file mode 100644 index 6b7d17412..000000000 --- a/web/backend/tray_offers_copy.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build (!darwin && !freebsd) || cgo - -package main - -func trayOffersDashboardTokenCopy() bool { return true } diff --git a/web/backend/tray_offers_copy_stub.go b/web/backend/tray_offers_copy_stub.go deleted file mode 100644 index 9312700f3..000000000 --- a/web/backend/tray_offers_copy_stub.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build (darwin || freebsd) && !cgo - -package main - -func trayOffersDashboardTokenCopy() bool { return false } diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 772cd7ec0..8899a664b 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -7,18 +7,16 @@ import ( "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 { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw") + return config.GetHome() } // GetDefaultConfigPath returns the default path to the picoclaw config file. @@ -47,6 +45,7 @@ func FindPicoclawBinary() string { } 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 @@ -56,18 +55,93 @@ func FindPicoclawBinary() string { return "picoclaw" } -// GetLocalIP returns the local IP address of the machine. -func GetLocalIP() string { +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 "" + return nil } + results := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String() + 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 "" + 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. diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js index bc9c64344..884649e41 100644 --- a/web/frontend/eslint.config.js +++ b/web/frontend/eslint.config.js @@ -22,10 +22,19 @@ export default defineConfig([ 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/package.json b/web/frontend/package.json index 8053d1f2a..bf3e7921b 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -3,6 +3,10 @@ "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", @@ -15,51 +19,53 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@tabler/icons-react": "^3.40.0", - "@tailwindcss/vite": "^4.2.2", - "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.167.0", - "@tanstack/react-router-devtools": "^1.163.3", + "@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", - "i18next": "^25.8.14", + "highlight.js": "^11.11.1", + "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", - "jotai": "^2.18.1", + "jotai": "^2.19.1", "radix-ui": "^1.4.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-i18next": "^16.5.8", + "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.1.0", + "shadcn": "^4.3.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@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.5.0", + "@types/node": "^25.6.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.57.1", - "@vitejs/plugin-react": "^5.2.0", - "eslint": "^9.39.4", + "@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.0.1", - "eslint-plugin-react-refresh": "^0.4.26", - "globals": "^16.5.0", - "prettier": "^3.8.1", + "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.57.1", - "vite": "^7.3.1" + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index edaf49ccc..78639de19 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -13,19 +13,19 @@ importers: version: 5.2.8 '@tabler/icons-react': specifier: ^3.40.0 - version: 3.40.0(react@19.2.4) + version: 3.41.1(react@19.2.5) '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + 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.90.21 - version: 5.91.2(react@19.2.4) + specifier: ^5.99.0 + version: 5.99.0(react@19.2.5) '@tanstack/react-router': - specifier: ^1.167.0 - version: 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + 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.163.3 - version: 1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + 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 @@ -35,33 +35,39 @@ importers: dayjs: specifier: ^1.11.20 version: 1.11.20 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 i18next: - specifier: ^25.8.14 - version: 25.8.20(typescript@5.9.3) + 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.18.1 - version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + 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.4(react@19.2.4))(react@19.2.4) + 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.0 - version: 19.2.4 + specifier: 19.2.5 + version: 19.2.5 react-dom: - specifier: ^19.2.0 - version: 19.2.4(react@19.2.4) + specifier: 19.2.5 + version: 19.2.5(react@19.2.5) react-i18next: - specifier: ^16.5.8 - version: 16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + 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.4) + 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.4) + 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 @@ -72,17 +78,17 @@ importers: specifier: ^4.0.1 version: 4.0.1 shadcn: - specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3) + 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.4(react@19.2.4))(react@19.2.4) + 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.2 - version: 4.2.2 + specifier: ^4.2.4 + version: 4.2.4 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -91,20 +97,20 @@ importers: version: 10.0.0 devDependencies: '@eslint/js': - specifier: ^9.39.4 - version: 9.39.4 + 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.2) + version: 0.5.19(tailwindcss@4.2.4) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.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.1) + version: 6.0.2(prettier@3.8.3) '@types/node': - specifier: ^25.5.0 - version: 25.5.0 + specifier: ^25.6.0 + version: 25.6.0 '@types/react': specifier: ^19.2.7 version: 19.2.14 @@ -112,41 +118,41 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': - specifier: ^8.57.1 - version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + 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: ^5.2.0 - version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + 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: ^9.39.4 - version: 9.39.4(jiti@2.6.1) + specifier: ^10.2.1 + version: 10.2.1(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) + version: 10.1.8(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + specifier: ^7.1.1 + version: 7.1.1(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-refresh: - specifier: ^0.4.26 - version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + specifier: ^0.5.2 + version: 0.5.2(eslint@10.2.1(jiti@2.7.0)) globals: - specifier: ^16.5.0 - version: 16.5.0 + specifier: ^17.5.0 + version: 17.5.0 prettier: - specifier: ^3.8.1 - version: 3.8.1 + 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.1))(prettier@3.8.1) + 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.57.1 - version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.59.1 + version: 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + 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: @@ -255,18 +261,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - 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'} @@ -295,16 +289,25 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@dotenvx/dotenvx@1.57.0': - resolution: {integrity: sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA==} + '@dotenvx/dotenvx@1.61.0': + resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==} hasBin: true - '@ecies/ciphers@0.2.5': - resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} - engines: {bun: '>=1', deno: '>=2', node: '>=16'} + '@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'} @@ -471,33 +474,34 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.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.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@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/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@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==} @@ -517,8 +521,8 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -539,35 +543,35 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=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@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + '@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@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + '@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@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} + '@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@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + '@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: @@ -590,8 +594,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -604,6 +608,12 @@ packages: 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} @@ -631,12 +641,18 @@ packages: '@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==} @@ -1327,133 +1343,106 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + '@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] - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + '@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] - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + '@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] - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + '@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] - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + '@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] - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + '@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] - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + '@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] - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + '@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] - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + '@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] - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + '@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] - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + '@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] - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + '@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] - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + '@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] - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + '@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] - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - 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==} @@ -1462,73 +1451,77 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@tabler/icons-react@3.40.0': - resolution: {integrity: sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==} + '@tabler/icons-react@3.41.1': + resolution: {integrity: sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==} peerDependencies: react: '>= 16' - '@tabler/icons@3.40.0': - resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==} + '@tabler/icons@3.41.1': + resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + '@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.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + '@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.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + '@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.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + '@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.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + '@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.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + '@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.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + '@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.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + '@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.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1539,20 +1532,20 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + '@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.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} engines: {node: '>= 20'} '@tailwindcss/typography@0.5.19': @@ -1560,8 +1553,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -1569,65 +1562,69 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.91.2': - resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==} + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} - '@tanstack/react-query@5.91.2': - resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==} + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.166.9': - resolution: {integrity: sha512-O49eZmaeEKB5YnKH/qd61AbxV/lW8ICm4stfZ4GNQNpzQQ6rhPIB0p3PMZDIgX+6DoMivdNvLRmXAOOpzpIpDg==} + '@tanstack/react-router-devtools@1.166.13': + resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.167.2 - '@tanstack/router-core': ^1.167.2 + '@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.167.5': - resolution: {integrity: sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ==} + '@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.2': - resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} + '@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.167.5': - resolution: {integrity: sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ==} + '@tanstack/router-core@1.168.7': + resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==} engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-devtools-core@1.166.9': - resolution: {integrity: sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw==} + '@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.167.2 + '@tanstack/router-core': ^1.168.11 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.166.13': - resolution: {integrity: sha512-ALxSs6OzimiSgpOuIm+AXmc7eUx/oGPwSPpdQbpZ/kX7WHRh6qM7lv8DAN0K3jWcBpzF8eeOIdryWryX8gH+Yg==} + '@tanstack/router-generator@1.166.22': + resolution: {integrity: sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.166.14': - resolution: {integrity: sha512-hypyj0qlsAbJf60/glmVYqSVwnRB4hKRrMCUsSXjrPdO2g6gs3z6xHmcWsHQ831C4G9+bSFEK9Uy5EjO3A4THQ==} + '@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.167.5 + '@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' @@ -1647,8 +1644,8 @@ packages: resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==} engines: {node: '>=20.19'} - '@tanstack/store@0.9.2': - resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + '@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==} @@ -1677,21 +1674,15 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@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==} @@ -1710,8 +1701,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@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==} @@ -1721,6 +1712,9 @@ packages: '@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==} @@ -1733,73 +1727,133 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.57.1': - resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} + '@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.57.1 + '@typescript-eslint/parser': ^8.58.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.57.1': - resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} + '@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.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.57.1': - resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} + '@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.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.57.1': - resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.57.1': - resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} + '@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.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.57.1': - resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} + '@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.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.57.1': - resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.57.1': - resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.57.1': - resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} + '@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.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.57.1': - resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} + '@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@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + '@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: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@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==} @@ -1881,8 +1935,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.9: - resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + baseline-browser-mapping@2.10.17: + resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1894,22 +1948,19 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + 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.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + 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 @@ -1933,16 +1984,12 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2007,11 +2054,8 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: @@ -2024,6 +2068,9 @@ packages: 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'} @@ -2125,12 +2172,12 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} - dotenv@17.3.1: - resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -2144,8 +2191,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.321: - resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + electron-to-chromium@1.5.334: + resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2157,8 +2204,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} entities@6.0.1: @@ -2210,36 +2257,32 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + 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 + 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.4.26: - resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} peerDependencies: - eslint: '>=8.40' + eslint: ^9 || ^10 - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + 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@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.1: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -2247,9 +2290,9 @@ packages: jiti: optional: true - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + 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==} @@ -2295,8 +2338,8 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - express-rate-limit@8.3.1: - resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + express-rate-limit@8.3.2: + resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2321,9 +2364,18 @@ packages: 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==} @@ -2430,8 +2482,8 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -2441,12 +2493,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@16.5.0: - resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} engines: {node: '>=18'} goober@2.1.18: @@ -2461,14 +2509,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.1: - resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} + 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-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2480,6 +2524,9 @@ packages: 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==} @@ -2495,14 +2542,17 @@ packages: 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@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + 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==} @@ -2510,8 +2560,12 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.8: - resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + 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: @@ -2542,10 +2596,10 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@25.8.20: - resolution: {integrity: sha512-xjo9+lbX/P1tQt3xpO2rfJiBppNfUnNIPKgCvNsTKsvTOCro1Qr/geXVg1N47j5ScOSaXAPq8ET93raK3Rr06A==} + i18next@26.0.8: + resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==} peerDependencies: - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: typescript: optional: true @@ -2675,8 +2729,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.36: - resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} engines: {node: '>=18'} isexe@2.0.0: @@ -2689,15 +2743,15 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + 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.18.1: - resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==} + jotai@2.19.1: + resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': '>=7.0.0' @@ -2802,24 +2856,28 @@ packages: 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==} @@ -2847,9 +2905,6 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -2857,6 +2912,9 @@ packages: 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==} @@ -3034,13 +3092,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -3051,8 +3106,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.13: - resolution: {integrity: sha512-9CV2mXT9+z0J26MQDfEZZkj/psJ5Er/w0w+t95FWdaGH/DTlhNZBx8vBO5jSYv8AZEnl3ouX+AaTT68KXdAIag==} + msw@2.13.4: + resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3061,9 +3116,9 @@ packages: typescript: optional: true - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + 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==} @@ -3086,8 +3141,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -3197,8 +3252,8 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3206,12 +3261,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pkce-challenge@5.0.1: @@ -3226,8 +3281,8 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -3293,8 +3348,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true @@ -3317,8 +3372,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -3345,19 +3400,19 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: - react: ^19.2.4 + react: ^19.2.5 - react-i18next@16.5.8: - resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} + react-i18next@17.0.4: + resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} peerDependencies: - i18next: '>= 25.6.2' + i18next: '>= 26.0.1' react: '>= 16.8.0' react-dom: '*' react-native: '*' - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: react-dom: optional: true @@ -3372,10 +3427,6 @@ packages: '@types/react': '>=18' react: '>=18' - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3412,8 +3463,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} readdirp@3.6.0: @@ -3424,6 +3475,9 @@ packages: 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==} @@ -3461,16 +3515,16 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + 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'} - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.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: @@ -3509,19 +3563,32 @@ packages: 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.1.0: - resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==} + shadcn@4.3.0: + resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==} hasBin: true shebang-command@2.0.0: @@ -3532,8 +3599,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -3629,20 +3696,12 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -3650,28 +3709,25 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + 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==} - tiny-warning@1.0.3: - resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tldts-core@7.0.26: - resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} - tldts@7.0.26: - resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} hasBin: true to-regex-range@5.0.1: @@ -3728,20 +3784,20 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.57.1: - resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==} + 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.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.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -3750,6 +3806,9 @@ packages: 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==} @@ -3861,15 +3920,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + 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 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -3880,12 +3940,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -3933,10 +3995,6 @@ packages: resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} engines: {node: '>=20'} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3967,18 +4025,18 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} + 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.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: - zod: ^3.25 || ^4 + zod: ^3.25.28 || ^4 zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} @@ -4041,7 +4099,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -4138,16 +4196,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -4195,22 +4243,39 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@dotenvx/dotenvx@1.57.0': + '@dotenvx/dotenvx@1.61.0': dependencies: commander: 11.1.0 - dotenv: 17.3.1 + dotenv: 17.4.2 eciesjs: 0.4.18 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.3 + picomatch: 4.0.4 which: 4.0.0 + yocto-spinner: 1.1.0 - '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.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 @@ -4289,50 +4354,38 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.23.5': dependencies: - '@eslint/object-schema': 2.1.7 + '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 3.1.5 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': + '@eslint/config-helpers@0.5.5': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.2.1 - '@eslint/core@0.17.0': + '@eslint/core@1.2.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@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: - ajv: 6.14.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.2.1 levn: 0.4.1 '@floating-ui/core@1.7.5': @@ -4344,19 +4397,19 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@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.4 - react-dom: 19.2.4(react@19.2.4) + 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.11(hono@4.12.8)': + '@hono/node-server@1.19.14(hono@4.12.14)': dependencies: - hono: 4.12.8 + hono: 4.12.14 '@humanfs/core@0.19.1': {} @@ -4369,33 +4422,32 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@5.1.21(@types/node@25.5.0)': + '@inquirer/confirm@6.0.11(@types/node@25.6.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.5.0) - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@inquirer/core': 11.1.8(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/core@10.3.2(@types/node@25.5.0)': + '@inquirer/core@11.1.8(@types/node@25.6.0)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@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 - mute-stream: 2.0.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@2.0.5': {} - '@inquirer/type@3.0.10(@types/node@25.5.0)': + '@inquirer/type@4.0.5(@types/node@25.6.0)': optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4416,9 +4468,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) + '@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 @@ -4427,14 +4479,14 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 + 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.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -4447,6 +4499,13 @@ snapshots: 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': @@ -4469,6 +4528,8 @@ snapshots: '@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 @@ -4476,977 +4537,956 @@ snapshots: '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@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.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + 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.4)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4)': + '@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.4) - react: 19.2.4 + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@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.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@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.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@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.4 - react-dom: 19.2.4(react@19.2.4) + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) + '@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.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + 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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4)': + '@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.4) - react: 19.2.4 + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.4)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + 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.4)': + '@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.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@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.4)': + '@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.4) - react: 19.2.4 + '@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.4)': + '@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.4) - react: 19.2.4 + '@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.4)': + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + 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.4)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + 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.4)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + 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.4)': + '@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.4 + 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.4)': + '@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.4) - react: 19.2.4 + '@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.4(react@19.2.4))(react@19.2.4)': + '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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/pluginutils@1.0.0-rc.3': {} - - '@rollup/rollup-android-arm-eabi@4.59.0': + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true - '@rollup/rollup-android-arm64@4.59.0': + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true - '@rollup/rollup-darwin-arm64@4.59.0': + '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true - '@rollup/rollup-darwin-x64@4.59.0': + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true - '@rollup/rollup-freebsd-arm64@4.59.0': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true - '@rollup/rollup-freebsd-x64@4.59.0': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.0': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.0': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.0': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.0': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.0': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.0': + '@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 - '@rollup/rollup-linux-ppc64-musl@4.59.0': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.0': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.0': - optional: true + '@rolldown/pluginutils@1.0.0-rc.17': {} - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true + '@rolldown/pluginutils@1.0.0-rc.7': {} '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} - '@tabler/icons-react@3.40.0(react@19.2.4)': + '@tabler/icons-react@3.41.1(react@19.2.5)': dependencies: - '@tabler/icons': 3.40.0 - react: 19.2.4 + '@tabler/icons': 3.41.1 + react: 19.2.5 - '@tabler/icons@3.40.0': {} + '@tabler/icons@3.41.1': {} - '@tailwindcss/node@4.2.2': + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 + 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.2 + tailwindcss: 4.2.4 - '@tailwindcss/oxide-android-arm64@4.2.2': + '@tailwindcss/oxide-android-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.2': + '@tailwindcss/oxide-darwin-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.2': + '@tailwindcss/oxide-darwin-x64@4.2.4': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@tailwindcss/oxide-freebsd-x64@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.2': + '@tailwindcss/oxide-linux-x64-musl@4.2.4': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.2': + '@tailwindcss/oxide-wasm32-wasi@4.2.4': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': optional: true - '@tailwindcss/oxide@4.2.2': + '@tailwindcss/oxide@4.2.4': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + '@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.2)': + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@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.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + '@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.91.2': {} + '@tanstack/query-core@5.99.0': {} - '@tanstack/react-query@5.91.2(react@19.2.4)': + '@tanstack/react-query@5.99.0(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.91.2 - react: 19.2.4 + '@tanstack/query-core': 5.99.0 + react: 19.2.5 - '@tanstack/react-router-devtools@1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@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.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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.167.5 + '@tanstack/router-core': 1.169.2 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@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.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.167.5 - isbot: 5.1.36 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 + '@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.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/store': 0.9.2 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) + '@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.167.5': + '@tanstack/router-core@1.168.7': dependencies: '@tanstack/history': 1.161.6 - '@tanstack/store': 0.9.2 cookie-es: 2.0.0 seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)': + '@tanstack/router-core@1.169.2': dependencies: - '@tanstack/router-core': 1.167.5 + '@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) - tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.166.13': + '@tanstack/router-generator@1.166.22': dependencies: - '@tanstack/router-core': 1.167.5 + '@tanstack/router-core': 1.168.7 '@tanstack/router-utils': 1.161.6 '@tanstack/virtual-file-routes': 1.161.7 - prettier: 3.8.1 + prettier: 3.8.3 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -5454,7 +5494,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@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) @@ -5462,16 +5502,16 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.167.5 - '@tanstack/router-generator': 1.166.13 + '@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.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + '@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 @@ -5483,17 +5523,17 @@ snapshots: '@babel/types': 7.29.0 ansis: 4.2.0 babel-dead-code-elimination: 1.0.12 - diff: 8.0.3 + diff: 8.0.4 pathe: 2.0.3 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 transitivePeerDependencies: - supports-color - '@tanstack/store@0.9.2': {} + '@tanstack/store@0.9.3': {} '@tanstack/virtual-file-routes@1.161.7': {} - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.2 @@ -5503,41 +5543,27 @@ snapshots: lodash-es: 4.17.23 minimatch: 9.0.9 parse-imports-exports: 0.2.4 - prettier: 3.8.1 + prettier: 3.8.3 transitivePeerDependencies: - supports-color '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 - minimatch: 10.2.4 + minimatch: 10.2.5 path-browserify: 1.0.1 - '@types/babel__core@7.20.5': + '@tybys/wasm-util@0.10.1': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 + 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 @@ -5556,9 +5582,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.5.0': + '@types/node@25.6.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.19.2 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -5568,6 +5594,10 @@ snapshots: 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': {} @@ -5576,15 +5606,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@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.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.1 - eslint: 9.39.4(jiti@2.6.1) + '@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) @@ -5592,94 +5622,166 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@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: - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.1 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.57.1': - dependencies: - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 - - '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + '@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/types@8.57.1': {} - - '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 + '@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 - minimatch: 10.2.4 + 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.15 + 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.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@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/visitor-keys@8.57.1': + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.57.1 + '@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@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.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: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) - transitivePeerDependencies: - - supports-color + '@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: @@ -5727,7 +5829,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@2.0.1: {} @@ -5754,7 +5856,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.9: {} + baseline-browser-mapping@2.10.17: {} binary-extensions@2.3.0: {} @@ -5766,22 +5868,17 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.0 + qs: 6.15.1 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: - supports-color - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -5789,13 +5886,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.9 - caniuse-lite: 1.0.30001780 - electron-to-chromium: 1.5.321 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + 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: @@ -5815,15 +5912,10 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001780: {} + caniuse-lite@1.0.30001787: {} ccount@2.0.1: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -5880,9 +5972,7 @@ snapshots: commander@14.0.3: {} - concat-map@0.0.1: {} - - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -5890,6 +5980,8 @@ snapshots: cookie-es@2.0.0: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -5959,9 +6051,9 @@ snapshots: dependencies: dequal: 2.0.3 - diff@8.0.3: {} + diff@8.0.4: {} - dotenv@17.3.1: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: dependencies: @@ -5971,14 +6063,14 @@ snapshots: eciesjs@0.4.18: dependencies: - '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@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.321: {} + electron-to-chromium@1.5.334: {} emoji-regex@10.6.0: {} @@ -5986,10 +6078,10 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.20.1: + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 entities@6.0.1: {} @@ -6044,58 +6136,55 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + 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: 9.39.4(jiti@2.6.1) + 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.4.26(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) - eslint-scope@8.4.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@4.2.1: {} - eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.6.1): + eslint@10.2.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@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.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 + '@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 - chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.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 @@ -6106,20 +6195,19 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color - espree@10.4.0: + espree@11.2.0: dependencies: acorn: 8.16.0 acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 + eslint-visitor-keys: 5.0.1 esprima@4.0.1: {} @@ -6172,7 +6260,7 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - express-rate-limit@8.3.1(express@5.2.1): + express-rate-limit@8.3.2(express@5.2.1): dependencies: express: 5.2.1 ip-address: 10.1.0 @@ -6181,7 +6269,7 @@ snapshots: dependencies: accepts: 2.0.0 body-parser: 2.2.2 - content-disposition: 1.0.1 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -6199,7 +6287,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.0 + qs: 6.15.1 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -6226,15 +6314,25 @@ snapshots: 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.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fetch-blob@3.2.0: dependencies: @@ -6332,7 +6430,7 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-tsconfig@4.13.6: + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -6344,9 +6442,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@14.0.0: {} - - globals@16.5.0: {} + globals@17.5.0: {} goober@2.1.18(csstype@3.2.3): dependencies: @@ -6356,9 +6452,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.1: {} - - has-flag@4.0.0: {} + graphql@16.13.2: {} has-symbols@1.1.0: {} @@ -6377,6 +6471,10 @@ snapshots: 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 @@ -6433,6 +6531,13 @@ snapshots: 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 @@ -6445,7 +6550,10 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: {} + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 hermes-estree@0.25.1: {} @@ -6453,7 +6561,9 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.8: {} + highlight.js@11.11.1: {} + + hono@4.12.14: {} html-parse-stringify@3.0.1: dependencies: @@ -6486,9 +6596,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@25.8.20(typescript@5.9.3): - dependencies: - '@babel/runtime': 7.29.2 + i18next@26.0.8(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -6574,7 +6682,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.36: {} + isbot@5.1.40: {} isexe@2.0.0: {} @@ -6582,16 +6690,16 @@ snapshots: javascript-natural-sort@0.7.1: {} - jiti@2.6.1: {} + jiti@2.7.0: {} jose@6.2.2: {} - jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + 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.4 + react: 19.2.5 js-tokens@4.0.0: {} @@ -6691,8 +6799,6 @@ snapshots: lodash-es@4.17.23: {} - lodash.merge@4.6.2: {} - log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -6700,6 +6806,12 @@ snapshots: 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 @@ -7067,7 +7179,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.54.0: {} @@ -7079,36 +7191,32 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.4: + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.4 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.12 + brace-expansion: 5.0.5 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 minimist@1.2.8: {} ms@2.1.3: {} - msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3): + msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.5.0) + '@inquirer/confirm': 6.0.11(@types/node@25.6.0) '@mswjs/interceptors': 0.41.3 - '@open-draft/deferred-promise': 2.2.0 + '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.1 - headers-polyfill: 4.0.3 + 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.10.1 + rettime: 0.11.7 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.1 @@ -7120,7 +7228,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - mute-stream@2.0.0: {} + mute-stream@3.0.0: {} nanoid@3.3.11: {} @@ -7136,7 +7244,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.36: {} + node-releases@2.0.37: {} normalize-path@3.0.0: {} @@ -7256,15 +7364,15 @@ snapshots: path-to-regexp@6.3.0: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pkce-challenge@5.0.1: {} @@ -7278,7 +7386,7 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.8: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -7288,13 +7396,13 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.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.1 + prettier: 3.8.3 optionalDependencies: - '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1) + '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3) - prettier@3.8.1: {} + prettier@3.8.3: {} pretty-ms@9.3.0: dependencies: @@ -7314,71 +7422,71 @@ snapshots: punycode@2.3.1: {} - qs@6.15.0: + 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.4(react@19.2.4))(react@19.2.4): + 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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@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.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@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) @@ -7392,23 +7500,23 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-dom@19.2.4(react@19.2.4): + react-dom@19.2.5(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 scheduler: 0.27.0 - react-i18next@16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + 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: 25.8.20(typescript@5.9.3) - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + 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.4(react@19.2.4) + 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.4): + 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 @@ -7417,7 +7525,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.4 + react: 19.2.5 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -7426,49 +7534,47 @@ snapshots: transitivePeerDependencies: - supports-color - react-refresh@0.18.0: {} - - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + 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.4): + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + 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.4) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + 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.4): + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1 - react: 19.2.4 + 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.4): + react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5): dependencies: '@babel/runtime': 7.29.2 - react: 19.2.4 - use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) - use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) + 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.4: {} + react@19.2.5: {} readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 recast@0.23.11: dependencies: @@ -7478,6 +7584,14 @@ snapshots: 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 @@ -7536,40 +7650,30 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.10.1: {} + rettime@0.11.7: {} reusify@1.1.0: {} - rollup@4.59.0: + rolldown@1.0.0-rc.17: dependencies: - '@types/estree': 1.0.8 + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 - fsevents: 2.3.3 + '@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: @@ -7577,7 +7681,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -7615,8 +7719,14 @@ snapshots: 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 @@ -7626,34 +7736,36 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.0: {} + setprototypeof@1.2.0: {} - shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3): + 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.57.0 - '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@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.1 + 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.3 + 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.12.13(@types/node@25.5.0)(typescript@5.9.3) + 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.8 + postcss: 8.5.10 postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 @@ -7663,7 +7775,7 @@ snapshots: tsconfig-paths: 4.2.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' @@ -7677,7 +7789,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -7701,7 +7813,7 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -7711,10 +7823,10 @@ snapshots: sisteransi@1.0.5: {} - sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) source-map-js@1.2.1: {} @@ -7772,8 +7884,6 @@ snapshots: strip-final-newline@4.0.0: {} - strip-json-comments@3.1.1: {} - style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -7782,32 +7892,26 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} - tailwindcss@4.2.2: {} + tailwindcss@4.2.4: {} - tapable@2.3.0: {} + tapable@2.3.3: {} tiny-invariant@1.3.3: {} - tiny-warning@1.0.3: {} - - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tldts-core@7.0.26: {} + tldts-core@7.0.28: {} - tldts@7.0.26: + tldts@7.0.28: dependencies: - tldts-core: 7.0.26 + tldts-core: 7.0.28 to-regex-range@5.0.1: dependencies: @@ -7817,7 +7921,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.26 + tldts: 7.0.28 trim-lines@3.0.1: {} @@ -7843,7 +7947,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.4 - get-tsconfig: 4.13.6 + get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 @@ -7863,20 +7967,20 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@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.18.2: {} + undici-types@7.19.2: {} unicorn-magic@0.3.0: {} @@ -7890,6 +7994,11 @@ snapshots: 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 @@ -7921,14 +8030,14 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.16.0 - picomatch: 4.0.3 + picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 until-async@3.0.2: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -7936,43 +8045,43 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + 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.4): + use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + 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.4): + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4): + use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) + 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.4): + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): dependencies: detect-node-es: 1.1.0 - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-sync-external-store@1.6.0(react@19.2.4): + use-sync-external-store@1.6.0(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 util-deprecate@1.0.2: {} @@ -7995,19 +8104,18 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.5.0 - fsevents: 2.3.3 - jiti: 2.6.1 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: {} @@ -8034,12 +8142,6 @@ snapshots: string-width: 8.2.0 strip-ansi: 7.2.0 - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -8071,11 +8173,13 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: {} + yocto-spinner@1.1.0: + dependencies: + yoctocolors: 2.1.2 yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@3.25.76): + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index eb4d41fd7..42a3a0606 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -1,5 +1,3 @@ -// API client for channels navigation and channel-specific config flows. - import { launcherFetch } from "@/api/http" export type ChannelConfig = Record @@ -12,6 +10,13 @@ export interface SupportedChannel { variant?: string } +export interface ChannelConfigResponse { + config: ChannelConfig + configured_secrets: string[] + config_key: string + variant?: string +} + interface ChannelsCatalogResponse { channels: SupportedChannel[] } @@ -54,6 +59,14 @@ 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 { diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts index 0eb872f3f..347dd9373 100644 --- a/web/frontend/src/api/http.ts +++ b/web/frontend/src/api/http.ts @@ -1,14 +1,14 @@ -import { isLauncherLoginPathname } from "@/lib/launcher-login-path" +import { isLauncherAuthPathname } from "@/lib/launcher-login-path" -function isLauncherLoginPath(): boolean { +function isLauncherAuthPath(): boolean { if (typeof globalThis.location === "undefined") { return false } - if (isLauncherLoginPathname(globalThis.location.pathname || "/")) { + if (isLauncherAuthPathname(globalThis.location.pathname || "/")) { return true } try { - return isLauncherLoginPathname( + return isLauncherAuthPathname( new URL(globalThis.location.href).pathname || "/", ) } catch { @@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean { /** * Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses. - * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll). + * Skips redirect while already on an auth page (login or setup) to avoid reload loops. */ export async function launcherFetch( input: RequestInfo | URL, @@ -33,7 +33,7 @@ export async function launcherFetch( if ( ct.includes("application/json") && typeof globalThis.location !== "undefined" && - !isLauncherLoginPath() + !isLauncherAuthPath() ) { globalThis.location.assign("/launcher-login") } diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts index 247d5ab9e..c7318d962 100644 --- a/web/frontend/src/api/launcher-auth.ts +++ b/web/frontend/src/api/launcher-auth.ts @@ -1,29 +1,33 @@ /** - * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid - * redirect loops on 401 while on the login page. + * 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( - token: string, -): Promise { + password: string, +): Promise { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", - body: JSON.stringify({ token: token.trim() }), + body: JSON.stringify({ password: password.trim() }), }) - return res.ok -} + if (res.ok) return { ok: true } -export type LauncherAuthTokenHelp = { - env_var_name: string - log_file?: string - tray_copy_menu: boolean - console_stdout: boolean + return { + ok: false, + status: res.status, + error: await readLauncherAuthError(res), + } } export type LauncherAuthStatus = { authenticated: boolean - token_help?: LauncherAuthTokenHelp + /** true when a bcrypt password has been stored in the DB */ + initialized: boolean } export async function getLauncherAuthStatus(): Promise { @@ -46,3 +50,33 @@ export async function postLauncherDashboardLogout(): Promise { }) 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 index d75b3ec3c..5bb275fde 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway" export interface ModelInfo { index: number model_name: string + provider?: string model: string api_base?: string api_key: string @@ -18,17 +19,32 @@ export interface ModelInfo { max_tokens_field?: string request_timeout?: number thinking_level?: string + tool_schema_transform?: string extra_body?: Record + custom_headers?: Record // Meta - configured: boolean + 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 { diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts index 6b8ceb49a..ca98a06da 100644 --- a/web/frontend/src/api/pico.ts +++ b/web/frontend/src/api/pico.ts @@ -2,16 +2,16 @@ import { launcherFetch } from "@/api/http" // API client for Pico Channel configuration. -interface PicoTokenResponse { - token: string +interface PicoInfoResponse { ws_url: string enabled: boolean + configured?: boolean } interface PicoSetupResponse { - token: string ws_url: string enabled: boolean + configured?: boolean changed: boolean } @@ -25,16 +25,16 @@ async function request(path: string, options?: RequestInit): Promise { return res.json() as Promise } -export async function getPicoToken(): Promise { - return request("/api/pico/token") +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 regenPicoToken(): Promise { + return request("/api/pico/token", { method: "POST" }) } export async function setupPico(): Promise { return request("/api/pico/setup", { method: "POST" }) } -export type { PicoTokenResponse, PicoSetupResponse } +export type { PicoInfoResponse, PicoSetupResponse } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index c91495901..edd7d7c27 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -1,5 +1,3 @@ -// Sessions API — list and retrieve chat session history - import { launcherFetch } from "@/api/http" export interface SessionSummary { @@ -13,7 +11,29 @@ export interface SessionSummary { export interface SessionDetail { id: string - messages: { role: "user" | "assistant"; content: 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 diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts index 72ccbcfe5..958808afd 100644 --- a/web/frontend/src/api/skills.ts +++ b/web/frontend/src/api/skills.ts @@ -5,22 +5,60 @@ export interface SkillSupportItem { 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[] } -interface SkillActionResponse { +export interface SkillSearchResponse { + results: SkillRegistrySearchResult[] + limit: number + offset: number + next_offset?: number + has_more: boolean +} + +type SkillActionResponse = Partial & { status?: string - name?: string - path?: string - source?: string - description?: 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 { @@ -39,6 +77,29 @@ 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) @@ -64,15 +125,23 @@ export async function deleteSkill(name: string): Promise { async function extractErrorMessage(res: Response): Promise { try { - const body = (await res.json()) as { - error?: string - errors?: string[] + const raw = await res.text() + if (raw.trim() === "") { + return `API error: ${res.status} ${res.statusText}` } - if (Array.isArray(body.errors) && body.errors.length > 0) { - return body.errors.join("; ") - } - if (typeof body.error === "string" && body.error.trim() !== "") { - return body.error + 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 diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 2e2f36f15..dfc48b6b8 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -13,6 +13,13 @@ export interface LauncherConfig { 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) { @@ -62,3 +69,7 @@ export async function setLauncherConfig( 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 index 824bcc0fa..a77f3ba80 100644 --- a/web/frontend/src/api/tools.ts +++ b/web/frontend/src/api/tools.ts @@ -17,6 +17,31 @@ 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) { @@ -56,3 +81,17 @@ export async function setToolEnabled( }, ) } + +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 index 4f0688008..700cc21e0 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -2,6 +2,7 @@ import { IconBook, IconLanguage, IconLoader2, + IconLogout, IconMenu2, IconMoon, IconPlayerPlay, @@ -13,6 +14,7 @@ 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, @@ -47,10 +49,12 @@ export function AppHeader() { state: gwState, loading: gwLoading, canStart, + startReason, restartRequired, start, restart, stop, + error: gwError, } = useGateway() const isRunning = gwState === "running" @@ -65,6 +69,12 @@ export function AppHeader() { (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)) { @@ -134,6 +144,23 @@ export function AppHeader() { + + + + {t("header.logout.tooltip")} + + {t("header.logout.description")} + + + + {t("common.cancel")} + void handleLogout()}> + {t("header.logout.confirm")} + + + + +
{restartRequired && ( @@ -163,6 +190,7 @@ export function AppHeader() { variant="destructive" size="icon-sm" className="size-8" + data-tour="gateway-button" onClick={handleGatewayToggle} disabled={gwLoading} aria-label={t("header.gateway.action.stop")} @@ -170,37 +198,65 @@ export function AppHeader() { - {t("header.gateway.action.stop")} + + {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 */} - + + + + {/* Logout */} + + + + + {t("header.logout.tooltip")} +
) diff --git a/web/frontend/src/components/app-layout.tsx b/web/frontend/src/components/app-layout.tsx index ff9877bae..a1f8f89ae 100644 --- a/web/frontend/src/components/app-layout.tsx +++ b/web/frontend/src/components/app-layout.tsx @@ -3,6 +3,7 @@ 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" @@ -21,6 +22,7 @@ export function AppLayout({ children }: { children: ReactNode }) {
+ ) diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 0e135c0c1..1980e458c 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -6,6 +6,7 @@ import { IconKey, IconListDetails, IconMessageCircle, + IconSearch, IconSettings, IconSparkles, IconTools, @@ -29,6 +30,7 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarRail, + useSidebar, } from "@/components/ui/sidebar" import { useSidebarChannels } from "@/hooks/use-sidebar-channels" @@ -68,6 +70,7 @@ const baseNavGroups: Omit[] = [ export function AppSidebar({ ...props }: React.ComponentProps) { const routerState = useRouterState() const { i18n, t } = useTranslation() + const { isMobile, setOpenMobile } = useSidebar() const currentPath = routerState.location.pathname const { channelItems, @@ -79,6 +82,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) { t, }) + const handleNavItemClick = React.useCallback(() => { + if (isMobile) { + setOpenMobile(false) + } + }, [isMobile, setOpenMobile]) + const navGroups: NavGroup[] = React.useMemo(() => { return [ { @@ -123,6 +132,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) { { ...baseNavGroups[2], items: [ + { + title: "navigation.hub", + url: "/agent/hub", + icon: IconSearch, + translateTitle: true, + }, { title: "navigation.skills", url: "/agent/skills", @@ -189,6 +204,10 @@ export function AppSidebar({ ...props }: React.ComponentProps) { 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 index 6af821ac9..d253980f8 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -1,14 +1,25 @@ -import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react" +import { IconLoader2 } from "@tabler/icons-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { type ChannelConfig, type SupportedChannel, - getAppConfig, + 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" @@ -17,34 +28,18 @@ 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 } -const SECRET_FIELD_MAP: Record = { - 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", - password: "_password", - nickserv_password: "_nickserv_password", - sasl_password: "_sasl_password", -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -60,16 +55,51 @@ function asBool(value: unknown): boolean { return value === true } -function buildEditConfig(config: ChannelConfig): ChannelConfig { - const edit: ChannelConfig = { ...config } - for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { - if (secretKey in config) { - edit[SECRET_FIELD_MAP[secretKey]] = "" - } +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), } - return edit } +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, @@ -89,27 +119,50 @@ function buildSavePayload( editConfig: ChannelConfig, enabled: boolean, ): ChannelConfig { - const payload: ChannelConfig = { enabled } + 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 (key in SECRET_FIELD_MAP) { - const editKey = SECRET_FIELD_MAP[key] - const incoming = asString(editConfig[editKey]) - payload[key] = incoming !== "" ? incoming : value + 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 - payload[key] = value + 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") { - payload.use_native = true + settings.use_native = true } if (channel.name === "whatsapp") { - payload.use_native = false + settings.use_native = false + } + + if (Object.keys(settings).length > 0) { + payload.settings = settings } return payload @@ -118,51 +171,50 @@ function buildSavePayload( 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 asString(config.token) !== "" + return hasValue("token") case "discord": - return asString(config.token) !== "" + return hasValue("token") case "slack": - return asString(config.bot_token) !== "" + return hasValue("bot_token") case "feishu": - return ( - asString(config.app_id) !== "" && asString(config.app_secret) !== "" - ) + return hasValue("app_id") && hasValue("app_secret") case "dingtalk": - return ( - asString(config.client_id) !== "" && - asString(config.client_secret) !== "" - ) + return hasValue("client_id") && hasValue("client_secret") case "line": - return asString(config.channel_access_token) !== "" + return hasValue("channel_secret") && hasValue("channel_access_token") case "qq": - return ( - asString(config.app_id) !== "" && asString(config.app_secret) !== "" - ) + return hasValue("app_id") && hasValue("app_secret") case "onebot": - return asString(config.ws_url) !== "" + return hasValue("ws_url") case "weixin": - return asString(config.account_id) !== "" + return hasValue("account_id") case "wecom": - return asString(config.bot_id) !== "" + return hasValue("bot_id") case "whatsapp": - return asString(config.bridge_url) !== "" + return hasValue("bridge_url") case "whatsapp_native": return asBool(config.use_native) case "pico": - return asString(config.token) !== "" + return hasValue("token") case "maixcam": - return asString(config.host) !== "" + return hasValue("host") case "matrix": return ( - asString(config.homeserver) !== "" && - asString(config.user_id) !== "" && - asString(config.access_token) !== "" + hasValue("homeserver") && + hasValue("user_id") && + hasValue("access_token") ) case "irc": - return asString(config.server) !== "" + return hasValue("server") default: return false } @@ -242,21 +294,38 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { 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, appConfig] = await Promise.all([ - getChannelsCatalog(), - getAppConfig(), - ]) + const catalog = await getChannelsCatalog() + if (loadRequestIdRef.current !== requestId) return const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { - setChannel(null) + resetPageState() setFetchError( t("channels.page.notFound", { name: channelName, @@ -265,29 +334,37 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return } - const channelsConfig = asRecord(asRecord(appConfig).channels) - const raw = asRecord(channelsConfig[matched.config_key]) + 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(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) setLoading(false) + if (!silent && loadRequestIdRef.current === requestId) { + setLoading(false) + } } }, - [channelName, t], + [channelName, resetPageState, t], ) useEffect(() => { + resetPageState() + setLoading(true) loadData() - }, [loadData]) + }, [loadData, resetPageState]) const previousGatewayStatusRef = useRef(gatewayState) useEffect(() => { @@ -298,15 +375,21 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { previousGatewayStatusRef.current = gatewayState }, [gatewayState, loadData]) - const savePayload = useMemo(() => { - if (!channel) return null - return buildSavePayload(channel, editConfig, enabled) - }, [channel, editConfig, enabled]) - const configured = useMemo(() => { - if (!channel || !savePayload) return false - return isConfigured(channel, savePayload) - }, [channel, savePayload]) + 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 "" @@ -358,18 +441,53 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }) }, []) + 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 = () => { - setEditConfig(buildEditConfig(baseConfig)) + if (!channel) return + setEditConfig(buildEditConfig(channel.name, baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) } const handleSave = async () => { - if (!channel || !savePayload) return + if (!channel) return + + const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig) + if (preparedEditConfig !== editConfig) { + setEditConfig(preparedEditConfig) + } const missingRequiredFields = requiredKeys.filter((key) => - isMissingRequiredValue(savePayload[key]), + isMissingRequiredValue( + getFieldValueForValidation(preparedEditConfig, configuredSecrets, key), + ), ) if (missingRequiredFields.length > 0) { const requiredFieldError = t("channels.validation.requiredField") @@ -386,12 +504,20 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) try { + const savePayload = buildSavePayload(channel, preparedEditConfig, enabled) await patchAppConfig({ - channels: { + 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") @@ -453,8 +579,10 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) case "discord": @@ -462,8 +590,10 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) case "slack": @@ -471,8 +601,10 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) case "feishu": @@ -480,8 +612,10 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) case "weixin": @@ -491,6 +625,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} isEdit={isEdit} onBindSuccess={() => void handleWeixinBindSuccess()} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "wecom": @@ -507,10 +643,12 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) @@ -519,10 +657,12 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) } @@ -533,19 +673,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { - {enabled ? ( - - {t("channels.page.enabled")} - - ) : configured ? ( - - {t("channels.status.configured")} - - ) : null} -
- ) : undefined + channel && + docsUrl && ( + + {t("channels.page.docLink")} + + ) } /> @@ -559,46 +697,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { {fetchError}
) : ( -
-
-

- {t("channels.edit", { - name: channelDisplayName, - })} -

- {channel && docsUrl && ( - - {t("channels.page.docLink")} - - )} -
- - {channel?.name === "weixin" && ( -
-
- -
-

- {t("channels.weixin.warningTitle")} -

-

- {t("channels.weixin.warningDesc")} -

-
-
-
- )} - +
{!hidesPageLevelEnableToggle && ( -
+

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

@@ -612,11 +713,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {

{serverError}

)} + {isDirty && ( + + )} +
- -
diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index 300175e20..d2a98d325 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,26 +1,35 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +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 - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asBool(value: unknown): boolean { return value === true } @@ -35,75 +44,78 @@ function asRecord(value: unknown): Record { export function DiscordForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) - const tokenExtraHint = - isEdit && asString(config.token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_token", v)} - placeholder={maskedSecretPlaceholder( - config.token, - t("channels.field.tokenPlaceholder"), - )} - /> - +
+ + + + 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", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + 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")} - /> +
+ { + 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 index 386adf9a5..ed49e29cf 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -1,15 +1,30 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +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 - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { @@ -20,112 +35,148 @@ function asBool(value: unknown): boolean { return typeof value === "boolean" ? value : false } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} } export function FeishuForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: FeishuFormProps) { const { t } = useTranslation() - const appSecretExtraHint = - isEdit && asString(config.app_secret) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const verificationExtraHint = - isEdit && asString(config.verification_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const encryptExtraHint = - isEdit && asString(config.encrypt_key) - ? ` ${t("channels.field.secretHintSet")}` - : "" + const groupTriggerConfig = asRecord(config.group_trigger) return ( -
- - onChange("app_id", e.target.value)} - placeholder="cli_xxxx" - /> - +
+ + + + onChange("app_id", e.target.value)} + placeholder="cli_xxxx" + /> + - - onChange("_app_secret", v)} - placeholder={maskedSecretPlaceholder( - config.app_secret, - t("channels.field.secretPlaceholder"), - )} - /> - + + onChange("_app_secret", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_secret", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + - - onChange("_verification_token", v)} - placeholder={maskedSecretPlaceholder( - config.verification_token, - t("channels.field.secretPlaceholder"), - )} - /> - - - onChange("_encrypt_key", v)} - placeholder={maskedSecretPlaceholder( - config.encrypt_key, - t("channels.field.secretPlaceholder"), - )} - /> - - onChange("is_lark", checked)} - /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + 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 index 936802944..c8ee3f69f 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -1,39 +1,36 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +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 - isEdit: boolean + configuredSecrets?: string[] hiddenKeys?: string[] requiredKeys?: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } -// Secret field names that should use masked input. -const SECRET_FIELDS = new Set([ - "token", - "app_secret", - "client_secret", - "corp_secret", - "channel_secret", - "channel_access_token", - "access_token", - "bot_token", - "app_token", - "encoding_aes_key", - "encrypt_key", - "verification_token", - "secret", - "password", - "nickserv_password", - "sasl_password", -]) - // Fields to skip in the generic form (handled by enabled toggle or internal). const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"]) @@ -64,11 +61,6 @@ function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -83,10 +75,12 @@ function asBool(value: unknown): boolean { export function GenericForm({ config, onChange, - isEdit, + configuredSecrets = [], hiddenKeys = [], requiredKeys = [], fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: GenericFormProps) { const { t } = useTranslation() const hiddenFieldSet = new Set(hiddenKeys) @@ -96,7 +90,7 @@ export function GenericForm({ const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) - const fields = Object.keys(config).filter( + const rawFields = Object.keys(config).filter( (k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && @@ -160,231 +154,263 @@ export function GenericForm({ ) } - return ( -
- {fields.map((key) => { - const isRequired = requiredFieldSet.has(key) - if (SECRET_FIELDS.has(key)) { - const editKey = `_${key}` - const extraHint = - isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : "" - return ( - - onChange(editKey, v)} - placeholder={maskedSecretPlaceholder(config[key])} - /> - - ) - } - - const value = config[key] - if (typeof value === "boolean") { - return ( - onChange(key, checked)} - ariaLabel={formatLabel(key)} - /> - ) - } - - if (Array.isArray(value)) { - return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - - ) - } - - return ( - - { - // Attempt to preserve number types - const v = e.target.value - if (typeof config[key] === "number") { - onChange(key, v === "" ? 0 : Number(v)) - } else { - onChange(key, v) - } - }} - /> - - ) - })} - - {/* Allow From field */} - {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( + const renderField = (key: string) => { + const isRequired = requiredFieldSet.has(key) + if (isSecretField(key)) { + const editKey = `_${key}` + return ( - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} + onChange(editKey, v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + key, + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} /> - )} + ) + } - {config.allow_origins !== undefined && - !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - - )} - - {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: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - - - )} - - {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( + const value = config[key] + if (typeof value === "boolean") { + return ( - onChange("typing", { ...typingConfig, enabled: checked }) - } - ariaLabel={t("channels.field.typingEnabled")} + key={key} + label={formatLabel(key)} + hint={buildHint(key)} + error={fieldErrors[key]} + checked={value} + onCheckedChange={(checked) => 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)} + + )} - {config.placeholder !== undefined && - !hiddenFieldSet.has("placeholder") && ( - - onChange("placeholder", { - ...placeholderConfig, - enabled: checked, - }) - } - ariaLabel={t("channels.field.placeholderEnabled")} - > - {placeholderEnabled && ( -
- - onChange("placeholder", { - ...placeholderConfig, - text: e.target.value, - }) + {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 }) } - placeholder={t("channels.field.placeholderText")} - aria-label={t("channels.field.placeholderText")} + 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 index 54650e842..b8184e8bc 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,86 +1,99 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +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 { Input } from "@/components/ui/input" +import { Card, CardContent } from "@/components/ui/card" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function SlackForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: SlackFormProps) { const { t } = useTranslation() - const botTokenExtraHint = - isEdit && asString(config.bot_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const appTokenExtraHint = - isEdit && asString(config.app_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_bot_token", v)} - placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")} - /> - +
+ + + + onChange("_bot_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "bot_token", + t("channels.field.secretHintSet"), + "xoxb-xxxx", + )} + /> + - - onChange("_app_token", v)} - placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")} - /> - + + onChange("_app_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_token", + t("channels.field.secretHintSet"), + "xapp-xxxx", + )} + /> + + + - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + 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 index 169ddec63..f9c7c778a 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,26 +1,35 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +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 - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -35,113 +44,119 @@ function asBool(value: unknown): boolean { export function TelegramForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) - const tokenExtraHint = - isEdit && asString(config.token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_token", v)} - placeholder={maskedSecretPlaceholder( - config.token, - t("channels.field.tokenPlaceholder"), - )} - /> - +
+ + + + 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", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - - - - 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, - }) + value={asString(config.base_url)} + onChange={(e) => 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 }) } - placeholder={t("channels.field.placeholderText")} - aria-label={t("channels.field.placeholderText")} + 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 index 744c87ba2..c21ac318a 100644 --- a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx @@ -11,6 +11,13 @@ 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 = @@ -123,9 +130,10 @@ export function WecomForm({ setToggleError("") try { await patchAppConfig({ - channels: { + channel_list: { wecom: { enabled: checked, + type: "wecom", }, }, }) @@ -329,39 +337,32 @@ export function WecomForm({ } return ( -
-
-
-
-

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

-

- {isBound - ? t("channels.wecom.enableDesc") - : t("channels.wecom.enableBindFirst")} -

-
+
+
+

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

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

+ {toggleError} +

+ )}
- {toggleError && ( -

{toggleError}

- )}
-
-
-

{t("channels.wecom.bindTitle")}

-

- {t("channels.wecom.bindDesc")} -

-
- {renderBindSection()} -
+ + + + {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 index 20e66ffc2..29700f094 100644 --- a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx @@ -10,8 +10,23 @@ 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 = @@ -28,22 +43,24 @@ interface WeixinFormProps { 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 : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function WeixinForm({ config, onChange, isEdit, onBindSuccess, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: WeixinFormProps) { const { t } = useTranslation() @@ -301,51 +318,43 @@ export function WeixinForm({ } return ( -
- {/* QR Bind Section */} -
-
-

+

+ + + {t("channels.weixin.bindTitle")} -

-

- {t("channels.weixin.bindDesc")} -

-
- {renderBindSection()} -
+ + {t("channels.weixin.bindDesc")} + + {renderBindSection()} + - {/* allow_from */} - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> - {/* proxy */} - - onChange("proxy", e.target.value)} - placeholder="http://localhost:7890" - /> - + + 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 index 05da3ceb1..157ca636f 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -1,69 +1,301 @@ -import { IconCheck, IconCopy } from "@tabler/icons-react" +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 = () => { - navigator.clipboard.writeText(content).then(() => { + 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 (
-
-
- PicoClaw - {formattedTimestamp && ( - <> - - {formattedTimestamp} - - )} + {!isCollapsedBlock && ( +
+
+ PicoClaw + {formattedTimestamp && ( + <> + + {formattedTimestamp} + + )} +
-
+ )} -
-
- - {content} - -
- + )} +
+ )} + + {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 index 7d696b898..b3354cc33 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -1,28 +1,62 @@ -import { IconArrowUp } from "@tabler/icons-react" +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 - isConnected: boolean - hasDefaultModel: boolean + onContextDetail?: () => void + inputDisabledReason: ChatInputDisabledReason | null + canSend: boolean + contextUsage?: ContextUsage } export function ChatComposer({ input, + attachments, onInputChange, + onAddImages, + onRemoveAttachment, onSend, - isConnected, - hasDefaultModel, + onContextDetail, + inputDisabledReason, + canSend, + contextUsage, }: ChatComposerProps) { const { t } = useTranslation() - const canInput = isConnected && hasDefaultModel + 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 @@ -33,16 +67,43 @@ export function ChatComposer({ } return ( -
-
+
+
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => ( +
+ {attachment.filename + +
+ ))} +
+ )} + onInputChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t("chat.placeholder")} + placeholder={placeholder} disabled={!canInput} + title={disabledMessage || undefined} className={cn( - "placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", + "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} @@ -50,16 +111,50 @@ export function ChatComposer({ />
-
{/* action buttons */}
+
+ +
- +
+ {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 index 0574c44d1..7e1abca17 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -10,19 +10,19 @@ import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" interface ChatEmptyStateProps { - hasConfiguredModels: boolean + hasAvailableModels: boolean defaultModelName: string isConnected: boolean } export function ChatEmptyState({ - hasConfiguredModels, + hasAvailableModels, defaultModelName, isConnected, }: ChatEmptyStateProps) { const { t } = useTranslation() - if (!hasConfiguredModels) { + if (!hasAvailableModels) { return (
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index ebcde8981..3ad811dae 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -1,9 +1,14 @@ import { IconPlus } from "@tabler/icons-react" -import { useEffect, useRef, useState } from "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 } from "@/components/chat/chat-composer" +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" @@ -11,23 +16,112 @@ 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, @@ -35,17 +129,22 @@ export function ChatPage() { const { state: gwState } = useGateway() const isGatewayRunning = gwState === "running" - const isChatConnected = connectionState === "connected" const { defaultModelName, - hasConfiguredModels, + hasAvailableModels, apiKeyModels, oauthModels, localModels, handleSetDefault, } = useChatModels({ isConnected: isGatewayRunning }) - const canSend = isChatConnected && Boolean(defaultModelName) + const hasDefaultModel = Boolean(defaultModelName) + const inputDisabledReason = resolveChatInputDisabledReason({ + hasDefaultModel, + connectionState, + gatewayState: gwState, + }) + const canInput = inputDisabledReason === null const { sessions, @@ -61,7 +160,7 @@ export function ChatPage() { }) const syncScrollState = (element: HTMLDivElement) => { - const { scrollTop, scrollHeight, clientHeight } = element + const { clientHeight, scrollHeight, scrollTop } = element setHasScrolled(scrollTop > 0) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) } @@ -80,21 +179,88 @@ export function ChatPage() { }, [messages, isTyping, isAtBottom]) const handleSend = () => { - if (!input.trim() || !canSend) return - if (sendMessage(input.trim())) { + 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 (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/session-history-menu.tsx b/web/frontend/src/components/chat/session-history-menu.tsx index 009e8fbb9..3ec1a5ed2 100644 --- a/web/frontend/src/components/chat/session-history-menu.tsx +++ b/web/frontend/src/components/chat/session-history-menu.tsx @@ -71,7 +71,7 @@ export function SessionHistoryMenu({ onClick={() => onSwitchSession(session.id)} > - {session.title || session.preview} + {session.title} {t("chat.messagesCount", { diff --git a/web/frontend/src/components/chat/typing-indicator.tsx b/web/frontend/src/components/chat/typing-indicator.tsx index 98580963d..df138553c 100644 --- a/web/frontend/src/components/chat/typing-indicator.tsx +++ b/web/frontend/src/components/chat/typing-indicator.tsx @@ -21,10 +21,7 @@ export function TypingIndicator() { return (
-
- PicoClaw -
-
+
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index 2baee87f3..8bfdf24c9 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -1,13 +1,54 @@ +import { cn } from "@/lib/utils" +import type { ChatAttachment } from "@/store/chat" + interface UserMessageProps { content: string + attachments?: ChatAttachment[] } -export function UserMessage({ content }: UserMessageProps) { +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 (
-
- {content} -
+ {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 index cbe4d8e91..0b5665640 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -1,4 +1,4 @@ -import { IconCode, IconDeviceFloppy } from "@tabler/icons-react" +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" @@ -7,12 +7,15 @@ 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, @@ -32,7 +35,9 @@ import { 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() { @@ -64,6 +69,12 @@ export function ConfigPage() { queryFn: getLauncherConfig, }) + const { data: versionInfo } = useQuery({ + queryKey: ["system", "version"], + queryFn: getSystemVersionInfo, + staleTime: 5 * 60 * 1000, + }) + const { data: autoStartStatus, isLoading: isAutoStartLoading, @@ -86,6 +97,8 @@ export function ConfigPage() { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), + dashboardPassword: "", + dashboardPasswordConfirm: "", } setLauncherForm(parsed) setLauncherBaseline(parsed) @@ -98,8 +111,14 @@ export function ConfigPage() { }, [autoStartStatus]) const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) - const launcherDirty = - JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline) + 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 @@ -134,6 +153,19 @@ export function ConfigPage() { 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() @@ -214,6 +246,7 @@ export function ConfigPage() { tool_feedback: { enabled: form.toolFeedbackEnabled, max_args_length: toolFeedbackMaxArgsLength, + separate_messages: form.toolFeedbackSeparateMessages, }, max_tokens: maxTokens, context_window: contextWindow, @@ -246,7 +279,8 @@ export function ConfigPage() { queryClient.invalidateQueries({ queryKey: ["config"] }) } - if (launcherDirty) { + let savedLauncherForm: LauncherForm | null = null + if (launcherSettingsDirty) { const port = parseIntField(launcherForm.port, "Service port", { min: 1, max: 65535, @@ -263,7 +297,10 @@ export function ConfigPage() { allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), + dashboardPassword: "", + dashboardPasswordConfirm: "", } + savedLauncherForm = parsedLauncher setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) queryClient.setQueryData( @@ -272,6 +309,23 @@ export function ConfigPage() { ) } + 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")) @@ -282,8 +336,13 @@ export function ConfigPage() { queryClient.setQueryData(["system", "autostart"], status) } - toast.success(t("pages.config.save_success")) - void refreshGatewayState({ force: true }) + 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"), @@ -293,10 +352,37 @@ export function ConfigPage() { } } + const actionButtons = ( +
+ + +
+ ) + return (
+ + {versionInfo.version} + + ) + } children={
) : (
- {isDirty && ( -
- {t("pages.config.unsaved_changes")} -
- )} + @@ -332,12 +418,6 @@ export function ConfigPage() { - - -
- - -
+ {!isDirty && actionButtons}
)}
+ {isDirty && ( +
+
+
+ +
+ {actionButtons} +
+
+ )}
) } diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index b5bec8acd..fa6b3a079 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -100,9 +100,7 @@ export function AgentDefaultsSection({ hint={t("pages.config.split_on_marker_hint")} layout="setting-row" checked={form.splitOnMarker} - onCheckedChange={(checked) => - onFieldChange("splitOnMarker", checked) - } + onCheckedChange={(checked) => onFieldChange("splitOnMarker", checked)} /> + {form.toolFeedbackEnabled && ( + + onFieldChange("toolFeedbackSeparateMessages", checked) + } + /> + )} + {form.toolFeedbackEnabled && ( + + + + onFieldChange("dashboardPassword", e.target.value) + } + /> + + + {launcherForm.dashboardPassword.trim() !== "" && ( + + + onFieldChange("dashboardPasswordConfirm", e.target.value) + } + /> + + )} + { - toast.success(t("pages.config.save_success")) try { const savedConfig = JSON.parse(submittedConfig) setLastSavedConfig(savedConfig) @@ -58,7 +59,14 @@ export function RawConfigPage() { } catch { queryClient.invalidateQueries({ queryKey: ["config"] }) } - void refreshGatewayState({ force: true }) + void refreshGatewayState({ force: true }).then((gateway) => { + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) + }) }, onError: () => { toast.error(t("pages.config.save_error")) @@ -141,9 +149,12 @@ export function RawConfigPage() { ) : (
{isDirty && ( -
- {t("pages.config.unsaved_changes")} -
+ )}