From 3e57679c696547675c612be6ce442b8c509f825b Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:13:31 +0900 Subject: [PATCH] merge: upstream/main into fork (2026-03-19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge 74 upstream commits. Key changes: - API key failover, ModeMarkdownV2, SpawnStatusTool - macOS systray, SecureStore encryption - Environment variable constants consolidation - Cron event-driven refactor, web frontend refactor - Action version updates (v4→v6, v3→v4) Conflict resolution: kept fork architecture (LLM worker queue, plan mode, orchestration, worktrees) + integrated upstream's new features (SenderID/DisplayName, allowPaths, native search, HTTP server, exit code extraction). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/docker-build.yml | 8 +- .github/workflows/nightly.yml | 8 +- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 8 +- .gitignore | 3 + Makefile | 67 +- README.fr.md | 1159 +------------ README.ja.md | 1179 ++------------ README.md | 1431 +---------------- README.pt-br.md | 1222 ++------------ README.vi.md | 1214 ++------------ README.zh.md | 880 ++-------- assets/wechat.png | Bin 352888 -> 162306 bytes cmd/picoclaw/internal/gateway/command.go | 12 +- cmd/picoclaw/internal/gateway/command_test.go | 1 + cmd/picoclaw/internal/helpers.go | 4 +- cmd/picoclaw/internal/onboard/command.go | 7 +- cmd/picoclaw/internal/onboard/command_test.go | 5 +- cmd/picoclaw/internal/onboard/helpers.go | 133 +- config/config.example.json | 17 +- docs/channels/feishu/README.zh.md | 24 +- docs/chat-apps.md | 431 +++++ docs/configuration.md | 218 +++ docs/credential_encryption.md | 168 ++ docs/docker.md | 166 ++ docs/fr/chat-apps.md | 588 +++++++ docs/fr/configuration.md | 217 +++ docs/fr/docker.md | 166 ++ docs/fr/providers.md | 434 +++++ docs/fr/spawn-tasks.md | 61 + docs/fr/tools_configuration.md | 336 ++++ docs/fr/troubleshooting.md | 45 + docs/ja/chat-apps.md | 574 +++++++ docs/ja/configuration.md | 256 +++ docs/ja/docker.md | 168 ++ docs/ja/providers.md | 434 +++++ docs/ja/spawn-tasks.md | 68 + docs/ja/tools_configuration.md | 336 ++++ docs/ja/troubleshooting.md | 45 + docs/providers.md | 436 +++++ docs/pt-br/chat-apps.md | 427 +++++ docs/pt-br/configuration.md | 217 +++ docs/pt-br/docker.md | 166 ++ docs/pt-br/providers.md | 434 +++++ docs/pt-br/spawn-tasks.md | 61 + docs/pt-br/tools_configuration.md | 336 ++++ docs/pt-br/troubleshooting.md | 45 + docs/spawn-tasks.md | 61 + docs/tools_configuration.md | 25 + docs/vi/chat-apps.md | 427 +++++ docs/vi/configuration.md | 217 +++ docs/vi/docker.md | 166 ++ docs/vi/providers.md | 434 +++++ docs/vi/spawn-tasks.md | 61 + docs/vi/tools_configuration.md | 336 ++++ docs/vi/troubleshooting.md | 45 + docs/zh/chat-apps.md | 574 +++++++ docs/zh/configuration.md | 256 +++ docs/zh/docker.md | 168 ++ docs/zh/providers.md | 428 +++++ docs/zh/spawn-tasks.md | 68 + docs/zh/tools_configuration.md | 336 ++++ docs/zh/troubleshooting.md | 45 + go.mod | 18 +- go.sum | 31 +- pkg/agent/context.go | 29 +- pkg/agent/context_cache_test.go | 68 +- pkg/agent/instance.go | 28 +- pkg/agent/instance_test.go | 86 + pkg/agent/loop.go | 44 +- pkg/agent/loop_run.go | 8 +- pkg/agent/loop_test.go | 267 ++- pkg/auth/store.go | 3 +- pkg/bus/bus.go | 141 +- pkg/bus/bus_test.go | 52 +- pkg/channels/feishu/feishu_64.go | 48 +- pkg/channels/feishu/token_cache.go | 52 + pkg/channels/manager.go | 166 +- pkg/channels/matrix/matrix.go | 4 +- pkg/channels/matrix/matrix_test.go | 3 +- pkg/channels/pico/pico.go | 31 +- pkg/channels/qq/qq_test.go | 20 +- .../telegram/parse_markdown_to_md_v2.go | 197 +++ .../telegram/parse_markdown_to_md_v2_test.go | 68 + .../telegram/parser_markdown_to_html.go | 111 ++ pkg/channels/telegram/telegram.go | 257 +-- .../telegram/telegram_dispatch_test.go | 6 +- .../telegram_group_command_filter_test.go | 28 +- pkg/channels/telegram/telegram_test.go | 211 ++- .../telegram/testdata/md2_all_formats.txt | 31 + .../whatsapp/whatsapp_command_test.go | 6 +- .../whatsapp_native/whatsapp_command_test.go | 23 +- pkg/config/config.go | 214 ++- pkg/config/config_test.go | 446 ++++- pkg/config/defaults.go | 14 +- pkg/config/envkeys.go | 37 + pkg/config/model_config_test.go | 30 + pkg/config/multikey_test.go | 291 ++++ pkg/credential/credential.go | 342 ++++ pkg/credential/credential_test.go | 283 ++++ pkg/credential/keygen.go | 62 + pkg/credential/keygen_test.go | 115 ++ pkg/credential/store.go | 44 + pkg/credential/store_test.go | 81 + pkg/cron/service.go | 77 +- pkg/cron/service_test.go | 199 +++ .../helpers.go => pkg/gateway/gateway.go | 371 ++--- pkg/health/server.go | 3 + pkg/logger/logger.go | 58 +- pkg/logger/logger_3rd_party.go | 37 +- pkg/media/tempdir.go | 13 + pkg/migrate/internal/common.go | 4 +- .../sources/openclaw/openclaw_config.go | 26 +- .../sources/openclaw/openclaw_handler.go | 7 +- pkg/providers/anthropic/provider.go | 4 + pkg/providers/anthropic_messages/provider.go | 8 +- pkg/providers/claude_cli_provider.go | 12 +- pkg/providers/codex_cli_credentials.go | 7 +- pkg/providers/codex_provider.go | 9 +- pkg/providers/codex_provider_test.go | 4 +- pkg/providers/factory_provider.go | 8 +- pkg/providers/factory_provider_test.go | 29 + pkg/providers/fallback.go | 16 +- pkg/providers/fallback_multikey_test.go | 384 +++++ pkg/providers/fallback_test.go | 15 +- pkg/providers/http_provider.go | 4 + pkg/providers/openai_compat/provider.go | 36 +- pkg/providers/openai_compat/provider_test.go | 291 +++- pkg/providers/types.go | 9 + pkg/tools/cron.go | 73 +- pkg/tools/cron_test.go | 135 +- pkg/tools/filesystem.go | 182 ++- pkg/tools/filesystem_test.go | 84 + pkg/tools/registry.go | 71 +- pkg/tools/registry_test.go | 263 +++ pkg/tools/send_file.go | 17 +- pkg/tools/send_file_test.go | 39 + pkg/tools/shell.go | 66 +- pkg/tools/shell_test.go | 63 + pkg/tools/spawn_status.go | 178 ++ pkg/tools/spawn_status_test.go | 408 +++++ pkg/tools/subagent.go | 25 + pkg/tools/subagent_preset.go | 2 +- pkg/tools/web.go | 222 ++- pkg/tools/web_test.go | 190 ++- pkg/utils/markdown.go | 411 +++++ pkg/utils/markdown_test.go | 245 +++ pkg/utils/media.go | 3 +- scripts/build-macos-app.sh | 106 ++ scripts/icon.icns | Bin 0 -> 16192 bytes scripts/setup.iss | 65 + web/Makefile | 70 +- web/backend/api/config.go | 22 + web/backend/api/config_test.go | 79 + web/backend/api/events.go | 65 - web/backend/api/gateway.go | 570 ++++--- web/backend/api/gateway_host.go | 44 +- web/backend/api/gateway_host_test.go | 133 +- web/backend/api/gateway_test.go | 183 ++- web/backend/api/oauth.go | 6 +- web/backend/api/pico.go | 49 +- web/backend/api/pico_test.go | 314 ++++ web/backend/api/router.go | 5 + web/backend/api/skills.go | 4 +- web/backend/api/tools.go | 14 +- web/backend/app_runtime.go | 62 + web/backend/embed.go | 12 +- web/backend/i18n.go | 120 ++ web/backend/icon.png | Bin 0 -> 104580 bytes web/backend/main.go | 140 +- web/backend/middleware/middleware.go | 13 +- web/backend/systray.go | 100 ++ web/backend/systray_unix.go | 8 + web/backend/systray_windows.go | 8 + web/backend/tray_stub_nocgo.go | 33 + web/backend/utils/onboard.go | 4 +- web/backend/utils/runtime.go | 25 +- web/frontend/package.json | 16 +- web/frontend/pnpm-lock.yaml | 155 +- .../scripts/ensure-backend-gitkeep.cjs | 9 + web/frontend/src/components/app-header.tsx | 34 +- .../channels/channel-forms/feishu-form.tsx | 12 +- .../src/components/chat/chat-composer.tsx | 4 +- .../src/components/chat/chat-empty-state.tsx | 2 +- .../src/components/chat/chat-page.tsx | 23 +- .../src/components/config/config-page.tsx | 40 +- .../src/components/config/config-sections.tsx | 138 +- .../src/components/config/form-model.ts | 55 + .../chat/controller.ts} | 338 ++-- web/frontend/src/features/chat/history.ts | 68 + web/frontend/src/features/chat/protocol.ts | 81 + .../chat/state.ts} | 0 web/frontend/src/features/chat/websocket.ts | 57 + web/frontend/src/hooks/use-gateway-logs.ts | 4 +- web/frontend/src/hooks/use-gateway.ts | 118 +- web/frontend/src/hooks/use-pico-chat.ts | 4 +- web/frontend/src/hooks/use-websocket.ts | 47 - web/frontend/src/i18n/locales/en.json | 26 +- web/frontend/src/i18n/locales/zh.json | 26 +- web/frontend/src/routes/__root.tsx | 2 +- web/frontend/src/store/chat.ts | 2 +- web/frontend/src/store/gateway.ts | 156 +- 202 files changed, 21545 insertions(+), 8294 deletions(-) create mode 100644 docs/chat-apps.md create mode 100644 docs/configuration.md create mode 100644 docs/credential_encryption.md create mode 100644 docs/docker.md create mode 100644 docs/fr/chat-apps.md create mode 100644 docs/fr/configuration.md create mode 100644 docs/fr/docker.md create mode 100644 docs/fr/providers.md create mode 100644 docs/fr/spawn-tasks.md create mode 100644 docs/fr/tools_configuration.md create mode 100644 docs/fr/troubleshooting.md create mode 100644 docs/ja/chat-apps.md create mode 100644 docs/ja/configuration.md create mode 100644 docs/ja/docker.md create mode 100644 docs/ja/providers.md create mode 100644 docs/ja/spawn-tasks.md create mode 100644 docs/ja/tools_configuration.md create mode 100644 docs/ja/troubleshooting.md create mode 100644 docs/providers.md create mode 100644 docs/pt-br/chat-apps.md create mode 100644 docs/pt-br/configuration.md create mode 100644 docs/pt-br/docker.md create mode 100644 docs/pt-br/providers.md create mode 100644 docs/pt-br/spawn-tasks.md create mode 100644 docs/pt-br/tools_configuration.md create mode 100644 docs/pt-br/troubleshooting.md create mode 100644 docs/spawn-tasks.md create mode 100644 docs/vi/chat-apps.md create mode 100644 docs/vi/configuration.md create mode 100644 docs/vi/docker.md create mode 100644 docs/vi/providers.md create mode 100644 docs/vi/spawn-tasks.md create mode 100644 docs/vi/tools_configuration.md create mode 100644 docs/vi/troubleshooting.md create mode 100644 docs/zh/chat-apps.md create mode 100644 docs/zh/configuration.md create mode 100644 docs/zh/docker.md create mode 100644 docs/zh/providers.md create mode 100644 docs/zh/spawn-tasks.md create mode 100644 docs/zh/tools_configuration.md create mode 100644 docs/zh/troubleshooting.md create mode 100644 pkg/channels/feishu/token_cache.go create mode 100644 pkg/channels/telegram/parse_markdown_to_md_v2.go create mode 100644 pkg/channels/telegram/parse_markdown_to_md_v2_test.go create mode 100644 pkg/channels/telegram/parser_markdown_to_html.go create mode 100644 pkg/channels/telegram/testdata/md2_all_formats.txt create mode 100644 pkg/config/envkeys.go create mode 100644 pkg/config/multikey_test.go create mode 100644 pkg/credential/credential.go create mode 100644 pkg/credential/credential_test.go create mode 100644 pkg/credential/keygen.go create mode 100644 pkg/credential/keygen_test.go create mode 100644 pkg/credential/store.go create mode 100644 pkg/credential/store_test.go rename cmd/picoclaw/internal/gateway/helpers.go => pkg/gateway/gateway.go (71%) create mode 100644 pkg/media/tempdir.go create mode 100644 pkg/providers/fallback_multikey_test.go create mode 100644 pkg/tools/spawn_status.go create mode 100644 pkg/tools/spawn_status_test.go create mode 100644 pkg/utils/markdown.go create mode 100644 pkg/utils/markdown_test.go create mode 100755 scripts/build-macos-app.sh create mode 100644 scripts/icon.icns create mode 100644 scripts/setup.iss delete mode 100644 web/backend/api/events.go create mode 100644 web/backend/api/pico_test.go create mode 100644 web/backend/app_runtime.go create mode 100644 web/backend/i18n.go create mode 100644 web/backend/icon.png create mode 100644 web/backend/systray.go create mode 100644 web/backend/systray_unix.go create mode 100644 web/backend/systray_windows.go create mode 100644 web/backend/tray_stub_nocgo.go create mode 100644 web/frontend/scripts/ensure-backend-gitkeep.cjs rename web/frontend/src/{lib/pico-chat-controller.ts => features/chat/controller.ts} (52%) create mode 100644 web/frontend/src/features/chat/history.ts create mode 100644 web/frontend/src/features/chat/protocol.ts rename web/frontend/src/{lib/pico-chat-state.ts => features/chat/state.ts} (100%) create mode 100644 web/frontend/src/features/chat/websocket.ts delete mode 100644 web/frontend/src/hooks/use-websocket.ts diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index dadbed212..784c404a6 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,11 +31,11 @@ jobs: # ── Docker Buildx ───────────────────────── - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # ── Login to GHCR ───────────────────────── - name: 🔑 Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} @@ -43,7 +43,7 @@ jobs: # ── Login to Docker Hub ──────────────────── - name: 🔑 Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -62,7 +62,7 @@ jobs: # ── Build & Push ────────────────────────── - name: 🚀 Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8996b1c8e..f59d19eee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -48,7 +48,7 @@ jobs: go-version-file: go.mod - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 @@ -65,17 +65,17 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d69928d82..0003bcd4a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -40,7 +40,7 @@ jobs: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a584773d..19c8e5404 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: go-version-file: go.mod - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 @@ -77,17 +77,17 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.gitignore b/.gitignore index 7b2340673..d5691562f 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ dist/ # Windows Application Icon/Resource *.syso +# Test telegram integration +cmd/telegram/ + # Keep embedded backend dist directory placeholder in VCS !web/backend/dist/ web/backend/dist/* diff --git a/Makefile b/Makefile index 23a89b8e2..0d1bb83ab 100644 --- a/Makefile +++ b/Makefile @@ -12,10 +12,11 @@ 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}') CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config -LDFLAGS=-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" +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 +WEB_GO?=$(GO) GOFLAGS?=-v -tags stdjson # Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600). @@ -79,6 +80,7 @@ ifeq ($(UNAME_S),Linux) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin + WEB_GO=CGO_ENABLED=1 go ifeq ($(UNAME_M),x86_64) ARCH=amd64 else ifeq ($(UNAME_M),arm64) @@ -107,7 +109,7 @@ generate: build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_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) @@ -119,7 +121,7 @@ build-launcher: echo "Building frontend..."; \ cd web/frontend && pnpm install && pnpm build:backend; \ fi - @$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend + @$(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" @@ -128,16 +130,16 @@ build-whatsapp-native: generate ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) - GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) - GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) - GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) -## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) +## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete" ## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @@ -145,21 +147,21 @@ build-whatsapp-native: generate build-linux-arm: generate @echo "Building for linux/arm (GOARM=7)..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm" ## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit) build-linux-arm64: generate @echo "Building for linux/arm64..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" ## build-linux-mipsle: Build for Linux MIPS32 LE build-linux-mipsle: generate @echo "Building for linux/mipsle (softfloat)..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle" @@ -171,15 +173,15 @@ build-pi-zero: build-linux-arm build-linux-arm64 build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) - GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) - GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) # mipsle skipped: modernc.org/sqlite (CGO-free) does not support mipsle via modernc.org/libc - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) - GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) # netbsd skipped: modernc.org/sqlite has build issues on netbsd (mutex API mismatch) @echo "All builds complete" @@ -217,11 +219,14 @@ clean: ## vet: Run go vet for static analysis vet: generate - @$(GO) vet ./... + @packages="$$(go list ./...)" && \ + $(GO) vet $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/') + @cd web/backend && $(WEB_GO) vet ./... ## test: Test Go code test: generate - @$(GO) test ./... + @$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/) + @cd web && make test ## fmt: Format Go code fmt: @@ -290,6 +295,18 @@ docker-clean: docker compose -f docker/docker-compose.full.yml down -v docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true + +## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) +build-macos-app: + @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) + @echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app" + ## help: Show this help message help: @echo "picoclaw Makefile" diff --git a/README.fr.md b/README.fr.md index d5fe873bf..325c6c096 100644 --- a/README.fr.md +++ b/README.fr.md @@ -3,10 +3,10 @@

PicoClaw : Assistant IA Ultra-Efficace en Go

-

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

+

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

- Go - Hardware + Go + Hardware License
Website @@ -18,14 +18,17 @@ Discord

- [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** + --- -🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [nanobot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code. +> **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com). Il est entièrement écrit en **Go** — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet. -⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **10$** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini ! +🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code. + +⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **$10** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini ! @@ -46,39 +49,59 @@ > **🚨 SÉCURITÉ & CANAUX OFFICIELS** > > * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**. +> > * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**. -> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers et ne nous appartiennent pas. +> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers. > * **Attention :** PicoClaw est en phase de développement précoce et peut présenter des problèmes de sécurité réseau non résolus. Ne déployez pas en environnement de production avant la version v1.0. > * **Note :** PicoClaw a récemment fusionné de nombreuses PR, ce qui peut entraîner une empreinte mémoire plus importante (10–20 Mo) dans les dernières versions. Nous prévoyons de prioriser l'optimisation des ressources dès que l'ensemble des fonctionnalités sera stabilisé. - ## 📢 Actualités -2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir ! +2026-03-17 🚀 **v0.2.3 publié !** Interface système tray (Windows & Linux), suivi de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du gateway, portes de sécurité cron, et 2 correctifs de sécurité. PicoClaw atteint **25K ⭐** ! -2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw. -🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire. +2026-03-09 🎉 **v0.2.1 — Plus grande mise à jour !** Support du protocole MCP, 4 nouveaux canaux (Matrix/IRC/WeCom/Discord Proxy), 3 nouveaux fournisseurs (Kimi/Minimax/Avian), pipeline de vision, stockage mémoire JSONL, et routage de modèles. -2026-02-09 🎉 PicoClaw est lancé ! Construit en 1 jour pour apporter les Agents IA au matériel à 10$ avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti ! +2026-02-28 📦 **v0.2.0** publié avec support Docker Compose et lanceur Web UI. + +2026-02-26 🎉 PicoClaw a atteint **20K étoiles** en seulement 17 jours ! L'orchestration automatique des canaux et les interfaces de capacités sont arrivées. + +
+Actualités précédentes... + +2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Les rôles de mainteneurs communautaires et la [feuille de route](ROADMAP.md) sont officiellement publiés. + +2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! La Feuille de Route du Projet et le Groupe de Développeurs sont en cours de mise en place. + +2026-02-09 🎉 **PicoClaw est lancé !** Construit en 1 jour pour apporter les Agents IA au matériel à $10 avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti ! + +
## ✨ Fonctionnalités -🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que Clawdbot pour les fonctionnalités essentielles. +🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que les fonctionnalités essentielles d'OpenClaw.* -💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à 10$ — 98% moins cher qu'un Mac mini. +💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à $10 — 98% moins cher qu'un Mac mini. -⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz. +⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en <1 seconde même sur un cœur unique à 0,6 GHz. 🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti ! 🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle. +🔌 **Support MCP** : Intégration native du [Model Context Protocol](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de l'agent. + +👁️ **Pipeline de Vision** : Envoyez des images et fichiers directement à l'agent — encodage base64 automatique pour les LLM multimodaux. + +🧠 **Routage Intelligent** : Routage de modèles basé sur des règles — les requêtes simples vont vers des modèles légers, économisant les coûts API. + +_*Les versions récentes peuvent utiliser 10–20 Mo en raison des fusions rapides de fonctionnalités. L'optimisation des ressources est prévue. La comparaison de démarrage est basée sur des benchmarks à cœur unique 0,8 GHz (voir tableau ci-dessous)._ + | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Langage** | TypeScript | Python | **Go** | -| **RAM** | >1 Go | >100 Mo | **< 10 Mo** | +| **RAM** | >1 Go | >100 Mo | **< 10 Mo*** | | **Démarrage**
(cœur 0,8 GHz) | >500s | >30s | **<1s** | -| **Coût** | Mac Mini 599$ | La plupart des SBC Linux
~50$ | **N'importe quelle carte Linux**
**À partir de 10$** | +| **Coût** | Mac Mini $599 | La plupart des SBC Linux
~$50 | **N'importe quelle carte Linux**
**À partir de $10** | PicoClaw @@ -108,15 +131,15 @@ Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide : -1. **Installez Termux** (disponible sur F-Droid ou Google Play). +1. **Installez [Termux](https://github.com/termux/termux-app)** (Téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou recherchez sur F-Droid / Google Play). 2. **Exécutez les commandes** ```bash -# Note : Remplacez v0.1.1 par la dernière version depuis la page des Releases -wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 -chmod +x picoclaw-linux-arm64 +# Téléchargez la dernière version depuis https://github.com/sipeed/picoclaw/releases +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-linux-arm64 onboard +termux-chroot ./picoclaw onboard ``` Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration ! @@ -128,7 +151,7 @@ Puis suivez les instructions de la section « Démarrage Rapide » pour terminer PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux ! - 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste -- 30~50$ [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs +- 30~$50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs - 50$ [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou 100$ [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) pour la Surveillance Intelligente @@ -139,7 +162,7 @@ PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux ! ### Installer avec un binaire précompilé -Téléchargez le binaire pour votre plateforme depuis la page des [releases](https://github.com/sipeed/picoclaw/releases). +Téléchargez le binaire pour votre plateforme depuis la page des [Releases](https://github.com/sipeed/picoclaw/releases). ### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement) @@ -155,457 +178,28 @@ make build # Compiler pour plusieurs plateformes make build-all +# Compiler pour Raspberry Pi Zero 2 W (32-bit : make build-linux-arm ; 64-bit : make build-linux-arm64) +make build-pi-zero + # Compiler et Installer make install ``` -## 🐳 Docker Compose +**Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32-bit → `make build-linux-arm` ; 64-bit → `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux. -Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement. +## 📚 Documentation -```bash -# 1. Clonez ce dépôt -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw +Pour des guides détaillés, consultez la documentation ci-dessous. Ce README ne couvre que le démarrage rapide. -# 2. Premier lancement — génère docker/data/config.json puis s'arrête -docker compose -f docker/docker-compose.yml --profile gateway up -# Le conteneur affiche "First-run setup complete." puis s'arrête. - -# 3. Configurez vos clés API -vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc. - -# 4. Démarrer -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. - -```bash -# 5. Voir les logs -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. Arrêter -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Mode Agent (exécution unique) - -```bash -# Poser une question -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?" - -# Mode interactif -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### Mettre à jour - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 Démarrage Rapide - -> [!TIP] -> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois). - -**1. Initialiser** - -```bash -picoclaw onboard -``` - -**2. Configurer** (`~/.picoclaw/config.json`) - -```json -{ - "model_list": [ - { - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "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": "sk-your-openai-key", - "request_timeout": 300, - "api_base": "https://api.openai.com/v1" - } - ], - "agents": { - "defaults": { - "model_name": "gpt-5.4" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "VOTRE_TOKEN_BOT", - "allow_from": ["VOTRE_USER_ID"] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "VOTRE_CLE_API_BRAVE", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails. -> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s). - -**3. Obtenir des Clés API** - -* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -* **Recherche Web** (optionnel) : [Brave Search](https://brave.com/search/api) - Offre gratuite disponible (2000 requêtes/mois) - -> **Note** : Consultez `config.example.json` pour un modèle de configuration complet. - -**4. Discuter** - -```bash -picoclaw agent -m "Combien font 2+2 ?" -``` - -Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes. - ---- - -## 💬 Applications de Chat - -Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom - -| Canal | Configuration | -| ------------ | -------------------------------------- | -| **Telegram** | Facile (juste un token) | -| **Discord** | Facile (token bot + intents) | -| **QQ** | Facile (AppID + AppSecret) | -| **DingTalk** | Moyen (identifiants de l'application) | -| **LINE** | Moyen (identifiants + URL de webhook) | -| **WeCom AI Bot** | Moyen (Token + clé AES) | - -
-Telegram (Recommandé) - -**1. Créer un bot** - -* Ouvrez Telegram, recherchez `@BotFather` -* Envoyez `/newbot`, suivez les instructions -* Copiez le token - -**2. Configurer** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "VOTRE_TOKEN_BOT", - "allow_from": ["VOTRE_USER_ID"] - } - } -} -``` - -> Obtenez votre User ID via `@userinfobot` sur Telegram. - -**3. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-Discord - -**1. Créer un bot** - -* Rendez-vous sur -* Créez une application → Bot → Add Bot -* Copiez le token du bot - -**2. Activer les intents** - -* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT** -* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous souhaitez utiliser des listes d'autorisation basées sur les données des membres - -**3. Obtenir votre User ID** - -* Paramètres Discord → Avancé → activez le **Mode Développeur** -* Clic droit sur votre avatar → **Copier l'identifiant** - -**4. Configurer** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "VOTRE_TOKEN_BOT", - "allow_from": ["VOTRE_USER_ID"] - } - } -} -``` - -**5. Inviter le bot** - -* OAuth2 → URL Generator -* Scopes : `bot` -* Permissions du Bot : `Send Messages`, `Read Message History` -* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur - -**6. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-QQ - -**1. Créer un bot** - -- Rendez-vous sur la [QQ Open Platform](https://q.qq.com/#) -- Créez une application → Obtenez l'**AppID** et l'**AppSecret** - -**2. Configurer** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "VOTRE_APP_ID", - "app_secret": "VOTRE_APP_SECRET", - "allow_from": [] - } - } -} -``` - -> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès. - -**3. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-DingTalk - -**1. Créer un bot** - -* Rendez-vous sur la [Open Platform](https://open.dingtalk.com/) -* Créez une application interne -* Copiez le Client ID et le Client Secret - -**2. Configurer** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "VOTRE_CLIENT_ID", - "client_secret": "VOTRE_CLIENT_SECRET", - "allow_from": [] - } - } -} -``` - -> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants pour restreindre l'accès. - -**3. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-LINE - -**1. Créer un Compte Officiel LINE** - -- Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/) -- Créez un provider → Créez un canal Messaging API -- Copiez le **Channel Secret** et le **Channel Access Token** - -**2. Configurer** - -```json -{ - "channels": { - "line": { - "enabled": true, - "channel_secret": "VOTRE_CHANNEL_SECRET", - "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN", - "webhook_path": "/webhook/line", - "allow_from": [] - } - } -} -``` - -**3. Configurer l'URL du Webhook** - -LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : - -```bash -# Exemple avec ngrok (tunnel vers le serveur Gateway partagé) -ngrok http 18790 -``` - -Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**. - -> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`. - -**4. Lancer** - -```bash -picoclaw gateway -``` - -> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original. - -> **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port. - -
- -
-WeCom (WeChat Work) - -PicoClaw prend en charge trois types d'intégration WeCom : - -**Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe -**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement -**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé - -Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées. - -**Configuration Rapide - WeCom Bot :** - -**1. Créer un bot** - -* Accédez à la Console d'Administration WeCom → Discussion de Groupe → Ajouter un Bot de Groupe -* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. Configurer** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -**Configuration Rapide - WeCom App :** - -**1. Créer une application** - -* Accédez à la Console d'Administration WeCom → Gestion des Applications → Créer une Application -* Copiez l'**AgentId** et le **Secret** -* Accédez à la page "Mon Entreprise", copiez le **CorpID** - -**2. Configurer la réception des messages** - -* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API" -* Définissez l'URL sur `http://your-server:18790/webhook/wecom-app` -* Générez le **Token** et l'**EncodingAESKey** - -**3. Configurer** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. Lancer** - -```bash -picoclaw gateway -``` - -> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production. - -**Configuration Rapide - WeCom AI Bot :** - -**1. Créer un AI Bot** - -* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot -* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot` -* Copiez le **Token** et générez l'**EncodingAESKey** - -**2. Configurer** - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "Bonjour ! Comment puis-je vous aider ?" - } - } -} -``` - -**3. Lancer** - -```bash -picoclaw gateway -``` - -> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`. - -
+| Sujet | Description | +|-------|-------------| +| 🐳 [Docker & Démarrage Rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent, configuration rapide | +| 💬 [Applications de Chat](docs/fr/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, et plus | +| ⚙️ [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sources de compétences, bac à sable de sécurité, heartbeat | +| 🔌 [Fournisseurs & Modèles](docs/fr/providers.md) | 20+ fournisseurs LLM, routage de modèles, configuration model_list, architecture des fournisseurs | +| 🔄 [Spawn & Tâches Asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration asynchrone de sous-agents | +| 🐛 [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 exec | ## ClawdChat Rejoignez le Réseau Social d'Agents @@ -613,547 +207,24 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes **Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)** -## ⚙️ Configuration - -Fichier de configuration : `~/.picoclaw/config.json` - -### Variables d'Environnement - -Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de picoclaw en tant que service système. Ces variables sont indépendantes et contrôlent différents chemins. - -| Variable | Description | Chemin par Défaut | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` | - -**Exemples :** - -```bash -# Exécuter picoclaw en utilisant un fichier de configuration spécifique -# Le chemin du workspace sera lu à partir de ce fichier de configuration -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw -# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json -# Le workspace sera créé dans /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Utiliser les deux pour une configuration entièrement personnalisée -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Structure du Workspace - -PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : - -``` -~/.picoclaw/workspace/ -├── sessions/ # Sessions de conversation et historique -├── memory/ # Mémoire à long terme (MEMORY.md) -├── state/ # État persistant (dernier canal, etc.) -├── cron/ # Base de données des tâches planifiées -├── skills/ # Compétences personnalisées -├── AGENTS.md # Guide de comportement de l'Agent -├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) -├── IDENTITY.md # Identité de l'Agent -├── SOUL.md # Âme de l'Agent -└── USER.md # Préférences utilisateur -``` - -### 🔒 Bac à Sable de Sécurité - -PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes qu'au sein du workspace configuré. - -#### Configuration par Défaut - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Option | Par défaut | Description | -|--------|------------|-------------| -| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent | -| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace | - -#### Outils Protégés - -Lorsque `restrict_to_workspace: true`, les outils suivants sont restreints au bac à sable : - -| Outil | Fonction | Restriction | -|-------|----------|-------------| -| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace | -| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace | -| `list_dir` | Lister des répertoires | Uniquement les répertoires dans le workspace | -| `edit_file` | Éditer des fichiers | Uniquement les fichiers dans le workspace | -| `append_file` | Ajouter à des fichiers | Uniquement les fichiers dans le workspace | -| `exec` | Exécuter des commandes | Les chemins doivent être dans le workspace | - -#### Protection Supplémentaire d'Exec - -Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses : - -* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse -* `format`, `mkfs`, `diskpart` — Formatage de disque -* `dd if=` — Écriture d'image disque -* Écriture vers `/dev/sd[a-z]` — Écriture directe sur le disque -* `shutdown`, `reboot`, `poweroff` — Arrêt du système -* Fork bomb `:(){ :|:& };:` - -#### Exemples d'Erreurs - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Désactiver les Restrictions (Risque de Sécurité) - -Si vous avez besoin que l'agent accède à des chemins en dehors du workspace : - -**Méthode 1 : Fichier de configuration** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Méthode 2 : Variable d'environnement** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Attention** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution uniquement dans des environnements contrôlés. - -#### Cohérence du Périmètre de Sécurité - -Le paramètre `restrict_to_workspace` s'applique de manière cohérente sur tous les chemins d'exécution : - -| Chemin d'Exécution | Périmètre de Sécurité | -|--------------------|----------------------| -| Agent Principal | `restrict_to_workspace` ✅ | -| Sous-agent / Spawn | Hérite de la même restriction ✅ | -| Tâches Heartbeat | Hérite de la même restriction ✅ | - -Tous les chemins partagent la même restriction de workspace — il est impossible de contourner le périmètre de sécurité via des sous-agents ou des tâches planifiées. - -### Heartbeat (Tâches Périodiques) - -PicoClaw peut exécuter des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace : - -```markdown -# Tâches Périodiques - -- Vérifier mes e-mails pour les messages importants -- Consulter mon agenda pour les événements à venir -- Vérifier les prévisions météo -``` - -L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera les tâches à l'aide des outils disponibles. - -#### Tâches Asynchrones avec Spawn - -Pour les tâches de longue durée (recherche web, appels API), utilisez l'outil `spawn` pour créer un **sous-agent** : - -```markdown -# Tâches Périodiques - -## Tâches Rapides (réponse directe) -- Indiquer l'heure actuelle - -## Tâches Longues (utiliser spawn pour l'asynchrone) -- Rechercher les actualités IA sur le web et les résumer -- Vérifier les e-mails et signaler les messages importants -``` - -**Comportements clés :** - -| Fonctionnalité | Description | -|----------------|-------------| -| **spawn** | Crée un sous-agent asynchrone, ne bloque pas le heartbeat | -| **Contexte indépendant** | Le sous-agent a son propre contexte, sans historique de session | -| **Outil message** | Le sous-agent communique directement avec l'utilisateur via l'outil message | -| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante | - -#### Fonctionnement de la Communication du Sous-agent - -``` -Le Heartbeat se déclenche - ↓ -L'Agent lit HEARTBEAT.md - ↓ -Pour une tâche longue : spawn d'un sous-agent - ↓ ↓ -Continue la tâche suivante Le sous-agent travaille indépendamment - ↓ ↓ -Toutes les tâches terminées Le sous-agent utilise l'outil "message" - ↓ ↓ -Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement -``` - -Le sous-agent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal. - -**Configuration :** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| Option | Par défaut | Description | -|--------|------------|-------------| -| `enabled` | `true` | Activer/désactiver le heartbeat | -| `interval` | `30` | Intervalle de vérification en minutes (min : 5) | - -**Variables d'environnement :** - -* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver -* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour modifier l'intervalle - -### Fournisseurs - -> [!NOTE] -> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent. - -| Fournisseur | Utilisation | Obtenir une Clé API | -| ------------------------ | ---------------------------------------- | ------------------------------------------------------ | -| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | -| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) | -| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | -| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | -| `deepseek` (À tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (Alibaba Qwen) | [dashscope.aliyuncs.com](https://dashscope.aliyuncs.com/compatible-mode/v1) | -| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) | -| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) | - -
-Configuration Zhipu - -**1. Obtenir la clé API** - -* Obtenez la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. Configurer** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "zhipu": { - "api_key": "Votre Clé API", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } -} -``` - -**3. Lancer** - -```bash -picoclaw agent -m "Bonjour, comment ça va ?" -``` - -
- -
-Exemple de configuration complète - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "BSA...", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -### Configuration de Modèle (model_list) - -> **Nouveau !** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `fournisseur/modèle` (par exemple, `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs—**aucune modification de code requise !** - -Cette conception permet également le **support multi-agent** avec une sélection flexible de fournisseurs : - -- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM -- **Modèles de secours (Fallbacks)** : Configurez des modèles primaires et de secours pour la résilience -- **Équilibrage de charge** : Répartissez les requêtes sur plusieurs points de terminaison -- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit - -#### 📋 Tous les Fournisseurs Supportés - -| Fournisseur | Préfixe `model` | API Base par Défaut | Protocole | Clé API | -|-------------|-----------------|---------------------|----------|---------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir Clé](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir Clé](https://console.anthropic.com) | -| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir Clé](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir Clé](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir Clé](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir Clé](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obtenir Clé](https://platform.moonshot.cn) | -| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir Clé](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obtenir Clé](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé nécessaire) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) | -| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://www.byteplus.com/) | -| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) | -| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obtenir un Token](https://modelscope.cn/my/tokens) | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### Configuration de Base - -```json -{ - "model_list": [ - { - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" - }, - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.4" - } - } -} -``` - -#### Exemples par Fournisseur - -**OpenAI** -```json -{ - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-..." -} -``` - -**VolcEngine (Doubao)** -```json -{ - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-..." -} -``` - -**Zhipu AI (GLM)** -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**Anthropic (avec OAuth)** -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" -} -``` -> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth. - -**Proxy/API personnalisée** -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -#### Équilibrage de Charge - -Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux : - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.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" - } - ] -} -``` - -#### Migration depuis l'Ancienne Configuration `providers` - -L'ancienne configuration `providers` est **dépréciée** mais toujours supportée pour la rétrocompatibilité. - -**Ancienne Configuration (dépréciée) :** -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**Nouvelle Configuration (recommandée) :** -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -Pour le guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). - -## Référence CLI - -| Commande | Description | -| ------------------------- | ------------------------------------- | -| `picoclaw onboard` | Initialiser la configuration & le workspace | -| `picoclaw agent -m "..."` | Discuter avec l'agent | -| `picoclaw agent` | Mode de discussion interactif | -| `picoclaw gateway` | Démarrer la passerelle | -| `picoclaw status` | Afficher le statut | -| `picoclaw cron list` | Lister toutes les tâches planifiées | -| `picoclaw cron add ...` | Ajouter une tâche planifiée | +## 🖥️ Référence CLI + +| Commande | Description | +| ------------------------- | ---------------------------------- | +| `picoclaw onboard` | Initialiser la config & le workspace | +| `picoclaw agent -m "..."` | Discuter avec l'agent | +| `picoclaw agent` | Mode chat interactif | +| `picoclaw gateway` | Démarrer le gateway | +| `picoclaw status` | Afficher le statut | +| `picoclaw version` | Afficher les infos de version | +| `picoclaw cron list` | Lister les tâches planifiées | +| `picoclaw cron add ...` | Ajouter une tâche planifiée | +| `picoclaw cron disable` | Désactiver une tâche planifiée | +| `picoclaw cron remove` | Supprimer une tâche planifiée | +| `picoclaw skills list` | Lister les compétences installées | +| `picoclaw skills install` | Installer une compétence | +| `picoclaw migrate` | Migrer les données des anciennes versions | +| `picoclaw auth login` | S'authentifier auprès des fournisseurs | ### Tâches Planifiées / Rappels @@ -1161,78 +232,18 @@ PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via * **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min * **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures -* **Expressions Cron** : « Rappelle-moi à 9h tous les jours » → utilise une expression cron - -Les tâches sont stockées dans `~/.picoclaw/workspace/cron/` et traitées automatiquement. +* **Expressions cron** : « Rappelle-moi à 9h chaque jour » → utilise une expression cron ## 🤝 Contribuer & Feuille de Route -Les PR sont les bienvenues ! Le code source est volontairement petit et lisible. 🤗 +Les PR sont les bienvenues ! Le code est intentionnellement petit et lisible. 🤗 -Feuille de route à venir... +Consultez notre [Feuille de Route Communautaire](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) complète. -Groupe de développeurs en construction. Condition d'entrée : au moins 1 PR fusionnée. +Groupe de développeurs en construction, rejoignez-nous après votre première PR fusionnée ! Groupes d'utilisateurs : -Discord : +discord : PicoClaw - -## 🐛 Dépannage - -### La recherche web affiche « API 配置问题 » - -C'est normal si vous n'avez pas encore configuré de clé API de recherche. PicoClaw fournira des liens utiles pour la recherche manuelle. - -Pour activer la recherche web : - -1. **Option 1 (Recommandé)** : Obtenez une clé API gratuite sur [https://brave.com/search/api](https://brave.com/search/api) (2000 requêtes gratuites/mois) pour les meilleurs résultats. -2. **Option 2 (Sans carte bancaire)** : Si vous n'avez pas de clé, le système bascule automatiquement sur **DuckDuckGo** (aucune clé requise). - -Ajoutez la clé dans `~/.picoclaw/config.json` si vous utilisez Brave : - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "VOTRE_CLE_API_BRAVE", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -### Erreurs de filtrage de contenu - -Certains fournisseurs (comme Zhipu) disposent d'un filtrage de contenu. Essayez de reformuler votre requête ou utilisez un modèle différent. - -### Le bot Telegram affiche « Conflict: terminated by other getUpdates » - -Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assurez-vous qu'un seul `picoclaw gateway` fonctionne à la fois. - ---- - -## 📝 Comparaison des Clés API - -| Service | Offre Gratuite | Cas d'Utilisation | -| ---------------- | -------------------- | ------------------------------------- | -| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) | -| **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) | -| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois | -| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web | -| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) | -| **ModelScope** | 2000 requêtes/jour | Inférence gratuite (Qwen, GLM, DeepSeek, etc.) | - ---- - -
- PicoClaw Meme -
diff --git a/README.ja.md b/README.ja.md index 7fff46d13..5cfd6359a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,13 +1,12 @@
-PicoClaw + PicoClaw -

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

+

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

-

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

-

+

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

- Go - Hardware + Go + Hardware License
Website @@ -23,10 +22,11 @@

- --- -🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。 +> **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% 安い! @@ -45,32 +45,70 @@
+> [!CAUTION] +> **🚨 セキュリティ&公式チャンネル** +> +> * **暗号通貨なし:** PicoClaw には公式トークン/コインは**一切ありません**。`pump.fun` やその他の取引プラットフォームでの主張はすべて**詐欺**です。 +> +> * **公式ドメイン:** **唯一**の公式サイトは **[picoclaw.io](https://picoclaw.io)**、企業サイトは **[sipeed.com](https://sipeed.com)** です。 +> * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。 +> * **注意:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。 +> * **注記:** PicoClaw は最近多くの PR をマージしており、最新バージョンではメモリフットプリントが大きくなる場合があります(10〜20MB)。機能セットが安定次第、リソース最適化を優先する予定です。 + ## 📢 ニュース -2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ! + +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 対応と Web UI ランチャー。 + +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 日で構築。🦐 行くぜ、シャコ! + +
## ✨ 特徴 -🪶 **超軽量**: メモリフットプリント 10MB 未満 — Clawdbot のコア機能より 99% 小さい。 +🪶 **超軽量**: メモリフットプリント 10MB 未満 — OpenClaw のコア機能より 99% 小さい。* 💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。 -⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。 +⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒未満で起動。 🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go! 🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。 -| | OpenClaw | NanoBot | **PicoClaw** | -| --- | --- | --- |--- | -| **言語** | TypeScript | Python | **Go** | -| **RAM** | >1GB |>100MB| **< 10MB** | -| **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | -| **コスト** | Mac Mini 599$ | 大半の Linux SBC
~50$ |**あらゆる Linux ボード**
**最安 10$** | +🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続してエージェント機能を拡張。 + +👁️ **ビジョンパイプライン**: 画像やファイルをエージェントに直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。 + +🧠 **スマートルーティング**: ルールベースのモデルルーティング — 簡単なクエリは軽量モデルへ、API コストを節約。 + +_*最近のバージョンでは急速な機能マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています(下表参照)。_ + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **言語** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | +| **コスト** | Mac Mini $599 | 大半の Linux SBC
~$50 | **あらゆる Linux ボード**
**最安 $10** | + PicoClaw - ## 🦾 デモンストレーション + ### 🛠️ スタンダードアシスタントワークフロー + @@ -89,14 +127,34 @@

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

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

PicoClaw: Ultra-Efficient AI Assistant in Go

-

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

+

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

- Go - Hardware + Go + Hardware License
Website @@ -24,7 +24,9 @@ --- -🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization. +> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project. + +🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization. ⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini! @@ -55,31 +57,51 @@ ## 📢 News -2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board! +2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status tracking (`spawn_status`), experimental gateway hot-reload, cron security gates, and 2 security fixes. PicoClaw now at **25K ⭐**! -2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. -🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. +2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, and model routing. -2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! +2026-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher. + +2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed. + +

+Older news... + +2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted. + +2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway. + +2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! + +
## ✨ Features -🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than Clawdbot - core functionality. +🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.* 💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini. -⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core. +⚡️ **Lightning Fast**: 400X Faster startup time, boot in <1 second even on 0.6GHz single core. 🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go! 🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. +🔌 **MCP Support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend agent capabilities. + +👁️ **Vision Pipeline**: Send images and files directly to the agent — automatic base64 encoding for multimodal LLMs. + +🧠 **Smart Routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs. + +_*Recent versions may use 10–20MB due to rapid feature merges. Resource optimization is planned. Startup comparison based on 0.8GHz single-core benchmarks (see table below)._ + | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Language** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | +| **RAM** | >1GB | >100MB | **< 10MB*** | | **Startup**
(0.8GHz core) | >500s | >30s | **<1s** | -| **Cost** | Mac Mini 599$ | Most Linux SBC
~50$ | **Any Linux Board**
**As low as 10$** | +| **Cost** | Mac Mini $599 | Most Linux SBC
~$50 | **Any Linux Board**
**As low as $10** | PicoClaw @@ -109,18 +131,19 @@ Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start: -1. **Install Termux** (Available on F-Droid or Google Play). +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. **Execute cmds** ```bash -# Note: Replace v0.1.1 with the latest version from the Releases page -wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 -chmod +x picoclaw-linux-arm64 +# Download the latest release from https://github.com/sipeed/picoclaw/releases +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-linux-arm64 onboard +termux-chroot ./picoclaw onboard ``` And then follow the instructions in the "Quick Start" section to complete the configuration! + PicoClaw ### 🐜 Innovative Low-Footprint Deploy @@ -139,7 +162,7 @@ PicoClaw can be deployed on almost any Linux device! ### Install with precompiled binary -Download the firmware for your platform from the [release](https://github.com/sipeed/picoclaw/releases) page. +Download the binary for your platform from the [Releases](https://github.com/sipeed/picoclaw/releases) page. ### Install from source (latest features, recommended for development) @@ -162,585 +185,21 @@ make build-pi-zero make install ``` -**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both. +**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Or run `make build-pi-zero` to build both. -## 🐳 Docker Compose +## 📚 Documentation -You can also run PicoClaw using Docker Compose without installing anything locally. +For detailed guides, see the docs below. The README covers quick start only. -```bash -# 1. Clone this repo -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw - -# 2. First run — auto-generates docker/data/config.json then exits -docker compose -f docker/docker-compose.yml --profile gateway up -# The container prints "First-run setup complete." and stops. - -# 3. Set your API keys -vim docker/data/config.json # Set provider API keys, bot tokens, etc. - -# 4. Start -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. - -```bash -# 5. Check logs -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. Stop -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Launcher Mode (Web Console) - -The `launcher` image includes 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. - -```bash -docker compose -f docker/docker-compose.yml --profile launcher up -d -``` - -Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. - -> [!WARNING] -> The web console does not yet support authentication. Avoid exposing it to the public internet. - -### Agent Mode (One-shot) - -```bash -# Ask a question -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" - -# Interactive mode -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### Update - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 Quick Start - -> [!TIP] -> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month). - -**1. Initialize** - -```bash -picoclaw onboard -``` - -**2. Configure** (`~/.picoclaw/config.json`) - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model_name": "gpt-5.4", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "model_list": [ - { - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "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", - "request_timeout": 300 - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" - } - ], - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "tavily": { - "enabled": false, - "api_key": "YOUR_TAVILY_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - }, - "perplexity": { - "enabled": false, - "api_key": "YOUR_PERPLEXITY_API_KEY", - "max_results": 5 - }, - "searxng": { - "enabled": false, - "base_url": "http://your-searxng-instance:8888", - "max_results": 5 - } - } - } -} -``` - -> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. -> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). - -**3. Get API Keys** - -* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -* **Web Search** (optional): - * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) - * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface - * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) - * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) - * DuckDuckGo - Built-in fallback (no API key required) - -> **Note**: See `config.example.json` for a complete configuration template. - -**4. Chat** - -```bash -picoclaw agent -m "What is 2+2?" -``` - -That's it! You have a working AI assistant in 2 minutes. - ---- - -## 💬 Chat Apps - -Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom - -> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. - -| Channel | Setup | -| ------------ | ---------------------------------- | -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **WhatsApp** | Easy (native: QR scan; or bridge URL) | -| **Matrix** | Medium (homeserver + bot access token) | -| **QQ** | Easy (AppID + AppSecret) | -| **DingTalk** | Medium (app credentials) | -| **LINE** | Medium (credentials + webhook URL) | -| **WeCom AI Bot** | Medium (Token + AES key) | - -
-Telegram (Recommended) - -**1. Create a bot** - -* Open Telegram, search `@BotFather` -* Send `/newbot`, follow prompts -* Copy the token - -**2. Configure** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -> Get your user ID from `@userinfobot` on Telegram. - -**3. Run** - -```bash -picoclaw gateway -``` - -**4. Telegram command menu (auto-registered at startup)** - -PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync. -Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. - -If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. - -
- -
-Discord - -**1. Create a bot** - -* Go to -* Create an application → Bot → Add Bot -* Copy the bot token - -**2. Enable intents** - -* In the Bot settings, enable **MESSAGE CONTENT INTENT** -* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data - -**3. Get your User ID** -* Discord Settings → Advanced → enable **Developer Mode** -* Right-click your avatar → **Copy User ID** - -**4. Configure** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -**5. Invite the bot** - -* OAuth2 → URL Generator -* Scopes: `bot` -* Bot Permissions: `Send Messages`, `Read Message History` -* Open the generated invite URL and add the bot to your server - -**Optional: Group trigger mode** - -By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add: - -```json -{ - "channels": { - "discord": { - "group_trigger": { "mention_only": true } - } - } -} -``` - -You can also trigger by keyword prefixes (e.g. `!bot`): - -```json -{ - "channels": { - "discord": { - "group_trigger": { "prefixes": ["!bot"] } - } - } -} -``` - -**6. Run** - -```bash -picoclaw gateway -``` - -
- -
-WhatsApp (native via whatsmeow) - -PicoClaw can connect to WhatsApp in two ways: - -- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`). -- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. - -**Configure (native)** - -```json -{ - "channels": { - "whatsapp": { - "enabled": true, - "use_native": true, - "session_store_path": "", - "allow_from": [] - } - } -} -``` - -If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices. - -
- -
-QQ - -**1. Create a bot** - -- Go to [QQ Open Platform](https://q.qq.com/#) -- Create an application → Get **AppID** and **AppSecret** - -**2. Configure** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "YOUR_APP_ID", - "app_secret": "YOUR_APP_SECRET", - "allow_from": [] - } - } -} -``` - -> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access. - -**3. Run** - -```bash -picoclaw gateway -``` - -
- -
-DingTalk - -**1. Create a bot** - -* Go to [Open Platform](https://open.dingtalk.com/) -* Create an internal app -* Copy Client ID and Client Secret - -**2. Configure** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - } - } -} -``` - -> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access. - -**3. Run** - -```bash -picoclaw gateway -``` -
- -
-Matrix - -**1. Prepare bot account** - -* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted) -* Create a bot user and obtain its access token - -**2. Configure** - -```json -{ - "channels": { - "matrix": { - "enabled": true, - "homeserver": "https://matrix.org", - "user_id": "@your-bot:matrix.org", - "access_token": "YOUR_MATRIX_ACCESS_TOKEN", - "allow_from": [] - } - } -} -``` - -**3. Run** - -```bash -picoclaw gateway -``` - -For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md). - -
- -
-LINE - -**1. Create a LINE Official Account** - -- Go to [LINE Developers Console](https://developers.line.biz/) -- Create a provider → Create a Messaging API channel -- Copy **Channel Secret** and **Channel Access Token** - -**2. Configure** - -```json -{ - "channels": { - "line": { - "enabled": true, - "channel_secret": "YOUR_CHANNEL_SECRET", - "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_path": "/webhook/line", - "allow_from": [] - } - } -} -``` - -> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). - -**3. Set up Webhook URL** - -LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: - -```bash -# Example with ngrok (gateway default port is 18790) -ngrok http 18790 -``` - -Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. - -**4. Run** - -```bash -picoclaw gateway -``` - -> In group chats, the bot responds only when @mentioned. Replies quote the original message. - -
- -
-WeCom (企业微信) - -PicoClaw supports three types of WeCom integration: - -**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats -**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only -**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat - -See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions. - -**Quick Setup - WeCom Bot:** - -**1. Create a bot** - -* Go to WeCom Admin Console → Group Chat → Add Group Bot -* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. Configure** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). - -**Quick Setup - WeCom App:** - -**1. Create an app** - -* Go to WeCom Admin Console → App Management → Create App -* Copy **AgentId** and **Secret** -* Go to "My Company" page, copy **CorpID** - -**2. Configure receive message** - -* In App details, click "Receive Message" → "Set API" -* Set URL to `http://your-server:18790/webhook/wecom-app` -* Generate **Token** and **EncodingAESKey** - -**3. Configure** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. Run** - -```bash -picoclaw gateway -``` - -> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS. - -**Quick Setup - WeCom AI Bot:** - -**1. Create an AI Bot** - -* Go to WeCom Admin Console → App Management → AI Bot -* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` -* Copy **Token** and click "Random Generate" for **EncodingAESKey** - -**2. Configure** - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "Hello! How can I help you?" - } - } -} -``` - -**3. Run** - -```bash -picoclaw gateway -``` - -> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery. - -
+| Topic | Description | +|-------|-------------| +| 🐳 [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes, Quick Start configuration | +| 💬 [Chat Apps](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, and more | +| ⚙️ [Configuration](docs/configuration.md) | Environment variables, workspace layout, skill sources, security sandbox, heartbeat | +| 🔌 [Providers & Models](docs/providers.md) | 20+ LLM providers, model routing, model_list configuration, provider architecture | +| 🔄 [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | +| 🐛 [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | +| 🔧 [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies | ## ClawdChat Join the Agent Social Network @@ -748,650 +207,7 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message **Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)** -## ⚙️ Configuration - -Config file: `~/.picoclaw/config.json` - -### Environment Variables - -You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths. - -| Variable | Description | Default Path | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` | - -**Examples:** - -```bash -# Run picoclaw using a specific config file -# The workspace path will be read from within that config file -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Run picoclaw with all its data stored in /opt/picoclaw -# Config will be loaded from the default ~/.picoclaw/config.json -# Workspace will be created at /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Use both for a fully customized setup -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Workspace Layout - -PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # Conversation sessions and history -├── memory/ # Long-term memory (MEMORY.md) -├── state/ # Persistent state (last channel, etc.) -├── cron/ # Scheduled jobs database -├── skills/ # Custom skills -├── AGENTS.md # Agent behavior guide -├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) -├── IDENTITY.md # Agent identity -├── SOUL.md # Agent soul -└── USER.md # User preferences -``` - -### Skill Sources - -By default, skills are loaded from: - -1. `~/.picoclaw/workspace/skills` (workspace) -2. `~/.picoclaw/skills` (global) -3. `/skills` (builtin) - -For advanced/test setups, you can override the builtin skills root with: - -```bash -export PICOCLAW_BUILTIN_SKILLS=/path/to/skills -``` - -### Unified Command Execution Policy - -- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. -- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup. -- Unknown slash command (for example `/foo`) passes through to normal LLM processing. -- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. -### 🔒 Security Sandbox - -PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. - -#### Default Configuration - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Option | Default | Description | -| ----------------------- | ----------------------- | ----------------------------------------- | -| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | -| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | - -#### Protected Tools - -When `restrict_to_workspace: true`, the following tools are sandboxed: - -| Tool | Function | Restriction | -| ------------- | ---------------- | -------------------------------------- | -| `read_file` | Read files | Only files within workspace | -| `write_file` | Write files | Only files within workspace | -| `list_dir` | List directories | Only directories within workspace | -| `edit_file` | Edit files | Only files within workspace | -| `append_file` | Append to files | Only files within workspace | -| `exec` | Execute commands | Command paths must be within workspace | - -#### Additional Exec Protection - -Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: - -* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion -* `format`, `mkfs`, `diskpart` — Disk formatting -* `dd if=` — Disk imaging -* Writing to `/dev/sd[a-z]` — Direct disk writes -* `shutdown`, `reboot`, `poweroff` — System shutdown -* Fork bomb `:(){ :|:& };:` - -#### Error Examples - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Disabling Restrictions (Security Risk) - -If you need the agent to access paths outside the workspace: - -**Method 1: Config file** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Method 2: Environment variable** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. - -#### Security Boundary Consistency - -The `restrict_to_workspace` setting applies consistently across all execution paths: - -| Execution Path | Security Boundary | -| ---------------- | ---------------------------- | -| Main Agent | `restrict_to_workspace` ✅ | -| Subagent / Spawn | Inherits same restriction ✅ | -| Heartbeat tasks | Inherits same restriction ✅ | - -All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. - -### Heartbeat (Periodic Tasks) - -PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: - -```markdown -# Periodic Tasks - -- Check my email for important messages -- Review my calendar for upcoming events -- Check the weather forecast -``` - -The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools. - -#### Async Tasks with Spawn - -For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**: - -```markdown -# Periodic Tasks - -## Quick Tasks (respond directly) - -- Report current time - -## Long Tasks (use spawn for async) - -- Search the web for AI news and summarize -- Check email and report important messages -``` - -**Key behaviors:** - -| Feature | Description | -| ----------------------- | --------------------------------------------------------- | -| **spawn** | Creates async subagent, doesn't block heartbeat | -| **Independent context** | Subagent has its own context, no session history | -| **message tool** | Subagent communicates with user directly via message tool | -| **Non-blocking** | After spawning, heartbeat continues to next task | - -#### How Subagent Communication Works - -``` -Heartbeat triggers - ↓ -Agent reads HEARTBEAT.md - ↓ -For long task: spawn subagent - ↓ ↓ -Continue to next task Subagent works independently - ↓ ↓ -All tasks done Subagent uses "message" tool - ↓ ↓ -Respond HEARTBEAT_OK User receives result directly -``` - -The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent. - -**Configuration:** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| Option | Default | Description | -| ---------- | ------- | ---------------------------------- | -| `enabled` | `true` | Enable/disable heartbeat | -| `interval` | `30` | Check interval in minutes (min: 5) | - -**Environment variables:** - -* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable -* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval - -### Providers - -> [!NOTE] -> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level. - -| Provider | Purpose | Get API Key | -| ------------ | --------------------------------------- | ------------------------------------------------------------ | -| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | -| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | -| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | -| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | -| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | -| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | - -### Model Configuration (model_list) - -> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!** - -This design also enables **multi-agent support** with flexible provider selection: - -- **Different agents, different providers**: Each agent can use its own LLM provider -- **Model fallbacks**: Configure primary and fallback models for resilience -- **Load balancing**: Distribute requests across multiple endpoints -- **Centralized configuration**: Manage all providers in one place - -#### 📋 All Supported Vendors - -| Vendor | `model` Prefix | Default API Base | Protocol | API Key | -| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | -| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | -| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | -| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | -| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | -| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### Basic Configuration - -```json -{ - "model_list": [ - { - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" - }, - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.4" - } - } -} -``` - -#### Vendor-Specific Examples - -**OpenAI** - -```json -{ - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-..." -} -``` - -**VolcEngine (Doubao)** - -```json -{ - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-..." -} -``` - -**智谱 AI (GLM)** - -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**DeepSeek** - -```json -{ - "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." -} -``` - -**Anthropic (with API key)** - -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" -} -``` - -> Run `picoclaw auth login --provider anthropic` to paste your API token. - -**Anthropic Messages API (native format)** - -For direct Anthropic API access or custom endpoints that only support Anthropic's native message format: - -```json -{ - "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", - "api_base": "https://api.anthropic.com" -} -``` - -> Use `anthropic-messages` protocol when: -> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`) -> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format -> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format) -> -> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format. - -**Ollama (local)** - -```json -{ - "model_name": "llama3", - "model": "ollama/llama3" -} -``` - -**Custom Proxy/API** - -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -**LiteLLM Proxy** - -```json -{ - "model_name": "lite-gpt4", - "model": "litellm/lite-gpt4", - "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." -} -``` - -PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`. - -#### Load Balancing - -Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.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" - } - ] -} -``` - -#### Migration from Legacy `providers` Config - -The old `providers` configuration is **deprecated** but still supported for backward compatibility. - -**Old Config (deprecated):** - -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**New Config (recommended):** - -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). - -### Provider Architecture - -PicoClaw routes providers by protocol family: - -- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. -- Anthropic protocol: Claude-native API behavior. -- Codex/OAuth path: OpenAI OAuth/token authentication route. - -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). - -
-Zhipu - -**1. Get API key and base URL** - -* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. Configure** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "zhipu": { - "api_key": "Your API Key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } -} -``` - -**3. Run** - -```bash -picoclaw agent -m "Hello" -``` - -
- -
-Full config example - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false, - "bridge_url": "ws://localhost:3001", - "use_native": false, - "session_store_path": "", - "allow_from": [] - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "BSA...", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - }, - "perplexity": { - "enabled": false, - "api_key": "", - "max_results": 5 - }, - "searxng": { - "enabled": false, - "base_url": "http://localhost:8888", - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -## CLI Reference +## 🖥️ CLI Reference | Command | Description | | ------------------------- | ----------------------------- | @@ -1400,8 +216,15 @@ picoclaw agent -m "Hello" | `picoclaw agent` | Interactive chat mode | | `picoclaw gateway` | Start the gateway | | `picoclaw status` | Show status | +| `picoclaw version` | Show version info | | `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron add ...` | Add a scheduled job | +| `picoclaw cron disable` | Disable a scheduled job | +| `picoclaw cron remove` | Remove a scheduled job | +| `picoclaw skills list` | List installed skills | +| `picoclaw skills install` | Install a skill | +| `picoclaw migrate` | Migrate data from older versions | +| `picoclaw auth login` | Authenticate with providers | ### Scheduled Tasks / Reminders @@ -1411,8 +234,6 @@ 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 -Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. - ## 🤝 Contribute & Roadmap PRs welcome! The codebase is intentionally small and readable. 🤗 @@ -1426,133 +247,3 @@ User Groups: discord: PicoClaw - -## 🐛 Troubleshooting - -### Web search says "API key configuration issue" - -This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching. - -#### Search Provider Priority - -PicoClaw automatically selects the best available search provider in this order: -1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations -2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries) -3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free) -4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free) - -#### Web Search Configuration Options - -**Option 1 (Best Results)**: Perplexity AI Search -```json -{ - "tools": { - "web": { - "perplexity": { - "enabled": true, - "api_key": "YOUR_PERPLEXITY_API_KEY", - "max_results": 5 - } - } - } -} -``` - -**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month) -```json -{ - "tools": { - "web": { - "brave": { - "enabled": true, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - } - } - } -} -``` - -**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance -```json -{ - "tools": { - "web": { - "searxng": { - "enabled": true, - "base_url": "http://your-server:8888", - "max_results": 5 - } - } - } -} -``` - -Benefits of SearXNG: -- **Zero cost**: No API fees or rate limits -- **Privacy-focused**: Self-hosted, no tracking -- **Aggregate results**: Queries 70+ search engines simultaneously -- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure) -- **No API key needed**: Just deploy and configure the base URL - -**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed) - -Add the key to `~/.picoclaw/config.json` if using Brave: - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - }, - "perplexity": { - "enabled": false, - "api_key": "YOUR_PERPLEXITY_API_KEY", - "max_results": 5 - }, - "searxng": { - "enabled": false, - "base_url": "http://your-searxng-instance:8888", - "max_results": 5 - } - } - } -} -``` - -### Getting content filtering errors - -Some providers (like Zhipu) have content filtering. Try rephrasing your query or use a different model. - -### Telegram bot says "Conflict: terminated by other getUpdates" - -This happens when another instance of the bot is running. Make sure only one `picoclaw gateway` is running at a time. - ---- - -## 📝 API Key Comparison - -| Service | Free Tier | Use Case | -| ---------------- | ------------------------ | ------------------------------------- | -| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | -| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | -| **Zhipu** | 200K tokens/month | Suitable for Chinese users | -| **Brave Search** | Paid ($5/1000 queries) | Web search functionality | -| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | -| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | -| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | -| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) | -| **ModelScope** | 2000 requests/day | Free inference (Qwen, GLM, DeepSeek, etc.) | - ---- - -
- PicoClaw Meme -
diff --git a/README.pt-br.md b/README.pt-br.md index 3fe24d7ea..04f7dae26 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -1,12 +1,12 @@
-PicoClaw + PicoClaw -

PicoClaw: Assistente de IA Ultra-Eficiente em Go

+

PicoClaw: Assistente de IA Ultra-Eficiente em Go

-

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

+

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

- Go - Hardware + Go + Hardware License
Website @@ -18,66 +18,88 @@ Discord

- [中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +
--- -🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código. +> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com). É escrito inteiramente em **Go** — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto. -⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! +🦐 PicoClaw é um assistente pessoal de IA ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot), reescrito do zero em Go por meio de um processo de auto-inicialização (self-bootstrapping), onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código. + +⚡️ Roda em hardware de $10 com <10MB de RAM: Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! - - - - + + + +
-

- -

-
-

- -

-
+

+ +

+
+

+ +

+
> [!CAUTION] > **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS** > > * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**. -> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**. -> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros, não são nossos. +> +> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)** +> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros. > * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0. -> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10-20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável. - +> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10–20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável. ## 📢 Novidades -2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo! +2026-03-17 🚀 **v0.2.3 Lançado!** Interface de bandeja do sistema (Windows & Linux), rastreamento de status de sub-agentes (`spawn_status`), hot-reload experimental do gateway, portões de segurança para cron e 2 correções de segurança. PicoClaw agora com **25K ⭐**! -2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. +2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos canais (Matrix/IRC/WeCom/Discord Proxy), 3 novos provedores (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL e roteamento de modelos. -🚀 **Chamada para Ação:** Envie suas solicitações de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na próxima reunião semanal. +2026-02-28 📦 **v0.2.0** lançado com suporte a Docker Compose e launcher Web UI. -2026-02-09 🎉 PicoClaw lançado oficialmente! Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! +2026-02-26 🎉 PicoClaw atingiu **20K stars** em apenas 17 dias! Orquestração automática de canais e interfaces de capacidade implementadas. + +
+Novidades anteriores... + +2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Papéis de maintainers da comunidade e [roadmap](ROADMAP.md) publicados oficialmente. + +2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Roadmap do Projeto e Grupo de Desenvolvedores em preparação. + +2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! + +
## ✨ Funcionalidades -🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. +🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o OpenClaw para funcionalidades essenciais.* 💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. -⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. +⚡️ **Inicialização Relâmpago**: Tempo de inicialização 400X mais rápido, boot em <1 segundo mesmo em CPU single-core de 0.6GHz. 🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era! 🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop. +🔌 **Suporte MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do agente. + +👁️ **Pipeline de Visão**: Envie imagens e arquivos diretamente ao agente — codificação base64 automática para LLMs multimodais. + +🧠 **Roteamento Inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API. + +_*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalidades. Otimização de recursos está planejada. Comparação de inicialização baseada em benchmarks de single-core a 0.8GHz (veja tabela abaixo)._ + | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Linguagem** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | +| **RAM** | >1GB | >100MB | **< 10MB*** | | **Inicialização**
(CPU 0.8GHz) | >500s | >30s | **<1s** | | **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | @@ -88,36 +110,36 @@ ### 🛠️ Fluxos de Trabalho Padrão do Assistente - - - - - - - - - - - - - - - + + + + + + + + + + + + + + +

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

Desenvolver • Implantar • EscalarAgendar • Automatizar • MemorizarDescobrir • Analisar • Tendências

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

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

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

+

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

-

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

+

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

- Go - Hardware + Go + Hardware License
Website @@ -19,64 +19,87 @@

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md) +
--- -🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn. +> **PicoClaw** là dự án mã nguồn mở độc lập được khởi xướng bởi [Sipeed](https://sipeed.com). Được viết hoàn toàn bằng **Go** — không phải là bản fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác. -⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini! +🦐 PicoClaw là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng Go thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn. + +⚡️ Chạy trên phần cứng chỉ $10 với RAM <10MB: Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini! - - - - + + + +
-

- -

-
-

- -

-
+

+ +

+
+

+ +

+
> [!CAUTION] > **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC** > > * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**. -> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**. -> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi. +> +> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)** +> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký. > * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0. > * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định. - ## 📢 Tin tức -2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn! +2026-03-17 🚀 **v0.2.3 Phát hành!** Giao diện khay hệ thống (Windows & Linux), theo dõi trạng thái sub-agent (`spawn_status`), hot-reload gateway thử nghiệm, cổng bảo mật cron và 2 bản vá bảo mật. PicoClaw đạt **25K ⭐**! -2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw. -🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần. +2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất!** Hỗ trợ giao thức MCP, 4 kênh mới (Matrix/IRC/WeCom/Discord Proxy), 3 nhà cung cấp mới (Kimi/Minimax/Avian), pipeline xử lý hình ảnh, bộ nhớ JSONL và định tuyến mô hình. -2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường! +2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và launcher Web UI. + +2026-02-26 🎉 PicoClaw đạt **20K stars** chỉ trong 17 ngày! Tự động điều phối kênh và giao diện năng lực đã được triển khai. + +
+Tin tức cũ hơn... + +2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Vai trò maintainer cộng đồng và [roadmap](ROADMAP.md) đã được công bố chính thức. + +2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Lộ trình dự án và Nhóm phát triển đang được thiết lập. + +2026-02-09 🎉 **PicoClaw chính thức ra mắt!** Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường! + +
## ✨ Tính năng nổi bật -🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi). +🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với OpenClaw (chức năng cốt lõi).* 💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini. -⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz. +⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong <1 giây ngay cả trên CPU đơn nhân 0.6GHz. 🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy! 🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người. +🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ máy chủ MCP nào để mở rộng khả năng của agent. + +👁️ **Pipeline Xử lý Hình ảnh**: Gửi hình ảnh và tệp trực tiếp cho agent — tự động mã hóa base64 cho các LLM đa phương thức. + +🧠 **Định tuyến Thông minh**: Định tuyến mô hình dựa trên quy tắc — truy vấn đơn giản chuyển đến mô hình nhẹ, tiết kiệm chi phí API. + +_*Các phiên bản gần đây có thể sử dụng 10–20MB do merge tính năng nhanh chóng. Tối ưu tài nguyên đang được lên kế hoạch. So sánh thời gian khởi động dựa trên benchmark đơn nhân 0.8GHz (xem bảng bên dưới)._ + | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Ngôn ngữ** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | +| **RAM** | >1GB | >100MB | **< 10MB*** | | **Thời gian khởi động**
(CPU 0.8GHz) | >500s | >30s | **<1s** | | **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**
**Chỉ từ $10** | @@ -87,32 +110,51 @@ ### 🛠️ Quy trình trợ lý tiêu chuẩn - - - - - - - - - - - - - - - + + + + + + + + + + + + + + +

🧩 Lập trình Full-Stack

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

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

Phát triển • Triển khai • Mở rộngLên lịch • Tự động hóa • Ghi nhớKhám phá • Phân tích • Xu hướng

🧩 Lập trình Full-Stack

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

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

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

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

-

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

+

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

- Go - Hardware + Go + Hardware License
Website @@ -24,7 +24,9 @@ --- -🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 +> **PicoClaw** 是由 [矽速科技 (Sipeed)](https://sipeed.com) 发起的独立开源项目,完全使用 **Go 语言**从零编写——不是 OpenClaw、NanoBot 或其他项目的分支。 + +🦐 **PicoClaw** 是一个受 [NanoBot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个"自举"过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 ⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%! @@ -43,42 +45,60 @@ -注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。 - > [!CAUTION] -> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** +> **🚨 安全声明** > > - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 > - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 > - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 -> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中 -> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化. +> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。 +> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。 -## 📢 新闻 (News) +## 📢 新闻 -2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与! +2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UI(Windows & Linux)、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐**! -2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。 -🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。 +2026-03-09 🎉 **v0.2.1 — 史上最大更新!** MCP 协议支持、4 个新频道 (Matrix/IRC/WeCom/Discord Proxy)、3 个新 Provider (Kimi/Minimax/Avian)、视觉管线、JSONL 记忆存储、模型路由。 -2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走! +2026-02-28 📦 **v0.2.0** 发布,支持 Docker Compose 和 Web UI 启动器。 + +2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。 + +

+更早的新闻... + +2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](ROADMAP.md) 正式发布。 + +2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。 + +2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,将 AI Agent 带入 $10 硬件与 <10MB 内存的世界。🦐 皮皮虾,我们走! + +
## ✨ 特性 -🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。 +🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 OpenClaw 小 99%。* -💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。 +💰 **极低成本**: 高效到足以在 $10 的硬件上运行 — 比 Mac mini 便宜 98%。 ⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行! -🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 +🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由"人机回环"微调。 + +🔌 **MCP 支持**: 原生 [Model Context Protocol](https://modelcontextprotocol.io/) 集成 — 连接任意 MCP 服务器扩展 Agent 能力。 + +👁️ **视觉管线**: 直接向 Agent 发送图片和文件 — 自动 base64 编码对接多模态 LLM。 + +🧠 **智能路由**: 基于规则的模型路由 — 简单查询走轻量模型,节省 API 成本。 + +_*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入计划。启动速度对比基于 0.8GHz 单核实测(见下方对比表)。_ | | OpenClaw | NanoBot | **PicoClaw** | | ------------------------------ | ------------- | ------------------------ | -------------------------------------- | | **语言** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | +| **RAM** | >1GB | >100MB | **< 10MB*** | | **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | | **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | @@ -108,31 +128,32 @@ ### 📱 在手机上轻松运行 -picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南: +PicoClaw 可以将你 10 年前的老旧手机废物利用,变身成为你的 AI 助理!快速指南: -1. 先去应用商店下载安装Termux +1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid 等应用商店搜索) 2. 打开后执行指令 ```bash -# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本 -wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 -chmod +x picoclaw-linux-arm64 +# 从 Release 页面下载最新版本 +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot -termux-chroot ./picoclaw-linux-arm64 onboard +termux-chroot ./picoclaw onboard ``` -然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! +然后跟随下面的"快速开始"章节继续配置 PicoClaw 即可使用! + PicoClaw ### 🐜 创新的低占用部署 PicoClaw 几乎可以部署在任何 Linux 设备上! -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。 -- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。 -- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。 +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手 +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维 +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控 -[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4) + 🌟 更多部署案例敬请期待! @@ -140,7 +161,7 @@ PicoClaw 几乎可以部署在任何 Linux 设备上! ### 使用预编译二进制文件安装 -从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。 +从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的二进制文件。 ### 从源码安装(获取最新特性,开发推荐) @@ -156,781 +177,72 @@ make build # 为多平台构建 make build-all +# 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64) +make build-pi-zero + # 构建并安装 make install - ``` -## 🐳 Docker Compose +**Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件:32 位 Raspberry Pi OS → `make build-linux-arm`;64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。 -您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 +## 📚 文档 -```bash -# 1. 克隆仓库 -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw +详细指南请参阅以下文档,README 仅涵盖快速入门。 -# 2. 首次运行 — 自动生成 docker/data/config.json 后退出 -docker compose -f docker/docker-compose.yml --profile gateway up -# 容器打印 "First-run setup complete." 后自动停止 - -# 3. 填写 API Key 等配置 -vim docker/data/config.json # 设置 provider API key、Bot Token 等 - -# 4. 正式启动 -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 - -```bash -# 5. 查看日志 -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. 停止 -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Agent 模式 (一次性运行) - -```bash -# 提问 -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?" - -# 交互模式 -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### 更新镜像 - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 快速开始 - -> [!TIP] -> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。 - -**1. 初始化 (Initialize)** - -```bash -picoclaw onboard - -``` - -**2. 配置 (Configure)** (`~/.picoclaw/config.json`) - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model_name": "gpt-5.4", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "model_list": [ - { - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "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", - "request_timeout": 300 - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" - } - ], - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "tavily": { - "enabled": false, - "api_key": "YOUR_TAVILY_API_KEY", - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - } -} -``` - -> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。 -> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 - -**3. 获取 API Key** - -* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月) - -> **注意**: 完整的配置模板请参考 `config.example.json`。 - -**4. 对话 (Chat)** - -```bash -picoclaw agent -m "2+2 等于几?" - -``` - -就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 - ---- - -## 💬 聊天应用集成 (Chat Apps) - -PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 - -> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。 - -### 核心渠道 - -| 渠道 | 设置难度 | 特性说明 | 文档链接 | -| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) | -| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) | -| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | -| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) | -| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | -| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | -| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) | -| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) | -| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | -| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | -| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | - -### Telegram 命令注册(启动时自动同步) - -PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 -Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 - -如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 +| 主题 | 说明 | +|------|------| +| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | +| 💬 [聊天应用配置](docs/zh/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、钉钉、LINE、飞书、企业微信等 | +| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、技能来源、安全沙箱、心跳任务 | +| 🔌 [提供商与模型配置](docs/zh/providers.md) | 20+ LLM 提供商、模型路由、model_list 配置、Provider 架构 | +| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | +| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 | +| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 | ## ClawdChat 加入 Agent 社交网络 -只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 - -\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai) - -## ⚙️ 配置详解 - -配置文件路径: `~/.picoclaw/config.json` - -### 环境变量 - -你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。 - -| 变量 | 描述 | 默认路径 | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` | - -**示例:** - -```bash -# 使用特定的配置文件运行 picoclaw -# 工作区路径将从该配置文件中读取 -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# 在 /opt/picoclaw 中存储所有数据运行 picoclaw -# 配置将从默认的 ~/.picoclaw/config.json 加载 -# 工作区将在 /opt/picoclaw/workspace 创建 -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# 同时使用两者进行完全自定义设置 -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### 工作区布局 (Workspace Layout) - -PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # 对话会话和历史 -├── memory/ # 长期记忆 (MEMORY.md) -├── state/ # 持久化状态 (最后一次频道等) -├── cron/ # 定时任务数据库 -├── skills/ # 自定义技能 -├── AGENTS.md # Agent 行为指南 -├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) -├── IDENTITY.md # Agent 身份设定 -├── SOUL.md # Agent 灵魂/性格 -└── USER.md # 用户偏好 - -``` - -### 技能来源 (Skill Sources) - -默认情况下,技能会按以下顺序加载: - -1. `~/.picoclaw/workspace/skills`(工作区) -2. `~/.picoclaw/skills`(全局) -3. `/skills`(内置) - -在高级/测试场景下,可通过以下环境变量覆盖内置技能目录: - -```bash -export PICOCLAW_BUILTIN_SKILLS=/path/to/skills -``` - -### 统一命令执行策略 - -- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 -- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。 -- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 -- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 -### 心跳 / 周期性任务 (Heartbeat) - -PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: - -```markdown -# Periodic Tasks - -- Check my email for important messages -- Review my calendar for upcoming events -- Check the weather forecast -``` - -Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 - -#### 使用 Spawn 的异步任务 - -对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: - -```markdown -# Periodic Tasks - -## Quick Tasks (respond directly) - -- Report current time - -## Long Tasks (use spawn for async) - -- Search the web for AI news and summarize -- Check email and report important messages -``` - -**关键行为:** - -| 特性 | 描述 | -| ---------------- | ---------------------------------------- | -| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | -| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | -| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | -| **非阻塞** | spawn 后,心跳继续处理下一个任务 | - -#### 子 Agent 通信原理 - -``` -心跳触发 (Heartbeat triggers) - ↓ -Agent 读取 HEARTBEAT.md - ↓ -对于长任务: spawn 子 Agent - ↓ ↓ -继续下一个任务 子 Agent 独立工作 - ↓ ↓ -所有任务完成 子 Agent 使用 "message" 工具 - ↓ ↓ -响应 HEARTBEAT_OK 用户直接收到结果 - -``` - -子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 - -**配置:** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| 选项 | 默认值 | 描述 | -| ---------- | ------ | ---------------------------- | -| `enabled` | `true` | 启用/禁用心跳 | -| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | - -**环境变量:** - -- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 -- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 - -### 提供商 (Providers) - -> [!NOTE] -> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。 - -| 提供商 | 用途 | 获取 API Key | -| -------------------- | ---------------------------- | -------------------------------------------------------------------- | -| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | -| `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | -| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | -| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | -| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | - -### 模型配置 (model_list) - -> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** - -该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: - -- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider -- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性 -- **负载均衡**:在多个 API 端点之间分配请求 -- **集中化配置**:在一个地方管理所有 provider - -#### 📋 所有支持的厂商 - -| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | -| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | -| **火山引擎(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) | -| **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) | -| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### 基础配置示例 - -```json -{ - "model_list": [ - { - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" - }, - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.4" - } - } -} -``` - -#### 各厂商配置示例 - -**OpenAI** - -```json -{ - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-..." -} -``` - -**火山引擎(Doubao)** - -```json -{ - "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-..." -} -``` - -**智谱 AI (GLM)** - -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**DeepSeek** - -```json -{ - "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." -} -``` - -**Anthropic (使用 OAuth)** - -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" -} -``` - -> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 - -**Anthropic Messages API(原生格式)** - -用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点: - -```json -{ - "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", - "api_base": "https://api.anthropic.com" -} -``` - -> 使用 `anthropic-messages` 协议的场景: -> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`) -> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务 -> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式) -> -> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。 - -**Ollama (本地)** - -```json -{ - "model_name": "llama3", - "model": "ollama/llama3" -} -``` - -**自定义代理/API** - -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -#### 负载均衡 - -为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询: - -```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" - } - ] -} -``` - -#### 从旧的 `providers` 配置迁移 - -旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 - -**旧配置(已弃用):** - -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**新配置(推荐):** - -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。 - -
-智谱 (Zhipu) 配置示例 - -**1. 获取 API key 和 base URL** - -- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. 配置** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "zhipu": { - "api_key": "Your API Key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } -} -``` - -**3. 运行** - -```bash -picoclaw agent -m "你好" - -``` - -
- -
-完整配置示例 - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -## CLI 命令行参考 - -| 命令 | 描述 | -| ------------------------- | ------------------ | -| `picoclaw onboard` | 初始化配置和工作区 | -| `picoclaw agent -m "..."` | 与 Agent 对话 | -| `picoclaw agent` | 交互式聊天模式 | -| `picoclaw gateway` | 启动网关 (Gateway) | -| `picoclaw status` | 显示状态 | -| `picoclaw cron list` | 列出所有定时任务 | -| `picoclaw cron add ...` | 添加定时任务 | - -### 定时任务 / 提醒 (Scheduled Tasks) +通过 CLI 或任何已集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 + +**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ CLI 命令行参考 + +| 命令 | 说明 | +| ------------------------- | ---------------------- | +| `picoclaw onboard` | 初始化配置与工作区 | +| `picoclaw agent -m "..."` | 与 Agent 对话 | +| `picoclaw agent` | 交互式对话模式 | +| `picoclaw gateway` | 启动网关 | +| `picoclaw status` | 查看状态 | +| `picoclaw version` | 查看版本信息 | +| `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` 工具支持定时提醒和重复任务: -- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次 -- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发 -- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式 +* **一次性提醒**: "10分钟后提醒我" → 10分钟后触发一次 +* **重复任务**: "每2小时提醒我" → 每2小时触发 +* **Cron 表达式**: "每天上午9点提醒我" → 使用 cron 表达式 -任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。 - -## 🤝 贡献与路线图 (Roadmap) +## 🤝 贡献与路线图 欢迎提交 PR!代码库刻意保持小巧和可读。🤗 -路线图即将发布... +查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)。 开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 用户群组: -Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) +Discord: PicoClaw - -## 🐛 疑难解答 (Troubleshooting) - -### 网络搜索提示 "API 配置问题" - -如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。 - -启用网络搜索: - -1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费) -2. 添加到 `~/.picoclaw/config.json`: - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -### 遇到内容过滤错误 (Content Filtering Errors) - -某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。 - -### Telegram bot 提示 "Conflict: terminated by other getUpdates" - -这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。 - ---- - -## 📝 API Key 对比 - -| 服务 | 免费层级 | 适用场景 | -| --- | --- | --- | -| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | -| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) | -| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 | -| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | -| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | -| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | -| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) | -| **ModelScope (魔搭)** | 2000 次请求/天 | 免费推理 (Qwen, GLM, DeepSeek 等) | - ---- - -
- PicoClaw Meme -
diff --git a/assets/wechat.png b/assets/wechat.png index 4cfcbbb1a8a24f35dfceb8554aee56f2fb1c8ec9..6512421edec830f5d3b1ced07b923656b499a599 100644 GIT binary patch literal 162306 zcmeEtRaYEcv@MVjf(Lg9!QI^g1PJc#!QCAig1fs0cL=VHySp^*+PFi*CEq>gC)~ID zwD+hQWos;%YtB_+3UU(22>1w4P*BK{KYu7eLBaSzL464L4D)^@Uxv!|eSx?Csp$lT zLY@SLLg@#E$Ka(31@#3=@`tdBd*Zb8}8eA^v$ zS)DhBQ!>!ffe&DVN$~!VDZ_aO0O5Qa@r8xjychO^eh}ycrCuV%0AARi?q9t~9%oZB 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

+SpWi7D&(VT~X6z6}YNGzs@P$dlZL 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 Rba(}PDMHj_>!eLX{}+sE=|%tm literal 352888 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νECe-OA-i>O%}aIb@Sb zU0Z2(1yk%`9>;hXV5+pJW`6#UBY=ZNOAn*CQ2N&1F(I@i;jOBsXMdeX((kt_c*+FZFd*c z<;#;n5kHiDOcO)O{?ikOYBF!^86&Km8yV?mgf|^-T8~jntn1PDxupbF;d(6~`3eRV%o!y(S9BU?)hV8?lJqKhh7#SpHgZ|qT#y*ZS?-kpQ&)CneC9uUU)2oWcg z6;+L(l#dW4muory4#!QmHJbM=;>!pjPWqs6%kl8C)KN<{o^;Ii&+Dnns=czq=JUkq z)TwRGGjsLFa2%MeG4uzr6w(y_kib%G{1}O+r94F?a?F#A1c6pyJ0mBsr9PRM%9_lbGxro zws|m!7a_Hrsp#^6IsHI}3ilHd1w4WRfD(RWNrw6xN);~3DLzY;vZZ~qQ0X5F?b2&q zc&}hj=eW$uq8-~cGixK&s7Ll&VEmUsWt*aU&KM;uy@xpaXYa+4hNB z?9XEeF>XUh3_-Z#bmP_?tIMm0;sHAsu~}ilEzVPE=z)b8txirCGK>4Xe9PnJi79r9 zoya<2eC9nKA#(k!R3BAR@d_>?Ml#h>$XN;A`b5qpwrZ$}ozHu8Rntd~FIX0>kQwTG z^OsGYdP0TBCfh(<2w72(Ot#1`^6WkS=sjy)-T|E4&um1@q)tiV{kB|2QwEX%3o5a6 zrl-p3>T=}9M0`zp;DnT}#d+Ad?-vhr`j@_hr;)wDeGh(@^>kE}6_=vhucgp-I1Q-) zWW8(3Tf}U={rMloxLf~z+6^azxt7HHw2?Jz{6OEELygsE<-&rhF{MFVmU#n3v`j7k z#g_Orw#4&G>af@%A+VI*SbY~RrCSK}Lzfkq)%enUh~dI=o7vc}Q6`!mk8SPK(>X_s zO(MM2W#D7olDEW2_G<#n@s~4{3)B4=0SQs!CMIV-`)11ei|ZspfpQUN zt%)Ph5JE}^#q8fK)X<>Xoup~ArC5&qh9*{**{lRb*;-| z4?<|Hr@E^acvX>e!km7(@l_*fW>!jjckZ%bl?c;jvU$`+`%lUsr$+B`ZF84B|m(ESwMA6e@{m0eB|NT6#ch>)}#wzyLD%{uz{tQ^p>2zcU^1 zd|A8d+a+B084A?iSsSO0b#{kC(G6`TAc-lDgf!^``WM@0vIiBBcUY+UJ4x`sj=ui& zbu~h(sVUmEhHZrlQ&U(bfWh|+oW{~gzu$$xBI|33@(~bVscs`3EkTXbTjCf|J!G#) zL?VPg?<#h}GL&XeIeB9{v*_Yerb4q<-49X%ofv-+?0$S;P8ugI8V6NduUj&RB(W|~ z1;x2FzJG`Fn`if8SmepHK62e+Sz8IQ%X{l^r@E}>7)E6pG7N<~bl${8uwGT3X`?qa z98-5}WRjSj=|s-bb8~}%cuKZvYS|67yg;&=a)kNB?G?R(% zo((43K~vr?4nMiH9HH@?9)Y#YWS&huL8g98DlYmChohtFzv(9$ta4LT!#TaiD$EGZ z!8`-Y`WTh%A3_~#SI8;J3uj`}a^&jJ zyy~io^WMs! zfH0nzX{V2kXRD80V~|sw$I0wzbq25bJ^t4WrkkTsQSU(oI5TK@d>ROY^i2i&Pf1FP z?hJ*8)tXNLle-N{zZ1x>ht=Mllhv*+L^|jhPdasHA?1K+0LH`073#NouFz$8Y&w#K zU26d$^+h2AH15*nKF(lhOKh4qn@M-4$HDh+O_xuiWuL$g+hVXH`z}O2K|&KMpWxS= zjw0fX@<;jBkr%N;s4{xLgL}pswxG0t)5NcMH>YdXoT5OYG<%WdMYrCAnV49Tfv*5S}H$xpD!rdxuUCDW3qJMgKP2LqbWVk zH`*O%@z8v+vHtYfafY!EjMmfc^F>I6>`GsU&17x)c)d&dQr9{b0O_?9M*S6H9tJuv z;zR^<^e;N3PWtk>@b}k5$Hfv|7r~;)9(W$h4s-L*{mNUSIM$Vk-M|~~{BrADUrmtY zj`~FTPZ*;Tqo!BgVmea4GzU5H_A&pPwnwu7IJ0;tmJq+@xdNrKJ=0>qE2Gdok&gR+ z6_xh2qS7ue0jbZ@sd4_zU_832gNP4%lgu%-adYFseCVPspA7hi`-QbGwQYRzeW8**am2cT!pQ?4OQZ95^m(jm(C7W$ED&ZR=c}`_PkpIhZ;1|JDY?}lp~8q zK{_S^o&Bl@l+?bSk66M-DU*{Y3x@=KqDIcf?OeD;DXU&W_+STLQ`aCCE}cr^bJTBr zUz=h|nysVW$}e=x4$o}N0DUe^gI>P$9Ce7l+)rayWh?+*N~My_-!DF5ef(V2(t>`OSGuRW-%e>@GcYH|1gMadP)iTiog@x@8x z{?GnTLpB27c8UXh`B5Kc!huTmswym((WR&=4w2#WCg~6oND(A~e^=>}ilFApcI-ZU z23ghJddIQN8#jZyrcpVK>!+lpHa{Kzd;tyNoO6@7d^IqgQ`H6Zlh5@jf%JyM11Grt zacKO>H~DH!L`%4}5E`ypyRvd{-CioH;+I$>C*S|e#2-@Mxq2c#XvxsZoOKjh8=Qii znzS3aBNt^Bx2+cYlU1m{AE!efL@IB6q=t3c9hv2Aa9QiWB=TY!2#)O8V<9jebT)Z0 zubd-v$t(E2f9fmXZr{+=Snr2=o~t|KDW#7c8z&1H=;ktZQC{n$oB2l%q)tD`ZX~mR ze41SH?K^>x<|0pa_}rNE_+`O+IdI&a4U7L%S-+apVgGwd`FqeXRy2d4k8r=o3)A{H z+;&T*lV{s!0(I!Opc3$)ItkF=b03qnc8ZEQN7iba$J^82fNJodee57tZ4BF&zqAuu z|G@>W*W~8RUPudJrY2( z#oc?NvvqJ>-jl9EvLFDO@h#?-yO$LONFhXED%mq5#K`>&++E?iS$(&{W?4VtT+yQNWP+4g znN%+Y#j5NxT~U_STIC2OH)r2O9&h9_Bb5N4svrV{=JpfHuWaBGY2YL8vCZqb$@Y_@ z?Z~~OzGB`milx`;b2<)UtX;>_u}?c@msFB)PMa+dcc{n9)%7fb-G5`;4`YArbd6eMp07^CqC-JGT#y)(Fs*COJvc3A<=9R>NC`q=keh*X>y0|9b<7oV^3^teU$l+$hF%t zxxd&qSjIgbd!>ReO26IAVt#$>lucaK?bV(ORgA7`$5BG(-K`)InPuT%r5-QI+5omM z;>5;4Jr_>hkukH5X9CX--(f9Om4f74#DwBZ8xXL0-aJr`RNy0!aCf(9!Iydh&VF$@ zPg_nXwtv2ckeb|WrrlsC|S7IR?qu0)WLfa53Hil&>9j8ylV{r-mk1` zK&8c-WI?g6n?qM?-0~sEr9+;F!omh`E^K!tCsY*EzInN_@LuYQezTP86g!XTXEu@~ z&`oc67oUh`Rt`BEBD2c1{^T=3SXH8rYE${|T~pF9%$~WW5A=w~jZRd?*&(GFcUL7% zOY)>$Q*>|QHjqECYE%iV-EaVyr8b5+5g8e*r1rSM|Sg-~9K#$Z25jn-qA+TZW%S8;znPz;6``FZA&2hOeorlW$S#K-X zcA(a^9*k0V+*t1TMiVvz(8*T*hjQbMfJ)P*Rlni0z`dI*)A_}0pDZ7`%I<4znnP{E ziX19ONeSV_kAZq3XkSI%FQj;nLoG*Tc?)BN@USYv8>JdlxMcKK@bL%eG^7IG(A`)R zR#i=O%I5sZaI7?~k{Co`{uEbeRb=3auUQImyV)7lr`A*$-J|}Y0o4Yp1wG77i{6rbsV)cn z{urY+h&qLT@|zw3MitJ^aAAn-jB|?MUC1y6eARo|>W{ocho}bR@v?X)&KTZVCzCtW zo1vSEnDtk}Qos+Dl1MwmG1!W~R!^^6GFP7LUY46YXyvL|We0O0U0?M8V?%$0FZ3ND z5nu`suj6}iS|=5ulgYAN2P0=5HlsbovB!qheU+>(1(wQXdtH8qZa3FG)yjI`i_x3} z%)FiVI2c}n`Ygi)!Jtw3-m^Y{gaGplhWQ#k3Ax2_y*49L)VIRAsb&kk5Os!h6ZMVn za&BQ04Z{tOhq_-GN_9VeALO3M5EeEtSXo{27a?P7Pi3?HnuPzs0_uKKVUxGi?LVqb zpIf!zDwG4^f1D0HE>a8Fmj0FZxv-_{x*#F2c*YL$xLU20=JLB6P+^a8l;uPxr1K4m z!h!Q?W3=2pBP0SmD-)uqD+o6q{Q|qUn-+VlLaygg`Bb-Ro(wu{gYg4cSj%x>VJ-hQ z<^9SXkV5)WHvf#BdLbTnq}8Nf<7pjZ4zQO2-$FzfJ*d7D6rGaRPtlv(W6imG{~+}= z;CQMQ3oTKu^{pp?uZ>TqRxyiL>1pjfQPxfPIs?i zGypDcsdcf(;qVSy~V? zF>!MuYb}aOfe&*9L8R5W_}avBD`8I?#r+!E(1jrw=b19avqN{NaSLRk@va9ZXWR|{ zRe)ATCp*trb7-C#_)Pq$gjmz|f zCWo>2R`%Up6}bmL#ym<21?K*$VsJ;QAQ6ctY$}lj>|Dler zXj84q)0S#BylGO{dfi~%uC13*Wogi2(7gVZL92)3b{6Kx&H24r9Ijui(u>qchC&7glmP9EAc7ISa7<6jU>``0&0e>hr-It(m_IkZJp z(V)OUT2vX?pLr-#Qnoq%=B?<`d*=ILbE{vO*ShT=RS)V?O;l~Gnh3Gr&~z$N!lJ1B zTaGJi(7VECq|9tWiIE_HZocHl|G!vAMuS7f(n|JdIC09qBPz9@l|9Ak0geBr8aa60 zN%VmA20t86QdWP9YV$!SQ@fnxv7Du;V)R|CWP!De&MuJ>Fe7`N#8f|%Df|0xsSks< zl;k8D!^-rrwCIK9${4k&_^bmO=d<1u3eQ3Ine(94Z<)AEU0{$q#P!iIB`CY@Yd& z;0Rfv10%#UE^59ZYoE{d!N&r4P1%ZB1q?0amy$3?kFMU9N=2M&Acd;h@KBJN@h`Fl zYiB9ZV_J*_&c{VR7D#?#F8UKuMp3q&AK=FKfZv()Mx#x=-+sNR%qba_ixu~!Z;uL1 zkN^?vtx;Ay-)zT8r$3YG%%$+dSTK$&;*A4Vh?%YR1*TxhsBV2F4?$>j4R_DJ(YP=n ziA>TALYyMN{E0VK#Tf-eja4*G&PF^XcS$<*H2BDLUD)+AzX|{Ge<;iL6=m7}Ed2w^ z0s%>?a`g@F^IsBz<3{P{RmzZ_pre%0J7>ZSCQr;VCOH+knJudPuhfbp;q^FGf8-;| zz7uQnV$GEO0Vv|Tz^e=vAaHUrzSCer`(u6Xv$UT+Gq0Oo(c|codlDH{WqvUuNg(r2 z`Dci$j%>spGy41Y=k}>YFCRvB9U(}+`oYF_*gE-cugGsn5}gL zLPVjNoCYi~Q}ef}dhhz;o7Q|lsLwmld;VKr68I2KdLU;3u0J{IjdT83CJlXivuSxPl;-2jhTY7T zaxXo30F!KKb!06-r71G&)I5>};1ASfaAzs1>+6Us5yhl~F}o$f@lO}Y3wngM=gA|~K4VDcrCJ46js$sA9$+*O zr1pA;_pr~`%OFf1B@cCd5h- zeyERlZW{@4AkgBYsAJkBu(5IG!WqMXjgKr6(6G)inaejP=Rq%hmKZ&kArvtGVYkhOk21Z?Ug?20qNCu5Uc~r(m(anO#-{VdZG6P z$81bj!w+#&e|6C<7MA)c=cR62j-Qgcjl|751E@Y8RH81uQ!8uS@D(A!+qXnt(-UTcKi>I&g>nrKMy1#B9Q7Ds{BMmH0h&qlukoAWvE9s3jFL+@ ziC~sHIp&_0^}M$Q32ycKA3Z;(g=Enho!Bx%p@bGTIMsG;HSfdF>M@!v#pcd|j(M}J zN8q8W7Yi-sAq9$3qRMQ(I>?~ASPVq(Tnd`TjD?t*)WD;SN`hasy-h@B;`SST4M@ z=*n^BT5jrXmDknOMMHgD{bT^6&nOpvW}As>iVi+*@+6-WOWWFOxCLZK|JO-)UbP0E zLPmC2fTkqiDq{@!y6QG$c>>P2_Xt8WCx*Gr;4-T@pPM)q*PG1y&CNcMK11qA z=D{=0*XbyP+*kHR>s{gEtb|DimWzhbf)TmMM$Dk79Q@1U>vmjx2#ktj#M*3t%-NlI z$~GSjXiRx2`3R`t&qQo}CM=h2vCOH+9C!fCiw3y9)V$BBx$gxn7g&ibS#@s=rIgwP zPPC7e1P5wzecn%Wk$pYazq7$54NrH%yIxqj@#Pwt`yy&ul`hEs*O8UZVi_VFFr<+P zVM;J6EVj~H?DhgbWNrR($2Tn${k^_7NwxiJA`=4YMusDa)kWlEsWn4~*e}=%k$TKq zwdBMN0iBWmIGV98$ptm`w0C_z*h$lWnu-#;F=W9eb?_f#GHm8YaVYq0r^}kg@AC?J z)n7rcx~WRR>qjmw3W=vk5;-zgGN?7vG2D&SRRQnjZ5Rb%h;!xANqRN=)!~#cU&a)D z;~>xRH%>>FVX?31um>enm`n!h;~`8}=b{e2L4(xy;Vr3iGn)zyw`oC9f&#RoH*|r6 z>466*3zVGj2pJ=iPxp=XSS`HX;uHxP8ueNkMKdUd4A%9VOz%iDO;WGWoCNidlrSru z(ygDEa5O$VP>V@*I3+Sw0upf`W3yF%)lXRdWWmLpk+rcq zGn`;;4-NV%t&qMUg1J$59{qG=sGUo5oNa9%)5>7+XD6+FdK41z&;{_^@tH7hXu|p= zVG)s2tr+5P__fpBR=t$Vsk~XC%M_Em4S~0HPLFD~#yzI#kYxsX;2T~xRod9$<8tY^xV9V`OVwb@@ONzxp$l`oQ6tbCM?FK* zDRxF!1U{e8KplE=;xt}~K=h17^MAboc9ALgn$d#WOQx{e*cl>WCT^&7v~H?~QN$Ti z{*KX(PMmMx+*gQXYG|t{qu}u-AI}+%8=c|0<3zH@BIenbhf-Df+orxLF~E>zR{^<$ zsZU{!jq=4E%(Tz7bafj7ldf*zje#mKuzL%Vn8-q7YLZGc zcmIqbHhfi<8Lb8-z@dZfq|X@9a&#(Ip`w4Uq+Q0*5kJGK6?JE_X@UEWmx!Y57alVD zUlBpyy`V5i*A5U}20Ii@e#2rl_K0-l&tbFe+QGb z3~MR(CMGMsOltCEuigPOu_*X=_Ws50k7Db1($SZs+)wtP;IWiCxh)#vzP_E{23q2+i+sa3(90z-76;%fm9jdr)nU1v7NE( zjyR-Xv*&_o3o1%6%HzO@-qwleyIB(E##TBY*QR>Y>Q?w;D5j^FE~CSt<$GjhPmA!< zH%hEuvr*+kN&|FVEoke~qX++y)cp7#|FwOU6>QI^7GBxOdjw90ZlqKuH%Yhg#q8lE z`@&wMwnTGZCgPW@$KSAN-U@|AH|?i(Y^vD;vfM{c!pTxXzP`%fVqP9RX%h_JzJ56AqyW0e*G4S;?Q6iyz=}7DUAlJNE68(|$ zo;b(aFm6Ykvn+968+10F9cOcyVVXBtsZ;ChbgK^78?42SB&6O;am42%o3Cktc%%B3 z>CbKFya!Z`h<6+dzjs%&O4cT{R8%hQRTPA)BJC3Z{X`6R;~_)DnK(dE=<>E|SwC@f zbgm|Ni?hMjTMc39*ddf0SVrq-Ib!i(&YhMRcQpt_wJl#R;EkfZ1V9Nm@J;wXCq(+C z|DSl)F9$1b{c-|3Fx>|ZNxk^GXKepity6P`?uxNf+X6TE9UN4>DY?LfxEjFnX4%n; z56N7gMZSJu7VPEAAtE5~@iPa}Z*q$3=FfdPj3CpDuMydTeL(!LUbQhN&B~Qb)+;DW zAJ^=^DOIuZRH!1#juO#ge~zJvp*W+PJIJh0iDAaQd3*}2px|u4!cI_^!`s7Lxov?F zpm!0$V*Q-bE+b~(WC0ImRtf{-N{PYGMrJ023&~tH;yKg1a~VY1B0zouJQW5x=#sCW zr$F16M=0fUBJ7E!6Z_gNBCJoXV zq`hn!ovwpj>GvnSEbA@%$sxkktITOE?{VJi5rw?t7>~%e`o~NfTx^-MR59^Nq-Fj* z(ZFtfpDfbfy?#hi2Zt2Ie`S4Uz;BGgp*Hk@@(?)r2XU=Xs@ar->)_He5*_j4F@m;= z5D@=prYGCU5#VvG?|)Pj-}_~*!BUVJ(+-J%>_UD#oE^*9dOPNT5Olfd#@;Z39JpM& zpC683S5upF(;^-q3Y8C+!e&dL3S&|SO;XrHi(>oeI@Xwl_o z$AV=Me9!k$x&rS@p0AI764-CTNQKEOsW3US+Ijt0N2$Y#G*i9P zE_9`Od-H*B5ih2ht7%eI$bsE@a?Ms`S)A;Il5BNJ7zYUsU}YrRr|iAHbj%voK4|Ne zZVRB}KCY0}Ptlu`H2Av`3hj0{qRJf$n$MkrtsZ?E)9H4<+?_1i_=bj%gEo>dRTzME zlG0fVZ5e|@lh=ThlTsyPevmV;sL`9NdRaIvQPS~Y^>6Pye^A1^fm&dPpybA=G@Y0x>(`;6h_K@{EwR6hS(*_1ED_Y!-Hc9!FOW7!9mm+!!sA*jn6t>mny@9tF zkBa?7{Fm$HQ!O>ow#G(jr{l(%LyUxYtlO|KDNDW#5LN=#&EJO*Q#`}`o;KdaHkqWQ z-y}Nn?XmtAU2osMJ+1)s*J0)@uSuPVr2Jpu`2V1y{ADc%a+XijH|P8;1zkREg*9v0xIvg|iy0=IaV5Ys5z@rtfPbilDje)n zBj_OLQJMqEt>9H#JY=lLt@xo(I+tb=NUS!fchIks0+sxg%^KvXZ^$VwTxN!`fkQHpM|r z>@sohA#4(4rBor;VS`^8*55DebLvX8OPK!zEDLD=OD`0;*DsXwKk^{(3bh1^8988? zv__6l6=tU8#BrWNtcsQCM3JmCqs?jvu)dr~BlV}9f%)+4s)j9HgRDM@`r3q>Z1xIK zsAL+1Ie)Rzd~ZFd1vwaa;sGnWi;qWsmRsWYvQ(M3?-Qf5nY6xR$o>3y;{;T$O17cz zKb$1x$4ZD#H_8!h?U09F`W|L2`(F0;F_ljY&7@n7GQAu+D&+ynLI29R31bJUFBQ{T7hM1!omC+J%GbC1uzR4Gi|fy*7ThUS*Bf z4>nv5mA!#hzf2T!aV(m*04E<=11=j>ai`{X$4Wz1`CTx&3G{?V7*`#Y*UB>|oN%wO z1!F6(;`|t#jRck&H2~tJ?cdgdx3JJc;5)6ijY|t9e(?mUK>Azqm-u|yDG9BhR(d

E!BtsbA)M4DS3exufDt_$*1#cZv{`V(nz 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

-N2B#AUJ4(fC(0#c;Uk_(&L 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%0D4*Z(UO_`MTN#2r)@MAVxyO(2Nm)0Gd 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 diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 5e42a677f..7a476e737 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -5,6 +5,8 @@ import ( "github.com/spf13/cobra" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/gateway" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -14,6 +16,7 @@ func NewGatewayCommand() *cobra.Command { var noTruncate bool var orchestration bool var enableStats bool + var allowEmpty bool cmd := &cobra.Command{ Use: "gateway", @@ -33,7 +36,7 @@ func NewGatewayCommand() *cobra.Command { return nil }, RunE: func(_ *cobra.Command, _ []string) error { - return gatewayCmd(debug, orchestration, enableStats) + return gateway.Run(debug, internal.GetConfigPath(), orchestration, enableStats, allowEmpty) }, } @@ -41,6 +44,13 @@ func NewGatewayCommand() *cobra.Command { cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs") cmd.Flags().BoolVar(&orchestration, "orchestration", false, "Enable subagent orchestration") cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats collection") + cmd.Flags().BoolVarP( + &allowEmpty, + "allow-empty", + "E", + false, + "Continue starting even when no default model is configured", + ) return cmd } diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 4d591ea67..839a7315a 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -28,4 +28,5 @@ 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")) } diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index e04bccffb..6b2d65c91 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -12,7 +12,7 @@ const Logo = "🦞" // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { - if home := os.Getenv("PICOCLAW_HOME"); home != "" { + if home := os.Getenv(config.EnvHome); home != "" { return home } home, _ := os.UserHomeDir() @@ -20,7 +20,7 @@ func GetPicoclawHome() string { } func GetConfigPath() string { - if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { + if configPath := os.Getenv(config.EnvConfig); configPath != "" { return configPath } return filepath.Join(GetPicoclawHome(), "config.json") diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index ec1012959..9f8b288c6 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -11,14 +11,19 @@ import ( var embeddedFiles embed.FS func NewOnboardCommand() *cobra.Command { + var encrypt bool + cmd := &cobra.Command{ Use: "onboard", Aliases: []string{"o"}, Short: "Initialize picoclaw configuration and workspace", Run: func(cmd *cobra.Command, args []string) { - onboard() + onboard(encrypt) }, } + cmd.Flags().BoolVar(&encrypt, "enc", false, + "Enable credential encryption (generates SSH key and prompts for passphrase)") + return cmd } diff --git a/cmd/picoclaw/internal/onboard/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go index bc799a079..56936190b 100644 --- a/cmd/picoclaw/internal/onboard/command_test.go +++ b/cmd/picoclaw/internal/onboard/command_test.go @@ -24,6 +24,9 @@ func TestNewOnboardCommand(t *testing.T) { assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) - assert.False(t, cmd.HasFlags()) + assert.True(t, cmd.HasFlags()) + encFlag := cmd.Flags().Lookup("enc") + require.NotNil(t, encFlag, "expected --enc flag to be registered") + assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false") assert.False(t, cmd.HasSubCommands()) } diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 4db8bdc8b..6f1d4bdd7 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -6,25 +6,71 @@ import ( "os" "path/filepath" + "golang.org/x/term" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" ) -func onboard() { +func onboard(encrypt bool) { configPath := internal.GetConfigPath() + configExists := false if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return + configExists = true + if encrypt { + // Only ask for confirmation when *both* config and SSH key already exist, + // indicating a full re-onboard that would reset the config to defaults. + sshKeyPath, _ := credential.DefaultSSHKeyPath() + if _, err := os.Stat(sshKeyPath); err == nil { + // Both exist — confirm a full reset. + fmt.Printf("Config already exists at %s\n", configPath) + fmt.Print("Overwrite config with defaults? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Aborted.") + return + } + configExists = false // user agreed to reset; treat as fresh + } + // Config exists but SSH key is missing — keep existing config, only add SSH key. } } - cfg := config.DefaultConfig() + var err error + if encrypt { + fmt.Println("\nSet up credential encryption") + fmt.Println("-----------------------------") + passphrase, pErr := promptPassphrase() + if pErr != nil { + fmt.Printf("Error: %v\n", pErr) + os.Exit(1) + } + // Expose the passphrase to credential.PassphraseProvider (which calls + // os.Getenv by default) so that SaveConfig can encrypt api_keys. + // This process is a one-shot CLI tool; the env var is never exposed outside + // the current process and disappears when it exits. + os.Setenv(credential.PassphraseEnvVar, passphrase) + + if err = setupSSHKey(); err != nil { + fmt.Printf("Error generating SSH key: %v\n", err) + os.Exit(1) + } + } + + var cfg *config.Config + if configExists { + // Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase. + cfg, err = config.LoadConfig(configPath) + if err != nil { + fmt.Printf("Error loading existing config: %v\n", err) + os.Exit(1) + } + } else { + cfg = config.DefaultConfig() + } if err := config.SaveConfig(configPath, cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) @@ -33,9 +79,17 @@ func onboard() { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("%s picoclaw is ready!\n", internal.Logo) + fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo) fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) + 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)") @@ -43,7 +97,62 @@ func onboard() { fmt.Println("") fmt.Println(" See README.md for 17+ supported providers.") fmt.Println("") - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") +} + +// promptPassphrase reads the encryption passphrase twice from the terminal +// (with echo disabled) and returns it. Returns an error if the passphrase is +// empty or if the two inputs do not match. +func promptPassphrase() (string, error) { + fmt.Print("Enter passphrase for credential encryption: ") + p1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase: %w", err) + } + if len(p1) == 0 { + return "", fmt.Errorf("passphrase must not be empty") + } + + fmt.Print("Confirm passphrase: ") + p2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase confirmation: %w", err) + } + + if string(p1) != string(p2) { + return "", fmt.Errorf("passphrases do not match") + } + return string(p1), nil +} + +// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key. +// If the key already exists the user is warned and asked to confirm overwrite. +// Answering anything other than "y" keeps the existing key (not an error). +func setupSSHKey() error { + keyPath, err := credential.DefaultSSHKeyPath() + if err != nil { + return fmt.Errorf("cannot determine SSH key path: %w", err) + } + + if _, err := os.Stat(keyPath); err == nil { + fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath) + fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.") + fmt.Print(" Overwrite? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Keeping existing SSH key.") + return nil + } + } + + if err := credential.GenerateSSHKey(keyPath); err != nil { + return err + } + fmt.Printf("SSH key generated: %s\n", keyPath) + return nil } func createWorkspaceTemplates(workspace string) { diff --git a/config/config.example.json b/config/config.example.json index 094aa46df..99eaab0aa 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -72,9 +72,8 @@ "token": "YOUR_TELEGRAM_BOT_TOKEN", "base_url": "", "proxy": "", - "allow_from": [ - "YOUR_USER_ID" - ], + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": false, "reasoning_channel_id": "" }, "discord": { @@ -117,7 +116,8 @@ "verification_token": "", "allow_from": [], "reasoning_channel_id": "", - "random_reaction_emoji": [] + "random_reaction_emoji": [], + "is_lark": false }, "dingtalk": { "enabled": false, @@ -307,6 +307,9 @@ "allow_write_paths": null, "web": { "enabled": true, + "prefer_native": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", @@ -345,7 +348,8 @@ "search_engine": "search_std", "max_results": 5 }, - "fetch_limit_bytes": 10485760 + "fetch_limit_bytes": 10485760, + "private_host_whitelist": [] }, "cron": { "enabled": true, @@ -512,6 +516,7 @@ }, "gateway": { "host": "127.0.0.1", - "port": 18790 + "port": 18790, + "hot_reload": false } } diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index 3fafffb7d..db7eb56eb 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -13,25 +13,27 @@ "app_secret": "xxx", "encrypt_key": "", "verification_token": "", - "allow_from": [] + "allow_from": [], + "is_lark": false } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| ------------------ | ------ | ---- | -------------------------------- | -| enabled | bool | 是 | 是否启用飞书频道 | -| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) | -| app_secret | string | 是 | 飞书应用的 App Secret | -| encrypt_key | string | 否 | 事件回调加密密钥 | -| verification_token | string | 否 | 用于Webhook事件验证的Token | -| allow_from | array | 否 | 用户ID白名单,空表示所有用户 | -| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" | +| 字段 | 类型 | 必填 | 描述 | +| --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ | +| enabled | bool | 是 | 是否启用飞书频道 | +| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) | +| app_secret | string | 是 | 飞书应用的 App Secret | +| encrypt_key | string | 否 | 事件回调加密密钥 | +| verification_token | string | 否 | 用于Webhook事件验证的Token | +| allow_from | array | 否 | 用户ID白名单,空表示所有用户 | +| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" | +| is_lark | bool | 否 | 是否使用 Lark 国际版域名(`open.larksuite.com`),默认为 `false`(使用飞书域名 `open.feishu.cn`) | ## 设置流程 -1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序 +1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用程序 2. 获取 App ID 和 App Secret 3. 配置事件订阅和Webhook URL 4. 设置加密(可选,生产环境建议启用) diff --git a/docs/chat-apps.md b/docs/chat-apps.md new file mode 100644 index 000000000..05afc7f33 --- /dev/null +++ b/docs/chat-apps.md @@ -0,0 +1,431 @@ +# 💬 Chat Apps Configuration + +> Back to [README](../README.md) + +## 💬 Chat Apps + +Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol) + +> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. + +| Channel | Setup | +| ------------ | ---------------------------------- | +| **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | +| **WhatsApp** | Easy (native: QR scan; or bridge URL) | +| **Matrix** | Medium (homeserver + bot access token) | +| **QQ** | Easy (AppID + AppSecret) | +| **DingTalk** | Medium (app credentials) | +| **LINE** | Medium (credentials + webhook URL) | +| **WeCom AI Bot** | Medium (Token + AES key) | +| **Feishu** | Medium (App ID + Secret, WebSocket mode) | +| **Slack** | Medium (Bot token + App token) | +| **IRC** | Medium (server + TLS config) | +| **OneBot** | Medium (QQ via OneBot protocol) | +| **MaixCam** | Easy (Sipeed hardware integration) | +| **Pico** | Native PicoClaw protocol | + +
+Telegram (Recommended) + +**1. Create a bot** + +* Open Telegram, search `@BotFather` +* Send `/newbot`, follow prompts +* Copy the token + +**2. Configure** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": false, + } + } +} +``` + +> Get your user ID from `@userinfobot` on Telegram. + +**3. Run** + +```bash +picoclaw gateway +``` + +**4. Telegram command menu (auto-registered at startup)** + +PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) 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. + +**4. Advanced Formatting** +You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks. + +
+ +
+Discord + +**1. Create a bot** + +* Go to +* Create an application → Bot → Add Bot +* Copy the bot token + +**2. Enable intents** + +* In the Bot settings, enable **MESSAGE CONTENT INTENT** +* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data + +**3. Get your User ID** +* Discord Settings → Advanced → enable **Developer Mode** +* Right-click your avatar → **Copy User ID** + +**4. Configure** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Invite the bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Open the generated invite URL and add the bot to your server + +**Optional: Group trigger mode** + +By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add: + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +You can also trigger by keyword prefixes (e.g. `!bot`): + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Run** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp (native via whatsmeow) + +PicoClaw can connect to WhatsApp in two ways: + +- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`). +- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. + +**Configure (native)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +If `session_store_path` is empty, the session is stored in `/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices. + +
+ +
+QQ + +**1. Create a bot** + +- Go to [QQ Open Platform](https://q.qq.com/#) +- Create an application → Get **AppID** and **AppSecret** + +**2. Configure** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access. + +**3. Run** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Create a bot** + +* Go to [Open Platform](https://open.dingtalk.com/) +* Create an internal app +* Copy Client ID and Client Secret + +**2. Configure** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access. + +**3. Run** + +```bash +picoclaw gateway +``` +
+ +
+Matrix + +**1. Prepare bot account** + +* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted) +* Create a bot user and obtain its access token + +**2. Configure** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md). + +
+ +
+LINE + +**1. Create a LINE Official Account** + +- Go to [LINE Developers Console](https://developers.line.biz/) +- Create a provider → Create a Messaging API channel +- Copy **Channel Secret** and **Channel Access Token** + +**2. Configure** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). + +**3. Set up Webhook URL** + +LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: + +```bash +# Example with ngrok (gateway default port is 18790) +ngrok http 18790 +``` + +Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. + +**4. Run** + +```bash +picoclaw gateway +``` + +> In group chats, the bot responds only when @mentioned. Replies quote the original message. + +
+ +
+WeCom (企业微信) + +PicoClaw supports three types of WeCom integration: + +**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats +**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only +**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat + +See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions. + +**Quick Setup - WeCom Bot:** + +**1. Create a bot** + +* Go to WeCom Admin Console → Group Chat → Add Group Bot +* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Configure** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). + +**Quick Setup - WeCom App:** + +**1. Create an app** + +* Go to WeCom Admin Console → App Management → Create App +* Copy **AgentId** and **Secret** +* Go to "My Company" page, copy **CorpID** + +**2. Configure receive message** + +* In App details, click "Receive Message" → "Set API" +* Set URL to `http://your-server:18790/webhook/wecom-app` +* Generate **Token** and **EncodingAESKey** + +**3. Configure** + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Run** + +```bash +picoclaw gateway +``` + +> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS. + +**Quick Setup - WeCom AI Bot:** + +**1. Create an AI Bot** + +* Go to WeCom Admin Console → App Management → AI Bot +* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` +* Copy **Token** and click "Random Generate" for **EncodingAESKey** + +**2. Configure** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery. + +
diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 000000000..9d503f44f --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,218 @@ +# ⚙️ Configuration Guide + +> Back to [README](../README.md) + +## ⚙️ Configuration + +Config file: `~/.picoclaw/config.json` + +### Environment Variables + +You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths. + +| Variable | Description | Default Path | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` | + +**Examples:** + +```bash +# Run picoclaw using a specific config file +# The workspace path will be read from within that config file +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Run picoclaw with all its data stored in /opt/picoclaw +# Config will be loaded from the default ~/.picoclaw/config.json +# Workspace will be created at /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Use both for a fully customized setup +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Workspace Layout + +PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Conversation sessions and history +├── memory/ # Long-term memory (MEMORY.md) +├── state/ # Persistent state (last channel, etc.) +├── cron/ # Scheduled jobs database +├── skills/ # Custom skills +├── AGENTS.md # Agent behavior guide +├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) +├── IDENTITY.md # Agent identity +├── SOUL.md # Agent soul +└── USER.md # User preferences +``` + +### Skill Sources + +By default, skills are loaded from: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin) + +For advanced/test setups, you can override the builtin skills root with: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### Unified Command Execution Policy + +- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. +- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup. +- Unknown slash command (for example `/foo`) passes through to normal LLM processing. +- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. +### 🔒 Security Sandbox + +PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. + +#### Default Configuration + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Default | Description | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | +| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | + +#### Protected Tools + +When `restrict_to_workspace: true`, the following tools are sandboxed: + +| Tool | Function | Restriction | +| ------------- | ---------------- | -------------------------------------- | +| `read_file` | Read files | Only files within workspace | +| `write_file` | Write files | Only files within workspace | +| `list_dir` | List directories | Only directories within workspace | +| `edit_file` | Edit files | Only files within workspace | +| `append_file` | Append to files | Only files within workspace | +| `exec` | Execute commands | Command paths must be within workspace | + +#### Additional Exec Protection + +Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: + +* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion +* `format`, `mkfs`, `diskpart` — Disk formatting +* `dd if=` — Disk imaging +* Writing to `/dev/sd[a-z]` — Direct disk writes +* `shutdown`, `reboot`, `poweroff` — System shutdown +* Fork bomb `:(){ :|:& };:` + +### File Access Control + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | +| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | + +### Exec Security + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | + +> **Security Note:** Symlink protection is enabled by default — all file paths are resolved through `filepath.EvalSymlinks` before whitelist matching, preventing symlink escape attacks. + +#### Known Limitation: Child Processes From Build Tools + +The exec safety guard only inspects the command line PicoClaw launches directly. It does not recursively inspect child +processes spawned by allowed developer tools such as `make`, `go run`, `cargo`, `npm run`, or custom build scripts. + +That means a top-level command can still compile or launch other binaries after it passes the initial guard check. In +practice, treat build scripts, Makefiles, package scripts, and generated binaries as executable code that needs the same +level of review as a direct shell command. + +For higher-risk environments: + +* Review build scripts before execution. +* Prefer approval/manual review for compile-and-run workflows. +* Run PicoClaw inside a container or VM if you need stronger isolation than the built-in guard provides. + +#### Error Examples + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Disabling Restrictions (Security Risk) + +If you need the agent to access paths outside the workspace: + +**Method 1: Config file** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Method 2: Environment variable** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. + +#### Security Boundary Consistency + +The `restrict_to_workspace` setting applies consistently across all execution paths: + +| Execution Path | Security Boundary | +| ---------------- | ---------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | + +All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. + +### Heartbeat (Periodic Tasks) + +PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools. + +#### Async Tasks with Spawn + +For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**: + +```markdown +# Periodic Tasks diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md new file mode 100644 index 000000000..448eaaa10 --- /dev/null +++ b/docs/credential_encryption.md @@ -0,0 +1,168 @@ +# Credential Encryption + +PicoClaw supports encrypting `api_key` values in `model_list` configuration entries. +Encrypted keys are stored as `enc://` strings and decrypted automatically at startup. + +--- + +## Quick Start + +**1. Set your passphrase** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Encrypt an API key** + +Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key, +then automatically re-encrypts any plaintext `api_key` entries in your config on +the next `SaveConfig` call. The resulting `enc://` value will look like: + +``` +enc://AAAA...base64... +``` + +**3. Paste the output into your config** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "api_key": "enc://AAAA...base64...", + "base_url": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Supported `api_key` Formats + +| Format | Example | Behaviour | +|--------|---------|-----------| +| Plaintext | `sk-abc123` | Used as-is | +| File reference | `file://openai.key` | Content read from the same directory as the config file | +| Encrypted | `enc://` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` | +| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) | + +--- + +## Cryptographic Design + +### Key Derivation + +Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor. + +``` +Without SSH key (passphrase only): + + ikm = SHA256(passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) + + +With SSH key (recommended): + + sshHash = SHA256(ssh_private_key_file_bytes) + ikm = HMAC-SHA256(key=sshHash, message=passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Encryption + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Wire Format + +``` +enc:// +``` + +| Field | Size | Description | +|-------|------|-------------| +| `salt` | 16 bytes | Random per encryption; fed into HKDF | +| `nonce` | 12 bytes | Random per encryption; AES-GCM IV | +| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag | + +The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext. + +### Performance + +| Operation | Time (ARM Cortex-A) | +|-----------|---------------------| +| Key derivation (HKDF) | < 1 ms | +| AES-256-GCM decrypt | < 1 ms | +| **Total startup overhead** | **< 2 ms per key** | + +--- + +## Two-Factor Security with SSH Key + +When a SSH private key is provided, breaking the encryption requires **both**: + +1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`) +2. The **SSH private key file** + +This means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength. + +### Threat Model + +| Attacker Has | Can Decrypt? | +|---|---| +| Config file only | No — needs passphrase + SSH key | +| SSH key only | No — needs passphrase | +| Passphrase only | No — needs SSH key | +| Config file + SSH key + passphrase | Yes — full compromise | + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation | +| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode | + +### SSH Key Auto-Detection + +If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +This dedicated file avoids conflicts with the user's existing SSH keys. +Run `picoclaw onboard` to generate it automatically. + +`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS). + +To explicitly disable SSH key usage and use passphrase-only mode: + +```bash +export PICOCLAW_SSH_KEY_PATH="" +``` + +--- + +## Migration + +Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward: + +1. Copy the config file to the new machine. +2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value. +3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location). + +No re-encryption is needed. + +--- + +## Security Considerations + +- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters). +- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file. +- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected. +- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values. diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 000000000..b91a7f68d --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,166 @@ +# 🐳 Docker & Quick Start Guide + +> Back to [README](../README.md) + +## 🐳 Docker Compose + +You can also run PicoClaw using Docker Compose without installing anything locally. + +```bash +# 1. Clone this repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. First run — auto-generates docker/data/config.json then exits +docker compose -f docker/docker-compose.yml --profile gateway up +# The container prints "First-run setup complete." and stops. + +# 3. Set your API keys +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Start +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. + +```bash +# 5. Check logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Stop +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher Mode (Web Console) + +The `launcher` image includes 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. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. + +> [!WARNING] +> The web console does not yet support authentication. Avoid exposing it to the public internet. + +### Agent Mode (One-shot) + +```bash +# Ask a question +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Interactive mode +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Update + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Quick Start + +> [!TIP] +> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month). + +**1. Initialize** + +```bash +picoclaw onboard +``` + +**2. Configure** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "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", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. +> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). + +**3. Get API Keys** + +* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Web Search** (optional): + * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) + * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface + * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) + * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) + * DuckDuckGo - Built-in fallback (no API key required) + +> **Note**: See `config.example.json` for a complete configuration template. + +**4. Chat** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +That's it! You have a working AI assistant in 2 minutes. + +--- diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md new file mode 100644 index 000000000..03bb6e17b --- /dev/null +++ b/docs/fr/chat-apps.md @@ -0,0 +1,588 @@ +# 💬 Configuration des Applications de Chat + +> Retour au [README](../../README.fr.md) + +## 💬 Applications de Chat + +Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam. + +> **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé. + +| Canal | Configuration | +| ------------ | -------------------------------------- | +| **Telegram** | Facile (juste un token) | +| **Discord** | Facile (bot token + intents) | +| **WhatsApp** | Facile (natif : scan QR ; ou bridge URL) | +| **Matrix** | Moyen (homeserver + bot access token) | +| **QQ** | Facile (AppID + AppSecret) | +| **DingTalk** | Moyen (identifiants de l'application) | +| **LINE** | Moyen (identifiants + webhook URL) | +| **WeCom AI Bot** | Moyen (Token + clé AES) | +| **Feishu** | Moyen (App ID + Secret, mode WebSocket) | +| **Slack** | Moyen (Bot token + App token) | +| **IRC** | Moyen (serveur + configuration TLS) | +| **OneBot** | Moyen (QQ via protocole OneBot) | +| **MaixCam** | Facile (intégration matérielle Sipeed) | +| **Pico** | Native PicoClaw protocol | + +
+Telegram (Recommandé) + +**1. Créer un bot** + +* Ouvrez Telegram, recherchez `@BotFather` +* Envoyez `/newbot`, suivez les instructions +* Copiez le token + +**2. Configurer** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenez votre identifiant utilisateur via `@userinfobot` sur Telegram. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +**4. Menu de commandes Telegram (enregistré automatiquement au démarrage)** + +PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`) 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. + +
+ +
+Discord + +**1. Créer un bot** + +* Allez sur +* Créez une application → Bot → Add Bot +* Copiez le token du bot + +**2. Activer les intents** + +* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT** +* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous prévoyez d'utiliser des listes d'autorisation basées sur les données des membres + +**3. Obtenir votre identifiant utilisateur** +* Paramètres Discord → Avancé → activez **Developer Mode** +* Clic droit sur votre avatar → **Copy User ID** + +**4. Configurer** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Inviter le bot** + +* OAuth2 → URL Generator +* Scopes : `bot` +* Bot Permissions : `Send Messages`, `Read Message History` +* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur + +**Mode déclenchement en groupe (optionnel)** + +Par défaut, le bot répond à tous les messages dans un canal de serveur. Pour limiter les réponses aux @mentions uniquement, ajoutez : + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Vous pouvez également déclencher par préfixes de mots-clés (par ex. `!bot`) : + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp (natif via whatsmeow) + +PicoClaw peut se connecter à WhatsApp de deux manières : + +- **Natif (recommandé) :** En processus via [whatsmeow](https://github.com/tulir/whatsmeow). Pas de bridge séparé. Définissez `"use_native": true` et laissez `bridge_url` vide. Au premier lancement, scannez le code QR avec WhatsApp (Appareils liés). La session est stockée dans votre workspace (par ex. `workspace/whatsapp/`). Le canal natif est **optionnel** pour garder le binaire par défaut léger ; compilez avec `-tags whatsapp_native` (par ex. `make build-whatsapp-native` ou `go build -tags whatsapp_native ./cmd/...`). +- **Bridge :** Connectez-vous à un bridge WebSocket externe. Définissez `bridge_url` (par ex. `ws://localhost:3001`) et gardez `use_native` à false. + +**Configurer (natif)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Si `session_store_path` est vide, la session est stockée dans `/whatsapp/`. Lancez `picoclaw gateway` ; au premier lancement, scannez le code QR affiché dans le terminal avec WhatsApp → Appareils liés. + +
+ +
+QQ + +**1. Créer un bot** + +- Allez sur [QQ Open Platform](https://q.qq.com/#) +- Créez une application → Obtenez **AppID** et **AppSecret** + +**2. Configurer** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Créer un bot** + +* Allez sur [Open Platform](https://open.dingtalk.com/) +* Créez une application interne +* Copiez le Client ID et le Client Secret + +**2. Configurer** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants DingTalk pour restreindre l'accès. + +**3. Lancer** + +```bash +picoclaw gateway +``` +
+ +
+Matrix + +**1. Préparer le compte bot** + +* Utilisez votre homeserver préféré (par ex. `https://matrix.org` ou auto-hébergé) +* Créez un utilisateur bot et obtenez son access token + +**2. Configurer** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](docs/channels/matrix/README.md). + +
+ +
+LINE + +**1. Créer un compte officiel LINE** + +- Allez sur [LINE Developers Console](https://developers.line.biz/) +- Créez un provider → Créez un canal Messaging API +- Copiez le **Channel Secret** et le **Channel Access Token** + +**2. Configurer** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> Le webhook LINE est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). + +**3. Configurer l'URL du Webhook** + +LINE nécessite HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : + +```bash +# Exemple avec ngrok (le port par défaut du gateway est 18790) +ngrok http 18790 +``` + +Puis définissez l'URL du Webhook dans la console LINE Developers à `https://your-domain/webhook/line` et activez **Use webhook**. + +**4. Lancer** + +```bash +picoclaw gateway +``` + +> Dans les discussions de groupe, le bot ne répond que lorsqu'il est @mentionné. Les réponses citent le message original. + +
+ +
+WeCom (企业微信) + +PicoClaw prend en charge trois types d'intégration WeCom : + +**Option 1 : WeCom Bot (Bot)** - Configuration plus facile, prend en charge les discussions de groupe +**Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement +**Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées + +Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour les instructions détaillées. + +**Configuration rapide - WeCom Bot :** + +**1. Créer un bot** + +* Allez dans la console d'administration WeCom → Discussion de groupe → Ajouter un bot de groupe +* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Configurer** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> Le webhook WeCom est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). + +**Configuration rapide - WeCom App :** + +**1. Créer une application** + +* Allez dans la console d'administration WeCom → Gestion des applications → Créer une application +* Copiez **AgentId** et **Secret** +* Allez sur la page "Mon entreprise", copiez **CorpID** + +**2. Configurer la réception des messages** + +* Dans les détails de l'application, cliquez sur "Recevoir les messages" → "Configurer l'API" +* Définissez l'URL à `http://your-server:18790/webhook/wecom-app` +* Générez **Token** et **EncodingAESKey** + +**3. Configurer** + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Lancer** + +```bash +picoclaw gateway +``` + +> **Note** : Les callbacks webhook WeCom sont servis sur le port Gateway (par défaut 18790). Utilisez un reverse proxy pour HTTPS. + +**Configuration rapide - WeCom AI Bot :** + +**1. Créer un AI Bot** + +* Allez dans la console d'administration WeCom → Gestion des applications → AI Bot +* Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot` +* Copiez **Token** et cliquez sur "Générer aléatoirement" pour **EncodingAESKey** + +**2. Configurer** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +> **Note** : WeCom AI Bot utilise le protocole streaming pull — pas de problème de timeout de réponse. Les tâches longues (>30 secondes) basculent automatiquement vers la livraison push via `response_url`. + +
+ +
+Feishu (飞书) + +**1. Créer une application** + +* Allez sur [Feishu Open Platform](https://open.feishu.cn/) +* Créez une application → Obtenez **App ID** et **App Secret** + +**2. Configurer** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +> Feishu utilise le mode WebSocket/SDK et ne nécessite pas de serveur webhook. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+Slack + +**1. Créer une application Slack** + +* Allez sur [Slack API](https://api.slack.com/apps) +* Créez une nouvelle application +* Obtenez le **Bot Token** et l'**App Token** + +**2. Configurer** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-your-bot-token", + "app_token": "xapp-your-app-token", + "allow_from": [] + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+IRC + +**1. Configurer le serveur IRC** + +* Préparez les informations de votre serveur IRC (adresse, port, canal) + +**2. Configurer** + +```json +{ + "channels": { + "irc": { + "enabled": true, + "server": "irc.example.com:6697", + "nick": "picoclaw-bot", + "channel": "#your-channel", + "use_tls": true, + "allow_from": [] + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+OneBot + +**1. Configurer OneBot** + +* Installez une implémentation OneBot compatible (par ex. go-cqhttp, Lagrange) +* Configurez la connexion WebSocket + +**2. Configurer** + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "allow_from": [] + } + } +} +``` + +> OneBot permet d'utiliser QQ via le protocole OneBot standard. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+MaixCam + +**1. Préparer le matériel** + +* Obtenez un appareil [Sipeed MaixCam](https://wiki.sipeed.com/maixcam) + +**2. Configurer** + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "allow_from": [] + } + } +} +``` + +> MaixCam est une intégration matérielle Sipeed pour l'interaction IA embarquée. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md new file mode 100644 index 000000000..c813fe25b --- /dev/null +++ b/docs/fr/configuration.md @@ -0,0 +1,217 @@ +# ⚙️ Guide de Configuration + +> Retour au [README](../../README.fr.md) + +## ⚙️ Configuration + +Fichier de configuration : `~/.picoclaw/config.json` + +### Variables d'Environnement + +Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de PicoClaw en tant que service système. Ces variables sont indépendantes et contrôlent des chemins différents. + +| Variable | Description | Chemin par défaut | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Remplace le chemin vers le fichier de configuration. Indique directement à PicoClaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Remplace le répertoire racine des données PicoClaw. Change l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` | + +**Exemples :** + +```bash +# Run picoclaw using a specific config file +# The workspace path will be read from within that config file +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Run picoclaw with all its data stored in /opt/picoclaw +# Config will be loaded from the default ~/.picoclaw/config.json +# Workspace will be created at /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Use both for a fully customized setup +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Structure du Workspace + +PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessions de conversation et historique +├── memory/ # Mémoire à long terme (MEMORY.md) +├── state/ # État persistant (dernier canal, etc.) +├── cron/ # Base de données des tâches planifiées +├── skills/ # Compétences personnalisées +├── AGENTS.md # Guide de comportement de l'agent +├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) +├── IDENTITY.md # Identité de l'agent +├── SOUL.md # Âme de l'agent +└── USER.md # Préférences utilisateur +``` + +### Sources de Compétences + +Par défaut, les compétences sont chargées depuis : + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin) + +Pour les configurations avancées/de test, vous pouvez remplacer la racine des compétences builtin avec : + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### 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. +- Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal. +- Une commande enregistrée mais non prise en charge sur le canal actuel (par exemple `/show` sur WhatsApp) renvoie une erreur explicite à l'utilisateur et arrête le traitement ultérieur. + +### 🔒 Sandbox de Sécurité + +PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes que dans le workspace configuré. + +#### Configuration par Défaut + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Par défaut | Description | +| ----------------------- | ----------------------- | ------------------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent | +| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace | + +#### Outils Protégés + +Lorsque `restrict_to_workspace: true`, les outils suivants sont sandboxés : + +| Outil | Fonction | Restriction | +| ------------- | --------------------- | ---------------------------------------------- | +| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace | +| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace | +| `list_dir` | Lister les répertoires| Uniquement les répertoires dans le workspace | +| `edit_file` | Modifier des fichiers | Uniquement les fichiers dans le workspace | +| `append_file` | Ajouter aux fichiers | Uniquement les fichiers dans le workspace | +| `exec` | Exécuter des commandes| Les chemins de commande doivent être dans le workspace | + +#### Protection Exec Supplémentaire + +Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses : + +* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse +* `format`, `mkfs`, `diskpart` — Formatage de disque +* `dd if=` — Imagerie de disque +* Écriture vers `/dev/sd[a-z]` — Écritures directes sur disque +* `shutdown`, `reboot`, `poweroff` — Arrêt du système +* Fork bomb `:(){ :|:& };:` + +### Contrôle d'Accès aux Fichiers + +| Clé de configuration | Type | Par défaut | Description | +|----------------------|------|------------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Chemins supplémentaires autorisés en lecture en dehors du workspace | +| `tools.allow_write_paths` | string[] | `[]` | Chemins supplémentaires autorisés en écriture en dehors du workspace | + +### Sécurité Exec + +| Clé de configuration | Type | Par défaut | Description | +|----------------------|------|------------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Autoriser l'outil exec depuis les canaux distants (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Activer l'interception des commandes dangereuses | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Patterns regex personnalisés à bloquer | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Patterns regex personnalisés à autoriser | + +> **Note de sécurité :** La protection Symlink est activée par défaut — tous les chemins de fichiers sont résolus via `filepath.EvalSymlinks` avant la correspondance avec la liste blanche, empêchant les attaques d'évasion par symlink. + +#### Limitation Connue : Processus Enfants des Outils de Build + +Le garde de sécurité exec n'inspecte que la ligne de commande lancée directement par PicoClaw. Il n'inspecte pas récursivement les processus enfants générés par les outils de développement autorisés tels que `make`, `go run`, `cargo`, `npm run` ou les scripts de build personnalisés. + +Cela signifie qu'une commande de niveau supérieur peut toujours compiler ou lancer d'autres binaires après avoir passé la vérification initiale du garde. En pratique, traitez les scripts de build, les Makefiles, les scripts de packages et les binaires générés comme du code exécutable nécessitant le même niveau de revue qu'une commande shell directe. + +Pour les environnements à haut risque : + +* Examinez les scripts de build avant l'exécution. +* Préférez l'approbation/revue manuelle pour les workflows de compilation et d'exécution. +* Exécutez PicoClaw dans un conteneur ou une VM si vous avez besoin d'une isolation plus forte que celle fournie par le garde intégré. + +#### Exemples d'Erreurs + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Désactiver les Restrictions (Risque de Sécurité) + +Si vous avez besoin que l'agent accède à des chemins en dehors du workspace : + +**Méthode 1 : Fichier de configuration** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Méthode 2 : Variable d'environnement** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Avertissement** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution dans des environnements contrôlés uniquement. + +#### Cohérence des Limites de Sécurité + +Le paramètre `restrict_to_workspace` s'applique de manière cohérente à tous les chemins d'exécution : + +| Chemin d'exécution | Limite de sécurité | +| ------------------ | -------------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Hérite de la même restriction ✅ | +| Heartbeat tasks | Hérite de la même restriction ✅ | + +Tous les chemins partagent la même restriction de workspace — il n'y a aucun moyen de contourner la limite de sécurité via les subagents ou les tâches planifiées. + +### Heartbeat (Tâches Périodiques) + +PicoClaw peut effectuer des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace : + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera toutes les tâches en utilisant les outils disponibles. + +#### Tâches Asynchrones avec Spawn + +Pour les tâches longues (recherche web, appels API), utilisez l'outil `spawn` pour créer un **subagent** : + +```markdown +# Periodic Tasks +``` diff --git a/docs/fr/docker.md b/docs/fr/docker.md new file mode 100644 index 000000000..f17ec355d --- /dev/null +++ b/docs/fr/docker.md @@ -0,0 +1,166 @@ +# 🐳 Docker et Démarrage Rapide + +> Retour au [README](../../README.fr.md) + +## 🐳 Docker Compose + +Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement. + +```bash +# 1. Cloner ce dépôt +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête +docker compose -f docker/docker-compose.yml --profile gateway up +# Le conteneur affiche "First-run setup complete." et s'arrête. + +# 3. Configurer vos clés API +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Démarrer +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous devez accéder aux endpoints de santé ou exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. + +```bash +# 5. Vérifier les logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Arrêter +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Mode Launcher (Console Web) + +L'image `launcher` inclut les 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. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway. + +> [!WARNING] +> La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public. + +### Mode Agent (One-shot) + +```bash +# Poser une question +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Mode interactif +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Mise à jour + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Démarrage Rapide + +> [!TIP] +> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenir des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement une [API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou une [API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois). + +**1. Initialiser** + +```bash +picoclaw onboard +``` + +**2. Configurer** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "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", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **Nouveau** : Le format de configuration `model_list` permet l'ajout de fournisseurs sans modification de code. Voir [Configuration des Modèles](#configuration-des-modèles-model_list) pour plus de détails. +> `request_timeout` est optionnel et utilise les secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le timeout par défaut (120s). + +**3. Obtenir des clés API** + +* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Recherche Web** (optionnel) : + * [Brave Search](https://brave.com/search/api) - Payant ($5/1000 requêtes, ~$5-6/mois) + * [Perplexity](https://www.perplexity.ai) - Recherche alimentée par l'IA avec interface de chat + * [SearXNG](https://github.com/searxng/searxng) - Métamoteur auto-hébergé (gratuit, pas de clé API nécessaire) + * [Tavily](https://tavily.com) - Optimisé pour les agents IA (1000 requêtes/mois) + * DuckDuckGo - Solution de repli intégrée (pas de clé API requise) + +> **Note** : Voir `config.example.json` pour un modèle de configuration complet. + +**4. Discuter** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +C'est tout ! Vous avez un assistant IA fonctionnel en 2 minutes. + +--- diff --git a/docs/fr/providers.md b/docs/fr/providers.md new file mode 100644 index 000000000..b0b950a44 --- /dev/null +++ b/docs/fr/providers.md @@ -0,0 +1,434 @@ +# 🔌 Fournisseurs et Configuration des Modèles + +> Retour au [README](../../README.fr.md) + +### Fournisseurs + +> [!NOTE] +> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM (Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | + +### Configuration des Modèles (model_list) + +> **Nouveauté** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `vendor/model` (par ex. `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs — **aucune modification de code requise !** + +Cette conception permet également le **support multi-agents** avec une sélection flexible de fournisseurs : + +- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM +- **Modèles de repli** : Configurez des modèles principaux et de repli pour la résilience +- **Répartition de charge** : Distribuez les requêtes entre plusieurs endpoints +- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit + +#### 📋 Tous les Vendors Supportés + +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | 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 | - | + +#### Configuration de Base + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-openai-key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-zhipu-key" + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +#### Exemples par Vendor + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-..." +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_key": "sk-..." +} +``` + +**Anthropic (avec clé API)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" +} +``` + +> Exécutez `picoclaw auth login --provider anthropic` pour coller votre token API. + +**API Anthropic Messages (format natif)** + +Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne prennent en charge que le format de message natif d'Anthropic : + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com" +} +``` + +> Utilisez le protocole `anthropic-messages` lorsque : +> - Vous utilisez des proxys tiers qui ne prennent en charge que l'endpoint natif `/v1/messages` d'Anthropic (pas le format compatible OpenAI `/v1/chat/completions`) +> - Vous vous connectez à des services comme MiniMax, Synthetic qui nécessitent le format de message natif d'Anthropic +> - Le protocole `anthropic` existant renvoie des erreurs 404 (indiquant que l'endpoint ne prend pas en charge le format compatible OpenAI) +> +> **Note :** Le protocole `anthropic` utilise le format compatible OpenAI (`/v1/chat/completions`), tandis que `anthropic-messages` utilise le format natif d'Anthropic (`/v1/messages`). Choisissez en fonction du format pris en charge par votre endpoint. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**Proxy/API Personnalisé** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_key": "sk-...", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_key": "sk-..." +} +``` + +PicoClaw ne supprime que le préfixe externe `litellm/` avant d'envoyer la requête, donc les alias de proxy comme `litellm/lite-gpt4` envoient `lite-gpt4`, tandis que `litellm/openai/gpt-4o` envoie `openai/gpt-4o`. + +#### Répartition de Charge + +Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectuera automatiquement un round-robin entre eux : + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_key": "sk-key1" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_key": "sk-key2" + } + ] +} +``` + +#### Migration depuis l'Ancienne Configuration `providers` + +L'ancienne configuration `providers` est **dépréciée** mais toujours prise en charge pour la compatibilité ascendante. + +**Ancienne configuration (dépréciée) :** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**Nouvelle configuration (recommandée) :** + +```json +{ + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "glm-4.7" + } + } +} +``` + +Pour un guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). + +### Architecture des Fournisseurs + +PicoClaw route les fournisseurs par famille de protocoles : + +- Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. +- Protocole 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`). + +
+Zhipu + +**1. Obtenir la clé API et l'URL de base** + +* Obtenir la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurer** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Exemple de configuration complète + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 Comparaison des Clés API + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/fr/spawn-tasks.md b/docs/fr/spawn-tasks.md new file mode 100644 index 000000000..5635cd645 --- /dev/null +++ b/docs/fr/spawn-tasks.md @@ -0,0 +1,61 @@ +# 🔄 Tâches Asynchrones et Spawn + +> Retour au [README](../../README.fr.md) + +## Tâches Rapides (réponse directe) + +- Rapporter l'heure actuelle + +## Tâches Longues (utiliser spawn pour l'asynchrone) + +- Rechercher sur le web des actualités IA et résumer +- Vérifier les emails et rapporter les messages importants +``` + +**Comportements clés :** + +| Fonctionnalité | Description | +| ----------------------- | --------------------------------------------------------------- | +| **spawn** | Crée un subagent asynchrone, ne bloque pas le heartbeat | +| **Independent context** | Le subagent a son propre contexte, pas d'historique de session | +| **message tool** | Le subagent communique directement avec l'utilisateur via l'outil message | +| **Non-blocking** | Après le spawn, le heartbeat continue à la tâche suivante | + +#### Fonctionnement de la Communication du Subagent + +``` +Heartbeat se déclenche + ↓ +L'agent lit HEARTBEAT.md + ↓ +Pour une tâche longue : spawn subagent + ↓ ↓ +Continue à la tâche suivante Le subagent travaille indépendamment + ↓ ↓ +Toutes les tâches terminées Le subagent utilise l'outil "message" + ↓ ↓ +Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement +``` + +Le subagent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal. + +**Configuration :** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Par défaut | Description | +| ---------- | ---------- | ---------------------------------------------- | +| `enabled` | `true` | Activer/désactiver le heartbeat | +| `interval` | `30` | Intervalle de vérification en minutes (min: 5) | + +**Variables d'environnement :** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver +* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour changer l'intervalle diff --git a/docs/fr/tools_configuration.md b/docs/fr/tools_configuration.md new file mode 100644 index 000000000..15573fc30 --- /dev/null +++ b/docs/fr/tools_configuration.md @@ -0,0 +1,336 @@ +# 🔧 Configuration des Outils + +> Retour au [README](../../README.fr.md) + +La configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`. + +## Structure du répertoire + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Outils Web + +Les outils web sont utilisés pour la recherche et la récupération de pages web. + +### Web Fetcher +Paramètres généraux pour la récupération et le traitement du contenu des pages web. + +| Config | Type | Par défaut | Description | +|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Activer la capacité de récupération de pages web. | +| `fetch_limit_bytes` | int | 10485760 | Taille maximale du contenu de la page web à récupérer, en octets (par défaut 10 Mo). | +| `format` | string | "plaintext" | Format de sortie du contenu récupéré. Options : `plaintext` ou `markdown` (recommandé). | + +### Brave + +| Config | Type | Par défaut | Description | +|---------------|--------|------------|---------------------------| +| `enabled` | bool | false | Activer la recherche Brave | +| `api_key` | string | - | Clé API Brave Search | +| `max_results` | int | 5 | Nombre maximum de résultats | + +### DuckDuckGo + +| Config | Type | Par défaut | Description | +|---------------|------|------------|--------------------------------| +| `enabled` | bool | true | Activer la recherche DuckDuckGo | +| `max_results` | int | 5 | Nombre maximum de résultats | + +### Perplexity + +| Config | Type | Par défaut | Description | +|---------------|--------|------------|--------------------------------| +| `enabled` | bool | false | Activer la recherche Perplexity | +| `api_key` | string | - | Clé API Perplexity | +| `max_results` | int | 5 | Nombre maximum de résultats | + +## Outil Exec + +L'outil exec est utilisé pour exécuter des commandes shell. + +| Config | Type | Par défaut | Description | +|------------------------|-------|------------|------------------------------------------------| +| `enable_deny_patterns` | bool | true | Activer le blocage par défaut des commandes dangereuses | +| `custom_deny_patterns` | array | [] | Modèles de refus personnalisés (expressions régulières) | + +### Fonctionnalité + +- **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses +- **`custom_deny_patterns`** : Ajouter des modèles regex de refus personnalisés ; les commandes correspondantes seront bloquées + +### Modèles de commandes bloquées par défaut + +Par défaut, PicoClaw bloque les commandes dangereuses suivantes : + +- Commandes de suppression : `rm -rf`, `del /f/q`, `rmdir /s` +- Opérations disque : `format`, `mkfs`, `diskpart`, `dd if=`, écriture vers `/dev/sd*` +- Opérations système : `shutdown`, `reboot`, `poweroff` +- Substitution de commandes : `$()`, `${}`, backticks +- Pipe vers shell : `| sh`, `| bash` +- Élévation de privilèges : `sudo`, `chmod`, `chown` +- Contrôle de processus : `pkill`, `killall`, `kill -9` +- Opérations distantes : `curl | sh`, `wget | sh`, `ssh` +- Gestion de paquets : `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Conteneurs : `docker run`, `docker exec` +- Git : `git push`, `git force` +- Autres : `eval`, `source *.sh` + +### Limitation architecturale connue + +Le garde exec ne valide que la commande de niveau supérieur envoyée à PicoClaw. Il n'inspecte **pas** récursivement les processus enfants générés par les outils de build ou les scripts après le démarrage de cette commande. + +Exemples de workflows pouvant contourner le garde de commande directe une fois la commande initiale autorisée : + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +Cela signifie que le garde est utile pour bloquer les commandes directes manifestement dangereuses, mais ce n'est **pas** un bac à sable complet pour les pipelines de build non vérifiés. Si votre modèle de menace inclut du code non fiable dans l'espace de travail, utilisez une isolation plus forte comme des conteneurs, des VM ou un flux d'approbation autour des commandes de build et d'exécution. + +### Exemple de configuration + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Outil Cron + +L'outil cron est utilisé pour planifier des tâches périodiques. + +| Config | Type | Par défaut | Description | +|------------------------|------|------------|----------------------------------------------------| +| `exec_timeout_minutes` | int | 5 | Délai d'expiration en minutes, 0 signifie sans limite | + +## Outil MCP + +L'outil MCP permet l'intégration avec des serveurs Model Context Protocol externes. + +### Découverte d'outils (chargement paresseux) + +Lors de la connexion à plusieurs serveurs MCP, exposer simultanément des centaines d'outils peut épuiser la fenêtre de contexte du LLM et augmenter les coûts API. La fonctionnalité **Discovery** résout ce problème en gardant les outils MCP *masqués* par défaut. + +Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger (utilisant la correspondance par mots-clés BM25 ou les expressions régulières). Lorsque le LLM a besoin d'une capacité spécifique, il recherche dans la bibliothèque masquée. Les outils correspondants sont alors temporairement « déverrouillés » et injectés dans le contexte pour un nombre configuré de tours (`ttl`). + +### Configuration globale + +| Config | Type | Par défaut | Description | +|-------------|--------|------------|----------------------------------------------| +| `enabled` | bool | false | Activer l'intégration MCP globalement | +| `discovery` | object | `{}` | Configuration de la découverte d'outils (voir ci-dessous) | +| `servers` | object | `{}` | Mappage du nom de serveur à la configuration du serveur | + +### Configuration Discovery (`discovery`) + +| Config | Type | Par défaut | Description | +|----------------------|------|------------|-----------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | Si true, les outils MCP sont masqués et chargés à la demande via la recherche. Si false, tous les outils sont chargés | +| `ttl` | int | 5 | Nombre de tours de conversation pendant lesquels un outil découvert reste déverrouillé | +| `max_search_results` | int | 5 | Nombre maximum d'outils retournés par requête de recherche | +| `use_bm25` | bool | true | Activer l'outil de recherche par langage naturel/mots-clés (`tool_search_tool_bm25`). **Attention** : consomme plus de ressources que la recherche regex | +| `use_regex` | bool | false | Activer l'outil de recherche par motif regex (`tool_search_tool_regex`) | + +> **Note :** Si `discovery.enabled` est `true`, vous **devez** activer au moins un moteur de recherche (`use_bm25` ou `use_regex`), +> sinon l'application ne démarrera pas. + +### Configuration par serveur + +| Config | Type | Requis | Description | +|------------|--------|----------|--------------------------------------------| +| `enabled` | bool | oui | Activer ce serveur MCP | +| `type` | string | non | Type de transport : `stdio`, `sse`, `http` | +| `command` | string | stdio | Commande exécutable pour le transport stdio | +| `args` | array | non | Arguments de commande pour le transport stdio | +| `env` | object | non | Variables d'environnement pour le processus stdio | +| `env_file` | string | non | Chemin vers le fichier d'environnement pour le processus stdio | +| `url` | string | sse/http | URL du point de terminaison pour le transport `sse`/`http` | +| `headers` | object | non | En-têtes HTTP pour le transport `sse`/`http` | + +### Comportement du transport + +- Si `type` est omis, le transport est détecté automatiquement : + - `url` est défini → `sse` + - `command` est défini → `stdio` +- `http` et `sse` utilisent tous deux `url` + `headers` optionnels. +- `env` et `env_file` ne sont appliqués qu'aux serveurs `stdio`. + +### Exemples de configuration + +#### 1) Serveur MCP Stdio + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) Serveur MCP distant SSE/HTTP + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) Configuration MCP massive avec découverte d'outils activée + +*Dans cet exemple, le LLM ne verra que `tool_search_tool_bm25`. Il recherchera et déverrouillera dynamiquement les outils Github ou Postgres uniquement lorsque l'utilisateur le demande.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + +## Outil Skills + +L'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub. + +### Registres + +| Config | Type | Par défaut | Description | +|------------------------------------|--------|----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Activer le registre ClawHub | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL de base ClawHub | +| `registries.clawhub.auth_token` | string | `""` | Jeton Bearer optionnel pour des limites de débit plus élevées | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Chemin de l'API de recherche | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Chemin de l'API Skills | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Chemin de l'API de téléchargement | + +### Exemple de configuration + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## Variables d'environnement + +Toutes les options de configuration peuvent être remplacées via des variables d'environnement au format `PICOCLAW_TOOLS_
_` : + +Par exemple : + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +Note : La configuration de type map imbriquée (par exemple `tools.mcp.servers..*`) est configurée dans `config.json` plutôt que via des variables d'environnement. diff --git a/docs/fr/troubleshooting.md b/docs/fr/troubleshooting.md new file mode 100644 index 000000000..bfe8901ef --- /dev/null +++ b/docs/fr/troubleshooting.md @@ -0,0 +1,45 @@ +# 🐛 Dépannage + +> Retour au [README](../../README.fr.md) + +## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" + +**Symptôme :** Vous voyez l'une des erreurs suivantes : + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter retourne 400 : `"free is not a valid model ID"` + +**Cause :** Le champ `model` dans votre entrée `model_list` est ce qui est envoyé à l'API. Pour OpenRouter, vous devez utiliser l'identifiant de modèle **complet**, pas un raccourci. + +- **Incorrect :** `"model": "free"` → OpenRouter reçoit `free` et le rejette. +- **Correct :** `"model": "openrouter/free"` → OpenRouter reçoit `openrouter/free` (routage automatique du niveau gratuit). + +**Correction :** Dans `~/.picoclaw/config.json` (ou votre chemin de configuration) : + +1. **agents.defaults.model** doit correspondre à un `model_name` dans `model_list` (par ex. `"openrouter-free"`). +2. Le **model** de cette entrée doit être un identifiant de modèle OpenRouter valide, par exemple : + - `"openrouter/free"` – niveau gratuit automatique + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Exemple : + +```json +{ + "agents": { + "defaults": { + "model": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Obtenez votre clé sur [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/ja/chat-apps.md b/docs/ja/chat-apps.md new file mode 100644 index 000000000..6d01c817b --- /dev/null +++ b/docs/ja/chat-apps.md @@ -0,0 +1,574 @@ +# 💬 チャットアプリ設定 + +> [README](../../README.ja.md) に戻る + +## 💬 チャットアプリ連携 + +PicoClaw は複数のチャットプラットフォームをサポートしており、Agent をどこにでも接続できます。 + +> **注意**: すべての Webhook ベースのチャネル(LINE、WeCom など)は、共有 Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。チャネルごとにポートを設定する必要はありません。注意:飛書(Feishu)は WebSocket/SDK モードを使用し、共有 HTTP Webhook サーバーは使用しません。 + +### チャネル一覧 + +| チャネル | セットアップ難易度 | 特徴 | ドキュメント | +| -------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.zh.md) | +| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.zh.md) | +| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](../channels/whatsapp/README.zh.md) | +| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.zh.md) | +| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.zh.md) | +| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.zh.md) | +| **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.zh.md) | +| **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.zh.md) | +| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.zh.md) / [App](../channels/wecom/wecom_app/README.zh.md) / [AI Bot](../channels/wecom/wecom_aibot/README.zh.md) | +| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.zh.md) | +| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | - | +| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.zh.md) | +| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.zh.md) | +| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | | + +--- + +
+Telegram(推奨) + +**1. Bot を作成** + +* Telegram を開き、`@BotFather` を検索 +* `/newbot` を送信し、プロンプトに従う +* Token をコピー + +**2. 設定** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Telegram の `@userinfobot` から User ID を取得できます。 + +**3. 実行** + +```bash +picoclaw gateway +``` + +**4. Telegram コマンドメニュー(起動時に自動登録)** + +PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。 +Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。 + +ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。 + +
+ +
+Discord + +**1. Bot を作成** + +* にアクセス +* アプリケーションを作成 → Bot → Bot を追加 +* Bot Token をコピー + +**2. Intents を有効化** + +* Bot 設定で **MESSAGE CONTENT INTENT** を有効化 +* (オプション)メンバーデータに基づくホワイトリストが必要な場合は **SERVER MEMBERS INTENT** を有効化 + +**3. User ID を取得** + +* Discord 設定 → 詳細設定 → **開発者モード** を有効化 +* アバターを右クリック → **ユーザー ID をコピー** + +**4. 設定** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Bot を招待** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* 生成された招待リンクを開き、Bot をサーバーに追加 + +**オプション:グループトリガーモード** + +デフォルトでは Bot はサーバーチャネル内のすべてのメッセージに応答します。@メンション時のみ応答するには: + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +キーワードプレフィックスでトリガーすることもできます(例: `!bot`): + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. 実行** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp(ネイティブ whatsmeow) + +PicoClaw は 2 つの WhatsApp 接続方式をサポートしています: + +- **ネイティブ(推奨):** プロセス内で [whatsmeow](https://github.com/tulir/whatsmeow) を使用。独立した Bridge は不要です。`"use_native": true` に設定し、`bridge_url` を空にします。初回実行時に WhatsApp で QR コードをスキャン(リンクデバイス)。セッションはワークスペース配下(例: `workspace/whatsapp/`)に保存されます。ネイティブチャネルは**オプション**ビルドで、`-tags whatsapp_native` でコンパイルします(例: `make build-whatsapp-native` または `go build -tags whatsapp_native ./cmd/...`)。 +- **Bridge:** 外部 WebSocket Bridge に接続。`bridge_url`(例: `ws://localhost:3001`)を設定し、`use_native` を false のままにします。 + +**設定(ネイティブ)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +`session_store_path` が空の場合、セッションは `/whatsapp/` に保存されます。`picoclaw gateway` を実行し、初回実行時にターミナルに表示される QR コードをスキャンしてください(WhatsApp → リンクデバイス)。 + +
+ +
+Matrix + +**1. Bot アカウントを準備** + +* お好みの homeserver(例: `https://matrix.org` またはセルフホスト)を使用 +* Bot ユーザーを作成し、access token を取得 + +**2. 設定** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 実行** + +```bash +picoclaw gateway +``` + +すべてのオプション(`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`)については [Matrix チャネル設定ガイド](../channels/matrix/README.md) を参照してください。 + +
+ +
+QQ + +**1. Bot を作成** + +- [QQ 開放プラットフォーム](https://q.qq.com/#) にアクセス +- アプリケーションを作成 → **AppID** と **AppSecret** を取得 + +**2. 設定** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にするとすべてのユーザーを許可します。QQ 番号を指定してアクセスを制限することもできます。 + +**3. 実行** + +```bash +picoclaw gateway +``` + +
+ +
+Slack + +**1. Slack App を作成** + +* [Slack API](https://api.slack.com/apps) でアプリを作成 +* **Socket Mode** を有効化 +* **Bot Token** と **App-Level Token** を取得 + +**2. 設定** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-YOUR_BOT_TOKEN", + "app_token": "xapp-YOUR_APP_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 実行** + +```bash +picoclaw gateway +``` + +
+ +
+IRC + +**1. 設定** + +```json +{ + "channels": { + "irc": { + "enabled": true, + "server": "irc.libera.chat:6697", + "nick": "picoclaw-bot", + "use_tls": true, + "channels_to_join": ["#your-channel"], + "allow_from": [] + } + } +} +``` + +**2. 実行** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Bot を作成** + +* [開放プラットフォーム](https://open.dingtalk.com/) にアクセス +* 内部アプリを作成 +* Client ID と Client Secret をコピー + +**2. 設定** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にするとすべてのユーザーを許可します。DingTalk ユーザー ID を指定してアクセスを制限することもできます。 + +**3. 実行** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. LINE 公式アカウントを作成** + +- [LINE Developers Console](https://developers.line.biz/) にアクセス +- Provider を作成 → Messaging API チャネルを作成 +- **Channel Secret** と **Channel Access Token** をコピー + +**2. 設定** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> LINE Webhook は共有 Gateway サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。 + +**3. Webhook URL を設定** + +LINE は HTTPS Webhook が必要です。リバースプロキシまたはトンネルを使用してください: + +```bash +# 例:ngrok を使用(Gateway デフォルトポートは 18790) +ngrok http 18790 +``` + +LINE Developers Console で Webhook URL を `https://your-domain/webhook/line` に設定し、**Use webhook** を有効にしてください。 + +**4. 実行** + +```bash +picoclaw gateway +``` + +> グループチャットでは、Bot は @メンション時のみ応答します。返信は元のメッセージを引用します。 + +
+ +
+Feishu (飛書) + +**1. アプリを作成** + +* [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセス +* 企業カスタムアプリを作成 +* **App ID** と **App Secret** を取得 + +**2. 設定** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +**3. 実行** + +```bash +picoclaw gateway +``` + +
+ +
+WeCom (企業微信) + +PicoClaw は 3 種類の WeCom 統合をサポートしています: + +**方式 1: グループ Bot (Bot)** — セットアップ簡単、グループチャット対応 +**方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ +**方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応 + +詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.zh.md) を参照してください。 + +**クイックセットアップ — グループ Bot:** + +**1. Bot を作成** + +* WeCom 管理コンソール → グループチャット → グループ Bot を追加 +* Webhook URL をコピー(形式:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. 設定** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> WeCom Webhook は共有 Gateway サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。 + +**クイックセットアップ — カスタムアプリ:** + +**1. アプリを作成** + +* WeCom 管理コンソール → アプリ管理 → アプリを作成 +* **AgentId** と **Secret** をコピー +* 「マイ企業」ページで **CorpID** をコピー + +**2. メッセージ受信を設定** + +* アプリ詳細で「メッセージ受信」→「API を設定」をクリック +* URL を `http://your-server:18790/webhook/wecom-app` に設定 +* **Token** と **EncodingAESKey** を生成 + +**3. 設定** + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. 実行** + +```bash +picoclaw gateway +``` + +> **注意**: WeCom Webhook コールバックは Gateway ポート(デフォルト 18790)で提供されます。HTTPS にはリバースプロキシを使用してください。 + +**クイックセットアップ — AI Bot:** + +**1. AI Bot を作成** + +* WeCom 管理コンソール → アプリ管理 → AI Bot +* AI Bot 設定でコールバック URL を設定:`http://your-server:18791/webhook/wecom-aibot` +* **Token** をコピーし、「ランダム生成」をクリックして **EncodingAESKey** を取得 + +**2. 設定** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "こんにちは!何かお手伝いできますか?" + } + } +} +``` + +**3. 実行** + +```bash +picoclaw gateway +``` + +> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用しており、返信タイムアウトの心配はありません。長時間タスク(30 秒超)は自動的に `response_url` プッシュ配信に切り替わります。 + +
+ +
+OneBot + +**1. 設定** + +NapCat / Go-CQHTTP などの OneBot 実装と互換性があります。 + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "allow_from": [] + } + } +} +``` + +**2. 実行** + +```bash +picoclaw gateway +``` + +
+ +
+MaixCam + +Sipeed AI カメラハードウェア向けの統合チャネルです。 + +```json +{ + "channels": { + "maixcam": { + "enabled": true + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md new file mode 100644 index 000000000..bfd574a4d --- /dev/null +++ b/docs/ja/configuration.md @@ -0,0 +1,256 @@ +# ⚙️ 設定ガイド + +> [README](../../README.ja.md) に戻る + +## ⚙️ 設定詳細 + +設定ファイルパス: `~/.picoclaw/config.json` + +### 環境変数 + +環境変数を使用してデフォルトパスを上書きできます。ポータブルインストール、コンテナ化デプロイ、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。 + +| 変数 | 説明 | デフォルトパス | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | 設定ファイルのパスを上書きします。picoclaw がどの `config.json` を読み込むかを直接指定し、他のすべての場所を無視します。 | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。`workspace` やその他のデータディレクトリのデフォルト場所を変更します。 | `~/.picoclaw` | + +**例:** + +```bash +# 特定の設定ファイルで picoclaw を実行 +# ワークスペースパスはその設定ファイル内から読み込まれます +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# /opt/picoclaw にすべてのデータを保存して picoclaw を実行 +# 設定はデフォルトの ~/.picoclaw/config.json から読み込まれます +# ワークスペースは /opt/picoclaw/workspace に作成されます +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# 両方を使用して完全にカスタマイズ +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### ワークスペースレイアウト + +PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: + +``` +~/.picoclaw/workspace/ +├── sessions/ # 会話セッションと履歴 +├── memory/ # 長期記憶 (MEMORY.md) +├── state/ # 永続化状態 (最後のチャネルなど) +├── cron/ # スケジュールジョブデータベース +├── skills/ # カスタムスキル +├── AGENTS.md # Agent 動作ガイド +├── HEARTBEAT.md # 定期タスクプロンプト (30 分ごとにチェック) +├── IDENTITY.md # Agent アイデンティティ +├── SOUL.md # Agent ソウル/性格 +└── USER.md # ユーザー設定 +``` + +### スキルソース + +デフォルトでは、スキルは以下の順序で読み込まれます: + +1. `~/.picoclaw/workspace/skills`(ワークスペース) +2. `~/.picoclaw/skills`(グローバル) +3. `/skills`(ビルトイン) + +高度な/テスト用セットアップでは、以下の環境変数でビルトインスキルのルートを上書きできます: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### 統一コマンド実行ポリシー + +- 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。 +- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時にサポートするコマンドメニューを自動登録します。 +- 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。 +- 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。 + +### 🔒 セキュリティサンドボックス + +PicoClaw はデフォルトでサンドボックス環境で実行されます。Agent は設定されたワークスペース内のファイルアクセスとコマンド実行のみが可能です。 + +#### デフォルト設定 + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| オプション | デフォルト値 | 説明 | +| ----------------------- | ----------------------- | ------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Agent の作業ディレクトリ | +| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペース内に制限 | + +#### 保護されたツール + +`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます: + +| ツール | 機能 | 制限 | +| ------------- | ---------------- | ---------------------------------- | +| `read_file` | ファイル読み取り | ワークスペース内のファイルのみ | +| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ | +| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ | +| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ | +| `append_file` | ファイル追記 | ワークスペース内のファイルのみ | +| `exec` | コマンド実行 | コマンドパスはワークスペース内必須 | + +#### 追加の Exec 保護 + +`restrict_to_workspace: false` の場合でも、`exec` ツールは以下の危険なコマンドをブロックします: + +* `rm -rf`、`del /f`、`rmdir /s` — 一括削除 +* `format`、`mkfs`、`diskpart` — ディスクフォーマット +* `dd if=` — ディスクイメージング +* `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み +* `shutdown`、`reboot`、`poweroff` — システムシャットダウン +* Fork bomb `:(){ :|:& };:` + +### ファイルアクセス制御 + +| 設定キー | 型 | デフォルト値 | 説明 | +|----------|------|-------------|------| +| `tools.allow_read_paths` | string[] | `[]` | ワークスペース外で読み取りを許可する追加パス | +| `tools.allow_write_paths` | string[] | `[]` | ワークスペース外で書き込みを許可する追加パス | + +### Exec セキュリティ設定 + +| 設定キー | 型 | デフォルト値 | 説明 | +|----------|------|-------------|------| +| `tools.exec.allow_remote` | bool | `false` | リモートチャネル(Telegram/Discord など)からの exec ツール実行を許可 | +| `tools.exec.enable_deny_patterns` | bool | `true` | 危険なコマンドのインターセプトを有効化 | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | カスタムブロック正規表現パターン | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | カスタム許可正規表現パターン | + +> **セキュリティ注意:** Symlink 保護はデフォルトで有効です。すべてのファイルパスはホワイトリストマッチング前に `filepath.EvalSymlinks` で解決され、シンボリックリンクエスケープ攻撃を防止します。 + +#### 既知の制限:ビルドツールの子プロセス + +exec セキュリティガードは PicoClaw が直接起動するコマンドラインのみを検査します。`make`、`go run`、`cargo`、`npm run`、またはカスタムビルドスクリプトなどの開発ツールが生成する子プロセスは再帰的に検査しません。 + +つまり、トップレベルのコマンドが初期ガードチェックを通過した後、他のバイナリをコンパイルまたは起動できます。実際には、ビルドスクリプト、Makefile、パッケージスクリプト、生成されたバイナリを、直接のシェルコマンドと同等レベルの実行可能コードとしてレビューする必要があります。 + +高リスク環境の場合: + +* 実行前にビルドスクリプトをレビューしてください。 +* コンパイル・実行ワークフローには承認/手動レビューを優先してください。 +* ビルトインガードより強力な分離が必要な場合は、コンテナまたは VM 内で PicoClaw を実行してください。 + +#### エラー例 + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### 制限の無効化(セキュリティリスク) + +Agent がワークスペース外のパスにアクセスする必要がある場合: + +**方法 1: 設定ファイル** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**方法 2: 環境変数** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **警告**: この制限を無効にすると、Agent がシステム上の任意のパスにアクセスできるようになります。管理された環境でのみ慎重に使用してください。 + +#### セキュリティ境界の一貫性 + +`restrict_to_workspace` 設定はすべての実行パスで一貫して適用されます: + +| 実行パス | セキュリティ境界 | +| ---------------- | ---------------------------- | +| メイン Agent | `restrict_to_workspace` ✅ | +| サブ Agent / Spawn | 同じ制限を継承 ✅ | +| ハートビートタスク | 同じ制限を継承 ✅ | + +すべてのパスは同じワークスペース制限を共有しており、サブ Agent やスケジュールタスクを通じてセキュリティ境界を回避することはできません。 + +### ハートビート(定期タスク) + +PicoClaw は定期タスクを自動実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成してください: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +Agent は 30 分ごと(設定可能)にこのファイルを読み取り、利用可能なツールを使用してタスクを実行します。 + +#### Spawn を使用した非同期タスク + +長時間実行タスク(Web 検索、API 呼び出し)には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**主な動作:** + +| 特性 | 説明 | +| ---------------- | -------------------------------------------- | +| **spawn** | 非同期サブ Agent を作成、メインハートビートをブロックしない | +| **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし | +| **message tool** | サブ Agent は message ツールでユーザーと直接通信 | +| **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む | + +**設定:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| オプション | デフォルト値 | 説明 | +| ---------- | ------------ | ------------------------------ | +| `enabled` | `true` | ハートビートの有効/無効 | +| `interval` | `30` | チェック間隔(分単位、最小: 5)| + +**環境変数:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更 diff --git a/docs/ja/docker.md b/docs/ja/docker.md new file mode 100644 index 000000000..6ad55d41d --- /dev/null +++ b/docs/ja/docker.md @@ -0,0 +1,168 @@ +# 🐳 Docker とクイックスタート + +> [README](../../README.ja.md) に戻る + +## 🐳 Docker Compose + +Docker Compose を使用して PicoClaw を実行できます。ローカルに何もインストールする必要はありません。 + +```bash +# 1. リポジトリをクローン +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 初回実行 — docker/data/config.json を自動生成して終了 +docker compose -f docker/docker-compose.yml --profile gateway up +# コンテナが "First-run setup complete." と表示して停止します + +# 3. API Key を設定 +vim docker/data/config.json # provider API key、Bot Token などを設定 + +# 4. 起動 +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Docker ユーザー**: デフォルトでは Gateway は `127.0.0.1` でリッスンしており、コンテナ外からはアクセスできません。ヘルスチェックエンドポイントへのアクセスやポート公開が必要な場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 + +```bash +# 5. ログを確認 +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. 停止 +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher モード (Web コンソール) + +`launcher` イメージには 3 つのバイナリ(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 + +> [!WARNING] +> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。 + +### Agent モード (ワンショット) + +```bash +# 質問する +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2は?" + +# インタラクティブモード +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### イメージの更新 + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +--- + +## 🚀 クイックスタート + +> [!TIP] +> `~/.picoclaw/config.json` に API Key を設定してください。API Key の取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は**オプション**です — 無料の [Tavily API](https://tavily.com) (月 1000 回無料) または [Brave Search API](https://brave.com/search/api) (月 2000 回無料) を取得できます。 + +**1. 初期化** + +```bash +picoclaw onboard +``` + +**2. 設定** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "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", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.md#モデル設定-model_list)を参照してください。 +> `request_timeout` はオプションで、単位は秒です。省略または `<= 0` に設定した場合、PicoClaw はデフォルトのタイムアウト(120 秒)を使用します。 + +**3. API Key の取得** + +* **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Web 検索** (オプション): + * [Brave Search](https://brave.com/search/api) - 有料 ($5/1000 queries, ~$5-6/month) + * [Perplexity](https://www.perplexity.ai) - AI 搭載の検索・チャットインターフェース + * [SearXNG](https://github.com/searxng/searxng) - セルフホスト型メタ検索エンジン(無料、API Key 不要) + * [Tavily](https://tavily.com) - AI Agent 向けに最適化 (1000 requests/month) + * DuckDuckGo - 組み込みフォールバック(API Key 不要) + +> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。 + +**4. チャット** + +```bash +picoclaw agent -m "2+2は?" +``` + +以上です!2 分で動作する AI アシスタントが手に入ります。 + +--- diff --git a/docs/ja/providers.md b/docs/ja/providers.md new file mode 100644 index 000000000..2323a27cc --- /dev/null +++ b/docs/ja/providers.md @@ -0,0 +1,434 @@ +# 🔌 プロバイダーとモデル設定 + +> [README](../../README.ja.md) に戻る + +### プロバイダー + +> [!NOTE] +> Groq は Whisper による無料の音声文字起こしを提供しています。Groq を設定すると、任意のチャネルからの音声メッセージが Agent レベルで自動的にテキストに変換されます。 + +| プロバイダー | 用途 | API Key の取得 | +| -------------------- | ---------------------------- | -------------------------------------------------------------------- | +| `gemini` | LLM (Gemini 直接接続) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu 直接接続) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM (Volcengine 直接接続) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (推奨、全モデルアクセス可) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude 直接接続) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT 直接接続) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek 直接接続) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen 直接接続) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **音声文字起こし** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras 直接接続) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid 直接接続) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot 直接接続) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax 直接接続) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian 直接接続) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral 直接接続) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat 直接接続) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope 直接接続) | [modelscope.cn](https://modelscope.cn) | + +### モデル設定 (model_list) + +> **新機能!** PicoClaw は**モデル中心**の設定方式を採用しました。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで新しい provider を追加できます——**コード変更は一切不要です!** + +この設計は**マルチ Agent シナリオ**もサポートし、柔軟な Provider 選択を提供します: + +- **Agent ごとに異なる Provider**: 各 Agent が独自の LLM provider を使用可能 +- **モデルフォールバック**: プライマリモデルとフォールバックモデルを設定し、信頼性を向上 +- **ロードバランシング**: 複数の API エンドポイント間でリクエストを分散 +- **一元管理**: すべての provider を一箇所で管理 + +#### 📋 サポートされている全ベンダー + +| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API Key の取得 | +| ------------------- | --------------------- | --------------------------------------------------- | ---------- | ----------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | 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) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [キーを取得](https://vivgrid.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [トークンを取得](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuth のみ | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | + +#### 基本設定 + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-openai-key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-zhipu-key" + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +#### ベンダー別設定例 + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-..." +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_key": "sk-..." +} +``` + +**Anthropic (API キー使用)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" +} +``` + +> `picoclaw auth login --provider anthropic` を実行して API トークンを設定してください。 + +**Anthropic Messages API(ネイティブ形式)** + +Anthropic API への直接アクセスや、Anthropic のネイティブメッセージ形式のみをサポートするカスタムエンドポイント向け: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com" +} +``` + +> `anthropic-messages` プロトコルを使用するケース: +> - Anthropic のネイティブ `/v1/messages` エンドポイントのみをサポートするサードパーティプロキシを使用する場合(OpenAI 互換の `/v1/chat/completions` 非対応) +> - MiniMax、Synthetic など Anthropic のネイティブメッセージ形式を必要とするサービスに接続する場合 +> - 既存の `anthropic` プロトコルが 404 エラーを返す場合(エンドポイントが OpenAI 互換形式をサポートしていないことを示す) +> +> **注意:** `anthropic` プロトコルは OpenAI 互換形式(`/v1/chat/completions`)を使用し、`anthropic-messages` は Anthropic のネイティブ形式(`/v1/messages`)を使用します。エンドポイントがサポートする形式に応じて選択してください。 + +**Ollama (ローカル)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**カスタムプロキシ/API** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_key": "sk-...", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_key": "sk-..." +} +``` + +PicoClaw はリクエスト送信前に外側の `litellm/` プレフィックスのみを除去するため、`litellm/lite-gpt4` は `lite-gpt4` を送信し、`litellm/openai/gpt-4o` は `openai/gpt-4o` を送信します。 + +#### ロードバランシング + +同じモデル名に複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_key": "sk-key1" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_key": "sk-key2" + } + ] +} +``` + +#### レガシー `providers` 設定からの移行 + +旧 `providers` 設定形式は**非推奨**ですが、後方互換性のためまだサポートされています。 + +**旧設定(非推奨):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**新設定(推奨):** + +```json +{ + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "glm-4.7" + } + } +} +``` + +詳細な移行ガイドは [docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。 + +### Provider アーキテクチャ + +PicoClaw はプロトコルファミリーごとに Provider をルーティングします: + +- OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 +- Anthropic プロトコル:Claude ネイティブ API 動作。 +- Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 + +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_key`)のみで実現しています。 + +
+Zhipu 設定例 + +**1. API key と base URL を取得** + +- [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) を取得 + +**2. 設定** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. 実行** + +```bash +picoclaw agent -m "こんにちは" +``` + +
+ +
+完全な設定例 + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 API Key 比較表 + +| サービス | Pricing | ユースケース | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | マルチモデル (Claude, GPT-4 など) | +| **Volcengine CodingPlan** | ¥9.9/first month | 中国ユーザー向け、複数の SOTA モデル (Doubao, DeepSeek など) | +| **Zhipu** | Free: 200K tokens/month | 中国ユーザー向け | +| **Brave Search** | $5/1000 queries | Web 検索機能 | +| **SearXNG** | Free (self-hosted) | プライバシー重視のメタ検索 (70+ engines) | +| **Groq** | Free tier available | 高速推論 (Llama, Mixtral) | +| **Cerebras** | Free tier available | 高速推論 (Llama, Qwen など) | +| **LongCat** | Free: up to 5M tokens/day | 高速推論 | +| **ModelScope** | Free: 2000 requests/day | 推論 (Qwen, GLM, DeepSeek など) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/ja/spawn-tasks.md b/docs/ja/spawn-tasks.md new file mode 100644 index 000000000..a13aab9eb --- /dev/null +++ b/docs/ja/spawn-tasks.md @@ -0,0 +1,68 @@ +# 🔄 非同期タスクと Spawn + +> [README](../../README.ja.md) に戻る + +### Spawn を使用した非同期タスク + +長時間実行タスク(Web 検索、API 呼び出し)には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**主な動作:** + +| 特性 | 説明 | +| ---------------- | ------------------------------------------------ | +| **spawn** | 非同期サブ Agent を作成、メインハートビートをブロックしない | +| **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし | +| **message tool** | サブ Agent は message ツールでユーザーと直接通信 | +| **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む | + +#### サブ Agent の通信の仕組み + +``` +ハートビートトリガー (Heartbeat triggers) + ↓ +Agent が HEARTBEAT.md を読み取り + ↓ +長時間タスクの場合: サブ Agent を spawn + ↓ ↓ +次のタスクに進む サブ Agent が独立して作業 + ↓ ↓ +すべてのタスク完了 サブ Agent が "message" ツールを使用 + ↓ ↓ +HEARTBEAT_OK を応答 ユーザーが直接結果を受信 +``` + +サブ Agent はツール(message、web_search など)にアクセスでき、メイン Agent を経由せずにユーザーと独立して通信できます。 + +**設定:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| オプション | デフォルト値 | 説明 | +| ---------- | ------------ | ------------------------------ | +| `enabled` | `true` | ハートビートの有効/無効 | +| `interval` | `30` | チェック間隔(分単位、最小: 5)| + +**環境変数:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更 diff --git a/docs/ja/tools_configuration.md b/docs/ja/tools_configuration.md new file mode 100644 index 000000000..e4568f6ae --- /dev/null +++ b/docs/ja/tools_configuration.md @@ -0,0 +1,336 @@ +# 🔧 ツール設定 + +> [README](../../README.ja.md) に戻る + +PicoClaw のツール設定は `config.json` の `tools` フィールドにあります。 + +## ディレクトリ構造 + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Web ツール + +Web ツールはウェブ検索とフェッチに使用されます。 + +### Web Fetcher +ウェブページコンテンツの取得と処理に関する一般設定。 + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------------|--------|---------------|----------------------------------------------------------------------------------------| +| `enabled` | bool | true | ウェブページ取得機能を有効にする。 | +| `fetch_limit_bytes` | int | 10485760 | 取得するウェブページペイロードの最大サイズ(バイト単位、デフォルトは10MB)。 | +| `format` | string | "plaintext" | 取得コンテンツの出力形式。オプション:`plaintext` または `markdown`(推奨)。 | + +### Brave + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|--------|------------|-----------------------| +| `enabled` | bool | false | Brave 検索を有効にする | +| `api_key` | string | - | Brave Search API キー | +| `max_results` | int | 5 | 最大結果数 | + +### DuckDuckGo + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|------|------------|---------------------------| +| `enabled` | bool | true | DuckDuckGo 検索を有効にする | +| `max_results` | int | 5 | 最大結果数 | + +### Perplexity + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|--------|------------|---------------------------| +| `enabled` | bool | false | Perplexity 検索を有効にする | +| `api_key` | string | - | Perplexity API キー | +| `max_results` | int | 5 | 最大結果数 | + +## Exec ツール + +Exec ツールはシェルコマンドの実行に使用されます。 + +| 設定項目 | 型 | デフォルト | 説明 | +|------------------------|-------|------------|------------------------------------| +| `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする | +| `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) | + +### 機能 + +- **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします +- **`custom_deny_patterns`**:カスタム拒否正規表現パターンを追加します。一致するコマンドはブロックされます + +### デフォルトでブロックされるコマンドパターン + +デフォルトで、PicoClaw は以下の危険なコマンドをブロックします: + +- 削除コマンド:`rm -rf`、`del /f/q`、`rmdir /s` +- ディスク操作:`format`、`mkfs`、`diskpart`、`dd if=`、`/dev/sd*` への書き込み +- システム操作:`shutdown`、`reboot`、`poweroff` +- コマンド置換:`$()`、`${}`、バッククォート +- シェルへのパイプ:`| sh`、`| bash` +- 権限昇格:`sudo`、`chmod`、`chown` +- プロセス制御:`pkill`、`killall`、`kill -9` +- リモート操作:`curl | sh`、`wget | sh`、`ssh` +- パッケージ管理:`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user` +- コンテナ:`docker run`、`docker exec` +- Git:`git push`、`git force` +- その他:`eval`、`source *.sh` + +### 既知のアーキテクチャ上の制限 + +exec ガードは PicoClaw に送信されたトップレベルのコマンドのみを検証します。そのコマンドの実行開始後にビルドツールやスクリプトが生成する子プロセスを再帰的に検査することは**ありません**。 + +初期コマンドが許可された後、直接コマンドガードをバイパスできるワークフローの例: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +これは、明らかに危険な直接コマンドのブロックには有用ですが、未レビューのビルドパイプラインに対する完全なサンドボックスでは**ありません**。脅威モデルにワークスペース内の信頼できないコードが含まれる場合は、コンテナ、VM、またはビルド・実行コマンドに対する承認フローなど、より強力な分離を使用してください。 + +### 設定例 + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Cron ツール + +Cron ツールは定期タスクのスケジューリングに使用されます。 + +| 設定項目 | 型 | デフォルト | 説明 | +|------------------------|-----|------------|-----------------------------------------| +| `exec_timeout_minutes` | int | 5 | 実行タイムアウト(分)、0 は無制限 | + +## MCP ツール + +MCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。 + +### ツールディスカバリ(遅延読み込み) + +複数の MCP サーバーに接続する場合、数百のツールを同時に公開すると LLM のコンテキストウィンドウを使い果たし、API コストが増加する可能性があります。**Discovery** 機能は、MCP ツールをデフォルトで*非表示*にすることでこの問題を解決します。 + +すべてのツールを読み込む代わりに、LLM には軽量な検索ツール(BM25 キーワードマッチングまたは正規表現を使用)が提供されます。LLM が特定の機能を必要とする場合、非表示のライブラリを検索します。一致するツールは一時的に「アンロック」され、設定されたターン数(`ttl`)の間コンテキストに注入されます。 + +### グローバル設定 + +| 設定項目 | 型 | デフォルト | 説明 | +|-------------|--------|------------|--------------------------------------| +| `enabled` | bool | false | MCP 統合をグローバルに有効にする | +| `discovery` | object | `{}` | ツールディスカバリ設定(下記参照) | +| `servers` | object | `{}` | サーバー名からサーバー設定へのマップ | + +### Discovery 設定(`discovery`) + +| 設定項目 | 型 | デフォルト | 説明 | +|----------------------|------|------------|---------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | true の場合、MCP ツールは非表示になり、検索を通じてオンデマンドで読み込まれます。false の場合、すべてのツールが読み込まれます | +| `ttl` | int | 5 | 発見されたツールがアンロック状態を維持する会話ターン数 | +| `max_search_results` | int | 5 | 検索クエリごとに返されるツールの最大数 | +| `use_bm25` | bool | true | 自然言語/キーワード検索ツール(`tool_search_tool_bm25`)を有効にする。**警告**:正規表現検索よりリソースを消費します | +| `use_regex` | bool | false | 正規表現パターン検索ツール(`tool_search_tool_regex`)を有効にする | + +> **注意:** `discovery.enabled` が `true` の場合、少なくとも1つの検索エンジン(`use_bm25` または `use_regex`)を有効にする**必要があります**。 +> そうしないとアプリケーションの起動に失敗します。 + +### サーバーごとの設定 + +| 設定項目 | 型 | 必須 | 説明 | +|------------|--------|----------|----------------------------------------| +| `enabled` | bool | はい | この MCP サーバーを有効にする | +| `type` | string | いいえ | トランスポートタイプ:`stdio`、`sse`、`http` | +| `command` | string | stdio | stdio トランスポートの実行コマンド | +| `args` | array | いいえ | stdio トランスポートのコマンド引数 | +| `env` | object | いいえ | stdio プロセスの環境変数 | +| `env_file` | string | いいえ | stdio プロセスの環境ファイルパス | +| `url` | string | sse/http | `sse`/`http` トランスポートのエンドポイント URL | +| `headers` | object | いいえ | `sse`/`http` トランスポートの HTTP ヘッダー | + +### トランスポートの動作 + +- `type` を省略した場合、トランスポートは自動検出されます: + - `url` が設定されている → `sse` + - `command` が設定されている → `stdio` +- `http` と `sse` はどちらも `url` + オプションの `headers` を使用します。 +- `env` と `env_file` は `stdio` サーバーにのみ適用されます。 + +### 設定例 + +#### 1) Stdio MCP サーバー + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) リモート SSE/HTTP MCP サーバー + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) ツールディスカバリを有効にした大規模 MCP セットアップ + +*この例では、LLM は `tool_search_tool_bm25` のみを認識します。ユーザーからリクエストがあった場合にのみ、Github や Postgres のツールを動的に検索してアンロックします。* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + +## Skills ツール + +Skills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。 + +### レジストリ + +| 設定項目 | 型 | デフォルト | 説明 | +|------------------------------------|--------|----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | ClawHub レジストリを有効にする | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub ベース URL | +| `registries.clawhub.auth_token` | string | `""` | より高いレート制限のためのオプションの Bearer トークン | +| `registries.clawhub.search_path` | string | `/api/v1/search` | 検索 API パス | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API パス | +| `registries.clawhub.download_path` | string | `/api/v1/download` | ダウンロード API パス | + +### 設定例 + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## 環境変数 + +すべての設定オプションは `PICOCLAW_TOOLS_
_` 形式の環境変数で上書きできます: + +例: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +注意:ネストされたマップ形式の設定(例:`tools.mcp.servers..*`)は環境変数ではなく `config.json` で設定します。 diff --git a/docs/ja/troubleshooting.md b/docs/ja/troubleshooting.md new file mode 100644 index 000000000..1c98224b9 --- /dev/null +++ b/docs/ja/troubleshooting.md @@ -0,0 +1,45 @@ +# 🐛 トラブルシューティング + +> [README](../../README.ja.md) に戻る + +## "model ... not found in model_list" または OpenRouter "free is not a valid model ID" + +**症状:** 以下のいずれかのエラーが表示されます: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter が 400 を返す:`"free is not a valid model ID"` + +**原因:** `model_list` エントリの `model` フィールドは API に送信される値です。OpenRouter では省略形ではなく、**完全な**モデル ID を使用する必要があります。 + +- **誤り:** `"model": "free"` → OpenRouter は `free` を受け取り、拒否します。 +- **正しい:** `"model": "openrouter/free"` → OpenRouter は `openrouter/free` を受け取ります(自動無料枠ルーティング)。 + +**修正方法:** `~/.picoclaw/config.json`(またはお使いの設定パス)で: + +1. **agents.defaults.model** は `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": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +キーは [OpenRouter Keys](https://openrouter.ai/keys) で取得できます。 diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 000000000..e62cbb969 --- /dev/null +++ b/docs/providers.md @@ -0,0 +1,436 @@ +# 🔌 Providers & Model Configuration + +> Back to [README](../README.md) + +### Providers + +> [!NOTE] +> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | + +### Model Configuration (model_list) + +> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!** + +This design also enables **multi-agent support** with flexible provider selection: + +- **Different agents, different providers**: Each agent can use its own LLM provider +- **Model fallbacks**: Configure primary and fallback models for resilience +- **Load balancing**: Distribute requests across multiple endpoints +- **Centralized configuration**: Manage all providers in one place + +#### 📋 All Supported Vendors + +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **VolcEngine (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) | +| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | + +#### Basic Configuration + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-openai-key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-zhipu-key" + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +#### Vendor-Specific Examples + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-..." +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_key": "sk-..." +} +``` + +**Anthropic (with API key)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" +} +``` + +> Run `picoclaw auth login --provider anthropic` to paste your API token. + +**Anthropic Messages API (native format)** + +For direct Anthropic API access or custom endpoints that only support Anthropic's native message format: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com" +} +``` + +> Use `anthropic-messages` protocol when: +> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`) +> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format +> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format) +> +> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**Custom Proxy/API** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_key": "sk-...", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_key": "sk-..." +} +``` + +PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`. + +#### Load Balancing + +Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.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" + } + ] +} +``` + +#### Migration from Legacy `providers` Config + +The old `providers` configuration is **deprecated** but still supported for backward compatibility. + +**Old Config (deprecated):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**New Config (recommended):** + +```json +{ + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "glm-4.7" + } + } +} +``` + +For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). + +### Provider Architecture + +PicoClaw routes providers by protocol family: + +- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Anthropic protocol: Claude-native API behavior. +- Codex/OAuth path: OpenAI OAuth/token authentication route. + +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). + +
+Zhipu + +**1. Get API key and base URL** + +* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configure** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Run** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Full config example + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 API Key Comparison + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/pt-br/chat-apps.md b/docs/pt-br/chat-apps.md new file mode 100644 index 000000000..5f18080f0 --- /dev/null +++ b/docs/pt-br/chat-apps.md @@ -0,0 +1,427 @@ +# 💬 Configuração de Aplicativos de Chat + +> Voltar ao [README](../../README.pt-br.md) + +## 💬 Aplicativos de Chat + +Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam + +> **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado. + +| Channel | Setup | +| ------------ | ---------------------------------- | +| **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | +| **WhatsApp** | Easy (native: QR scan; or bridge URL) | +| **Matrix** | Medium (homeserver + bot access token) | +| **QQ** | Easy (AppID + AppSecret) | +| **DingTalk** | Medium (app credentials) | +| **LINE** | Medium (credentials + webhook URL) | +| **WeCom AI Bot** | Medium (Token + AES key) | +| **Feishu** | Medium (App ID + Secret, WebSocket mode) | +| **Slack** | Medium (Bot token + App token) | +| **IRC** | Medium (server + TLS config) | +| **OneBot** | Medium (QQ via OneBot protocol) | +| **MaixCam** | Easy (Sipeed hardware integration) | +| **Pico** | Native PicoClaw protocol | + +
+Telegram (Recomendado) + +**1. Criar um bot** + +* Abra o Telegram, pesquise `@BotFather` +* Envie `/newbot`, siga as instruções +* Copie o token + +**2. Configurar** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenha seu ID de usuário com `@userinfobot` no Telegram. + +**3. Executar** + +```bash +picoclaw gateway +``` + +**4. Menu de comandos do Telegram (registrado automaticamente na inicialização)** + +O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`) 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. + +
+ +
+Discord + +**1. Criar um bot** + +* Acesse +* Crie um aplicativo → Bot → Add Bot +* Copie o token do bot + +**2. Habilitar intents** + +* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT** +* (Opcional) Habilite **SERVER MEMBERS INTENT** se planeja usar listas de permissão baseadas em dados de membros + +**3. Obter seu User ID** +* Configurações do Discord → Avançado → habilite **Developer Mode** +* Clique com o botão direito no seu avatar → **Copy User ID** + +**4. Configurar** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Convidar o bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Abra a URL de convite gerada e adicione o bot ao seu servidor + +**Opcional: Modo de ativação em grupo** + +Por padrão, o bot responde a todas as mensagens em um canal do servidor. Para restringir respostas apenas a @menções, adicione: + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Você também pode ativar por prefixos de palavras-chave (ex.: `!bot`): + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp (nativo via whatsmeow) + +O PicoClaw pode se conectar ao WhatsApp de duas formas: + +- **Nativo (recomendado):** In-process usando [whatsmeow](https://github.com/tulir/whatsmeow). Sem bridge separado. Defina `"use_native": true` e deixe `bridge_url` vazio. Na primeira execução, escaneie o QR code com o WhatsApp (Dispositivos Vinculados). A sessão é armazenada no seu workspace (ex.: `workspace/whatsapp/`). O canal nativo é **opcional** para manter o binário padrão pequeno; compile com `-tags whatsapp_native` (ex.: `make build-whatsapp-native` ou `go build -tags whatsapp_native ./cmd/...`). +- **Bridge:** Conecte-se a um bridge WebSocket externo. Defina `bridge_url` (ex.: `ws://localhost:3001`) e mantenha `use_native` como false. + +**Configurar (nativo)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Se `session_store_path` estiver vazio, a sessão é armazenada em `/whatsapp/`. Execute `picoclaw gateway`; na primeira execução, escaneie o QR code impresso no terminal com WhatsApp → Dispositivos Vinculados. + +
+ +
+QQ + +**1. Criar um bot** + +- Acesse a [QQ Open Platform](https://q.qq.com/#) +- Crie um aplicativo → Obtenha **AppID** e **AppSecret** + +**2. Configurar** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Defina `allow_from` como vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Criar um bot** + +* Acesse a [Open Platform](https://open.dingtalk.com/) +* Crie um aplicativo interno +* Copie o Client ID e o Client Secret + +**2. Configurar** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Defina `allow_from` como vazio para permitir todos os usuários, ou especifique IDs de usuário DingTalk para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` +
+ +
+Matrix + +**1. Preparar conta do bot** + +* Use seu homeserver preferido (ex.: `https://matrix.org` ou auto-hospedado) +* Crie um usuário bot e obtenha seu access token + +**2. Configurar** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](docs/channels/matrix/README.md). + +
+ +
+LINE + +**1. Criar uma Conta Oficial LINE** + +- Acesse o [LINE Developers Console](https://developers.line.biz/) +- Crie um provider → Crie um canal Messaging API +- Copie o **Channel Secret** e o **Channel Access Token** + +**2. Configurar** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> O webhook do LINE é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). + +**3. Configurar URL do Webhook** + +O LINE requer HTTPS para webhooks. Use um proxy reverso ou túnel: + +```bash +# Exemplo com ngrok (porta padrão do gateway é 18790) +ngrok http 18790 +``` + +Em seguida, defina a URL do Webhook no LINE Developers Console como `https://your-domain/webhook/line` e habilite **Use webhook**. + +**4. Executar** + +```bash +picoclaw gateway +``` + +> Em chats de grupo, o bot responde apenas quando @mencionado. As respostas citam a mensagem original. + +
+ +
+WeCom (企业微信) + +O PicoClaw suporta três tipos de integração WeCom: + +**Opção 1: WeCom Bot (Bot)** - Configuração mais fácil, suporta chats de grupo +**Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado +**Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado + +Veja o [Guia de Configuração do WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas de configuração. + +**Configuração Rápida - WeCom Bot:** + +**1. Criar um bot** + +* Acesse o Console de Administração WeCom → Chat de Grupo → Adicionar Bot de Grupo +* Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Configurar** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> O webhook do WeCom é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). + +**Configuração Rápida - WeCom App:** + +**1. Criar um aplicativo** + +* Acesse o Console de Administração WeCom → Gerenciamento de Apps → Criar App +* Copie o **AgentId** e o **Secret** +* Acesse a página "Minha Empresa", copie o **CorpID** + +**2. Configurar recebimento de mensagens** + +* Nos detalhes do App, clique em "Receber Mensagem" → "Configurar API" +* Defina a URL como `http://your-server:18790/webhook/wecom-app` +* Gere o **Token** e o **EncodingAESKey** + +**3. Configurar** + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Executar** + +```bash +picoclaw gateway +``` + +> **Nota**: Os callbacks de webhook do WeCom são servidos na porta do Gateway (padrão 18790). Use um proxy reverso para HTTPS. + +**Configuração Rápida - WeCom AI Bot:** + +**1. Criar um AI Bot** + +* Acesse o Console de Administração WeCom → Gerenciamento de Apps → AI Bot +* Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot` +* Copie o **Token** e clique em "Gerar Aleatoriamente" para o **EncodingAESKey** + +**2. Configurar** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +> **Nota**: O WeCom AI Bot usa protocolo de streaming pull — sem preocupações com timeout de resposta. Tarefas longas (>30 segundos) mudam automaticamente para entrega via `response_url` push. + +
diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md new file mode 100644 index 000000000..bf4833da4 --- /dev/null +++ b/docs/pt-br/configuration.md @@ -0,0 +1,217 @@ +# ⚙️ Guia de Configuração + +> Voltar ao [README](../../README.pt-br.md) + +## ⚙️ Configuração + +Arquivo de configuração: `~/.picoclaw/config.json` + +### Variáveis de Ambiente + +Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou execução do picoclaw como serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes. + +| Variável | Descrição | Caminho Padrão | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso indica diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Substitui o diretório raiz para dados do picoclaw. Isso altera o local padrão do `workspace` e outros diretórios de dados. | `~/.picoclaw` | + +**Exemplos:** + +```bash +# Executar picoclaw usando um arquivo de configuração específico +# O caminho do workspace será lido de dentro desse arquivo de configuração +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Executar picoclaw com todos os dados armazenados em /opt/picoclaw +# A configuração será carregada do padrão ~/.picoclaw/config.json +# O workspace será criado em /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Usar ambos para uma configuração totalmente personalizada +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Layout do Workspace + +O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessões de conversa e histórico +├── memory/ # Memória de longo prazo (MEMORY.md) +├── state/ # Estado persistente (último canal, etc.) +├── cron/ # Banco de dados de tarefas agendadas +├── skills/ # Skills personalizadas +├── AGENTS.md # Guia de comportamento do agente +├── HEARTBEAT.md # Prompts de tarefas periódicas (verificados a cada 30 min) +├── IDENTITY.md # Identidade do agente +├── SOUL.md # Alma do agente +└── USER.md # Preferências do usuário +``` + +### Fontes de Skills + +Por padrão, as skills são carregadas de: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin) + +Para configurações avançadas/de teste, você pode substituir o diretório raiz de skills builtin com: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### 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. +- Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM. +- Comando registrado mas não suportado no canal atual (por exemplo `/show` no WhatsApp) retorna um erro explícito ao usuário e interrompe o processamento. + +### 🔒 Sandbox de Segurança + +O PicoClaw é executado em um ambiente sandbox por padrão. O agente só pode acessar arquivos e executar comandos dentro do workspace configurado. + +#### Configuração Padrão + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Opção | Padrão | Descrição | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente | +| `restrict_to_workspace` | `true` | Restringir acesso a arquivos/comandos ao workspace | + +#### Ferramentas Protegidas + +Quando `restrict_to_workspace: true`, as seguintes ferramentas são isoladas: + +| Ferramenta | Função | Restrição | +| ------------- | ---------------- | -------------------------------------- | +| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | +| `write_file` | Escrever arquivos| Apenas arquivos dentro do workspace | +| `list_dir` | Listar diretórios| Apenas diretórios dentro do workspace | +| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | +| `append_file` | Anexar a arquivos| Apenas arquivos dentro do workspace | +| `exec` | Executar comandos| Caminhos de comando devem estar dentro do workspace | + +#### Proteção Adicional do Exec + +Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: + +* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa +* `format`, `mkfs`, `diskpart` — Formatação de disco +* `dd if=` — Imagem de disco +* Escrita em `/dev/sd[a-z]` — Escritas diretas em disco +* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema +* Fork bomb `:(){ :|:& };:` + +### Controle de Acesso a Arquivos + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | +| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | + +### Segurança do Exec + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | + +> **Nota de Segurança:** A proteção contra symlinks é habilitada por padrão — todos os caminhos de arquivo são resolvidos através de `filepath.EvalSymlinks` antes da correspondência com a whitelist, prevenindo ataques de escape via symlink. + +#### Limitação Conhecida: Processos Filhos de Ferramentas de Build + +O guard de segurança do exec inspeciona apenas a linha de comando que o PicoClaw executa diretamente. Ele não inspeciona recursivamente processos filhos gerados por ferramentas de desenvolvimento permitidas como `make`, `go run`, `cargo`, `npm run` ou scripts de build personalizados. + +Isso significa que um comando de nível superior ainda pode compilar ou executar outros binários após passar pela verificação inicial do guard. Na prática, trate scripts de build, Makefiles, scripts de pacotes e binários gerados como código executável que precisa do mesmo nível de revisão que um comando shell direto. + +Para ambientes de maior risco: + +* Revise scripts de build antes da execução. +* Prefira aprovação/revisão manual para fluxos de trabalho de compilação e execução. +* Execute o PicoClaw dentro de um contêiner ou VM se precisar de isolamento mais forte do que o guard integrado oferece. + +#### Exemplos de Erro + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Desabilitando Restrições (Risco de Segurança) + +Se você precisar que o agente acesse caminhos fora do workspace: + +**Método 1: Arquivo de configuração** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Método 2: Variável de ambiente** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cautela apenas em ambientes controlados. + +#### Consistência do Limite de Segurança + +A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução: + +| Caminho de Execução | Limite de Segurança | +| -------------------- | ---------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Herda a mesma restrição ✅ | +| Heartbeat tasks | Herda a mesma restrição ✅ | + +Todos os caminhos compartilham a mesma restrição de workspace — não há como contornar o limite de segurança através de subagentes ou tarefas agendadas. + +### Heartbeat (Tarefas Periódicas) + +O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: + +```markdown +# Tarefas Periódicas + +- Verificar meu e-mail para mensagens importantes +- Revisar meu calendário para eventos próximos +- Verificar a previsão do tempo +``` + +O agente lerá este arquivo a cada 30 minutos (configurável) e executará quaisquer tarefas usando as ferramentas disponíveis. + +#### Tarefas Assíncronas com Spawn + +Para tarefas de longa duração (busca na web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: + +```markdown +# Tarefas Periódicas +``` diff --git a/docs/pt-br/docker.md b/docs/pt-br/docker.md new file mode 100644 index 000000000..af58c89b2 --- /dev/null +++ b/docs/pt-br/docker.md @@ -0,0 +1,166 @@ +# 🐳 Docker e Início Rápido + +> Voltar ao [README](../../README.pt-br.md) + +## 🐳 Docker Compose + +Você também pode executar o PicoClaw usando Docker Compose sem instalar nada localmente. + +```bash +# 1. Clone este repositório +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra +docker compose -f docker/docker-compose.yml --profile gateway up +# O contêiner exibe "First-run setup complete." e para. + +# 3. Configure suas chaves de API +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Iniciar +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Usuários Docker**: Por padrão, o Gateway escuta em `127.0.0.1`, que não é acessível a partir do host. Se você precisar acessar os endpoints de saúde ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` no seu ambiente ou atualize o `config.json`. + +```bash +# 5. Verificar logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Parar +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Modo Launcher (Console Web) + +A imagem `launcher` inclui 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. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente. + +> [!WARNING] +> O console web ainda não suporta autenticação. Evite expô-lo na internet pública. + +### Modo Agent (One-shot) + +```bash +# Fazer uma pergunta +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Modo interativo +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Atualização + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Início Rápido + +> [!TIP] +> Configure sua chave de API em `~/.picoclaw/config.json`. Obtenha chaves de API: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). A busca na web é opcional — obtenha gratuitamente uma [API Tavily](https://tavily.com) (1000 consultas gratuitas/mês) ou [API Brave Search](https://brave.com/search/api) (2000 consultas gratuitas/mês). + +**1. Inicializar** + +```bash +picoclaw onboard +``` + +**2. Configurar** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "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", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alteração de código. Veja [Configuração de Modelos](#configuração-de-modelos-model_list) para detalhes. +> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s). + +**3. Obter chaves de API** + +* **Provedor LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Busca na Web** (opcional): + * [Brave Search](https://brave.com/search/api) - Pago ($5/1000 consultas, ~$5-6/mês) + * [Perplexity](https://www.perplexity.ai) - Busca com IA e interface de chat + * [SearXNG](https://github.com/searxng/searxng) - Metabuscador auto-hospedado (gratuito, sem necessidade de chave de API) + * [Tavily](https://tavily.com) - Otimizado para agentes de IA (1000 requisições/mês) + * DuckDuckGo - Fallback integrado (sem necessidade de chave de API) + +> **Nota**: Veja `config.example.json` para um modelo de configuração completo. + +**4. Conversar** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +Pronto! Você tem um assistente de IA funcionando em 2 minutos. + +--- diff --git a/docs/pt-br/providers.md b/docs/pt-br/providers.md new file mode 100644 index 000000000..04fb9fc6b --- /dev/null +++ b/docs/pt-br/providers.md @@ -0,0 +1,434 @@ +# 🔌 Provedores e Configuração de Modelos + +> Voltar ao [README](../../README.pt-br.md) + +### Provedores + +> [!NOTE] +> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | + +### Configuração de Modelos (model_list) + +> **Novidade?** O PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `vendor/model` (ex.: `zhipu/glm-4.7`) para adicionar novos provedores — **sem necessidade de alteração de código!** + +Este design também permite **suporte multi-agente** com seleção flexível de provedores: + +- **Agentes diferentes, provedores diferentes**: Cada agente pode usar seu próprio provedor LLM +- **Fallback de modelos**: Configure modelos primários e de fallback para resiliência +- **Balanceamento de carga**: Distribua requisições entre múltiplos endpoints +- **Configuração centralizada**: Gerencie todos os provedores em um só lugar + +#### 📋 Todos os Vendors Suportados + +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | 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 | - | + +#### Configuração Básica + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-openai-key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-zhipu-key" + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +#### Exemplos por Vendor + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-..." +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_key": "sk-..." +} +``` + +**Anthropic (com chave de API)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" +} +``` + +> Execute `picoclaw auth login --provider anthropic` para colar seu token de API. + +**Anthropic Messages API (formato nativo)** + +Para acesso direto à API Anthropic ou endpoints personalizados que suportam apenas o formato de mensagem nativo da Anthropic: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com" +} +``` + +> Use o protocolo `anthropic-messages` quando: +> - Usar proxies de terceiros que suportam apenas o endpoint nativo `/v1/messages` da Anthropic (não o compatível com OpenAI `/v1/chat/completions`) +> - Conectar a serviços como MiniMax, Synthetic que requerem o formato de mensagem nativo da Anthropic +> - O protocolo `anthropic` existente retorna erros 404 (indicando que o endpoint não suporta formato compatível com OpenAI) +> +> **Nota:** O protocolo `anthropic` usa formato compatível com OpenAI (`/v1/chat/completions`), enquanto `anthropic-messages` usa o formato nativo da Anthropic (`/v1/messages`). Escolha com base no formato suportado pelo seu endpoint. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**Proxy/API Personalizado** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_key": "sk-...", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_key": "sk-..." +} +``` + +O PicoClaw remove apenas o prefixo externo `litellm/` antes de enviar a requisição, então aliases de proxy como `litellm/lite-gpt4` enviam `lite-gpt4`, enquanto `litellm/openai/gpt-4o` envia `openai/gpt-4o`. + +#### Balanceamento de Carga + +Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará automaticamente round-robin entre eles: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_key": "sk-key1" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_key": "sk-key2" + } + ] +} +``` + +#### Migração da Configuração Legacy `providers` + +A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade retroativa. + +**Configuração Antiga (descontinuada):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**Configuração Nova (recomendada):** + +```json +{ + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "glm-4.7" + } + } +} +``` + +Para guia de migração detalhado, veja [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). + +### Arquitetura de Provedores + +O PicoClaw roteia provedores por família de protocolo: + +- Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM. +- Protocolo 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`). + +
+Zhipu + +**1. Obter chave de API e URL base** + +* Obtenha a [chave de API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurar** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Exemplo de configuração completa + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 Comparação de Chaves de API + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/pt-br/spawn-tasks.md b/docs/pt-br/spawn-tasks.md new file mode 100644 index 000000000..d6b539cb1 --- /dev/null +++ b/docs/pt-br/spawn-tasks.md @@ -0,0 +1,61 @@ +# 🔄 Tarefas Assíncronas e Spawn + +> Voltar ao [README](../../README.pt-br.md) + +## Tarefas Rápidas (resposta direta) + +- Informar a hora atual + +## Tarefas Longas (usar spawn para assíncrono) + +- Pesquisar na web notícias sobre IA e resumir +- Verificar e-mail e relatar mensagens importantes +``` + +**Comportamentos principais:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### Como Funciona a Comunicação do Subagente + +``` +Heartbeat é acionado + ↓ +Agente lê HEARTBEAT.md + ↓ +Para tarefa longa: spawn subagente + ↓ ↓ +Continua para próxima tarefa Subagente trabalha independentemente + ↓ ↓ +Todas as tarefas concluídas Subagente usa ferramenta "message" + ↓ ↓ +Responde HEARTBEAT_OK Usuário recebe resultado diretamente +``` + +O subagente tem acesso a ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal. + +**Configuração:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Variáveis de ambiente:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar +* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo diff --git a/docs/pt-br/tools_configuration.md b/docs/pt-br/tools_configuration.md new file mode 100644 index 000000000..b6f726aa4 --- /dev/null +++ b/docs/pt-br/tools_configuration.md @@ -0,0 +1,336 @@ +# 🔧 Configuração de Ferramentas + +> Voltar ao [README](../../README.pt-br.md) + +A configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`. + +## Estrutura de diretórios + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Ferramentas Web + +As ferramentas web são usadas para pesquisa e busca de páginas web. + +### Web Fetcher +Configurações gerais para busca e processamento de conteúdo de páginas web. + +| Config | Tipo | Padrão | Descrição | +|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Habilitar a capacidade de busca de páginas web. | +| `fetch_limit_bytes` | int | 10485760 | Tamanho máximo do payload da página web a ser buscado, em bytes (padrão é 10MB). | +| `format` | string | "plaintext" | Formato de saída do conteúdo buscado. Opções: `plaintext` ou `markdown` (recomendado). | + +### Brave + +| Config | Tipo | Padrão | Descrição | +|---------------|--------|--------|----------------------------| +| `enabled` | bool | false | Habilitar pesquisa Brave | +| `api_key` | string | - | Chave API do Brave Search | +| `max_results` | int | 5 | Número máximo de resultados | + +### DuckDuckGo + +| Config | Tipo | Padrão | Descrição | +|---------------|------|--------|--------------------------------| +| `enabled` | bool | true | Habilitar pesquisa DuckDuckGo | +| `max_results` | int | 5 | Número máximo de resultados | + +### Perplexity + +| Config | Tipo | Padrão | Descrição | +|---------------|--------|--------|--------------------------------| +| `enabled` | bool | false | Habilitar pesquisa Perplexity | +| `api_key` | string | - | Chave API do Perplexity | +| `max_results` | int | 5 | Número máximo de resultados | + +## Ferramenta Exec + +A ferramenta exec é usada para executar comandos shell. + +| Config | Tipo | Padrão | Descrição | +|------------------------|-------|--------|-------------------------------------------------| +| `enable_deny_patterns` | bool | true | Habilitar bloqueio padrão de comandos perigosos | +| `custom_deny_patterns` | array | [] | Padrões de negação personalizados (expressões regulares) | + +### Funcionalidade + +- **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão +- **`custom_deny_patterns`**: Adicione padrões regex de negação personalizados; comandos correspondentes serão bloqueados + +### Padrões de comandos bloqueados por padrão + +Por padrão, o PicoClaw bloqueia os seguintes comandos perigosos: + +- Comandos de exclusão: `rm -rf`, `del /f/q`, `rmdir /s` +- Operações de disco: `format`, `mkfs`, `diskpart`, `dd if=`, escrita em `/dev/sd*` +- Operações do sistema: `shutdown`, `reboot`, `poweroff` +- Substituição de comandos: `$()`, `${}`, crases +- Pipe para shell: `| sh`, `| bash` +- Escalação de privilégios: `sudo`, `chmod`, `chown` +- Controle de processos: `pkill`, `killall`, `kill -9` +- Operações remotas: `curl | sh`, `wget | sh`, `ssh` +- Gerenciamento de pacotes: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Contêineres: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Outros: `eval`, `source *.sh` + +### Limitação arquitetural conhecida + +O guarda exec apenas valida o comando de nível superior enviado ao PicoClaw. Ele **não** inspeciona recursivamente processos filhos gerados por ferramentas de build ou scripts após o início desse comando. + +Exemplos de fluxos de trabalho que podem contornar o guarda de comando direto uma vez que o comando inicial é permitido: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +Isso significa que o guarda é útil para bloquear comandos diretos obviamente perigosos, mas **não** é um sandbox completo para pipelines de build não revisados. Se seu modelo de ameaça inclui código não confiável no workspace, use isolamento mais forte, como contêineres, VMs ou um fluxo de aprovação em torno de comandos de build e execução. + +### Exemplo de configuração + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Ferramenta Cron + +A ferramenta cron é usada para agendar tarefas periódicas. + +| Config | Tipo | Padrão | Descrição | +|------------------------|------|--------|-----------------------------------------------------| +| `exec_timeout_minutes` | int | 5 | Tempo limite de execução em minutos, 0 significa sem limite | + +## Ferramenta MCP + +A ferramenta MCP permite a integração com servidores Model Context Protocol externos. + +### Descoberta de ferramentas (carregamento preguiçoso) + +Ao conectar a vários servidores MCP, expor centenas de ferramentas simultaneamente pode esgotar a janela de contexto do LLM e aumentar os custos de API. O recurso **Discovery** resolve isso mantendo as ferramentas MCP *ocultas* por padrão. + +Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa leve (usando correspondência de palavras-chave BM25 ou Regex). Quando o LLM precisa de uma capacidade específica, ele pesquisa a biblioteca oculta. As ferramentas correspondentes são então temporariamente "desbloqueadas" e injetadas no contexto por um número configurado de turnos (`ttl`). + +### Configuração global + +| Config | Tipo | Padrão | Descrição | +|-------------|--------|--------|----------------------------------------------| +| `enabled` | bool | false | Habilitar integração MCP globalmente | +| `discovery` | object | `{}` | Configuração de descoberta de ferramentas (veja abaixo) | +| `servers` | object | `{}` | Mapa de nome do servidor para configuração do servidor | + +### Configuração Discovery (`discovery`) + +| Config | Tipo | Padrão | Descrição | +|----------------------|------|--------|-----------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | Se true, as ferramentas MCP ficam ocultas e são carregadas sob demanda via pesquisa. Se false, todas as ferramentas são carregadas | +| `ttl` | int | 5 | Número de turnos de conversa que uma ferramenta descoberta permanece desbloqueada | +| `max_search_results` | int | 5 | Número máximo de ferramentas retornadas por consulta de pesquisa | +| `use_bm25` | bool | true | Habilitar a ferramenta de pesquisa por linguagem natural/palavras-chave (`tool_search_tool_bm25`). **Aviso**: consome mais recursos que a pesquisa regex | +| `use_regex` | bool | false | Habilitar a ferramenta de pesquisa por padrão regex (`tool_search_tool_regex`) | + +> **Nota:** Se `discovery.enabled` for `true`, você **deve** habilitar pelo menos um mecanismo de pesquisa (`use_bm25` ou `use_regex`), +> caso contrário a aplicação falhará ao iniciar. + +### Configuração por servidor + +| Config | Tipo | Obrigatório | Descrição | +|------------|--------|-------------|--------------------------------------------| +| `enabled` | bool | sim | Habilitar este servidor MCP | +| `type` | string | não | Tipo de transporte: `stdio`, `sse`, `http` | +| `command` | string | stdio | Comando executável para transporte stdio | +| `args` | array | não | Argumentos do comando para transporte stdio | +| `env` | object | não | Variáveis de ambiente para processo stdio | +| `env_file` | string | não | Caminho para arquivo de ambiente para processo stdio | +| `url` | string | sse/http | URL do endpoint para transporte `sse`/`http` | +| `headers` | object | não | Cabeçalhos HTTP para transporte `sse`/`http` | + +### Comportamento do transporte + +- Se `type` for omitido, o transporte é detectado automaticamente: + - `url` está definido → `sse` + - `command` está definido → `stdio` +- `http` e `sse` ambos usam `url` + `headers` opcionais. +- `env` e `env_file` são aplicados apenas a servidores `stdio`. + +### Exemplos de configuração + +#### 1) Servidor MCP Stdio + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) Servidor MCP remoto SSE/HTTP + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) Configuração MCP massiva com descoberta de ferramentas habilitada + +*Neste exemplo, o LLM verá apenas o `tool_search_tool_bm25`. Ele pesquisará e desbloqueará ferramentas do Github ou Postgres dinamicamente apenas quando solicitado pelo usuário.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + +## Ferramenta Skills + +A ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub. + +### Registros + +| Config | Tipo | Padrão | Descrição | +|------------------------------------|--------|-----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Habilitar registro ClawHub | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL base do ClawHub | +| `registries.clawhub.auth_token` | string | `""` | Token Bearer opcional para limites de taxa mais altos | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Caminho da API de pesquisa | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Caminho da API de Skills | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Caminho da API de download | + +### Exemplo de configuração + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## Variáveis de ambiente + +Todas as opções de configuração podem ser substituídas via variáveis de ambiente com o formato `PICOCLAW_TOOLS_
_`: + +Por exemplo: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +Nota: Configuração de tipo mapa aninhado (por exemplo `tools.mcp.servers..*`) é configurada no `config.json` em vez de variáveis de ambiente. diff --git a/docs/pt-br/troubleshooting.md b/docs/pt-br/troubleshooting.md new file mode 100644 index 000000000..e6c1a55ab --- /dev/null +++ b/docs/pt-br/troubleshooting.md @@ -0,0 +1,45 @@ +# 🐛 Solução de Problemas + +> Voltar ao [README](../../README.pt-br.md) + +## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" + +**Sintoma:** Você vê um dos seguintes erros: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter retorna 400: `"free is not a valid model ID"` + +**Causa:** O campo `model` na sua entrada `model_list` é o que é enviado para a API. Para o OpenRouter, você deve usar o ID de modelo **completo**, não uma abreviação. + +- **Errado:** `"model": "free"` → OpenRouter recebe `free` e rejeita. +- **Correto:** `"model": "openrouter/free"` → OpenRouter recebe `openrouter/free` (roteamento automático do nível gratuito). + +**Correção:** Em `~/.picoclaw/config.json` (ou seu caminho de configuração): + +1. **agents.defaults.model** deve corresponder a um `model_name` em `model_list` (ex.: `"openrouter-free"`). +2. O **model** dessa entrada deve ser um ID de modelo OpenRouter válido, por exemplo: + - `"openrouter/free"` – nível gratuito automático + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Exemplo: + +```json +{ + "agents": { + "defaults": { + "model": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Obtenha sua chave em [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/spawn-tasks.md b/docs/spawn-tasks.md new file mode 100644 index 000000000..eff96ce45 --- /dev/null +++ b/docs/spawn-tasks.md @@ -0,0 +1,61 @@ +# 🔄 Spawn & Async Tasks + +> Back to [README](../README.md) + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**Key behaviors:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### How Subagent Communication Works + +``` +Heartbeat triggers + ↓ +Agent reads HEARTBEAT.md + ↓ +For long task: spawn subagent + ↓ ↓ +Continue to next task Subagent works independently + ↓ ↓ +All tasks done Subagent uses "message" tool + ↓ ↓ +Respond HEARTBEAT_OK User receives result directly +``` + +The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent. + +**Configuration:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Environment variables:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable +* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index 8c8eb31f0..08746e267 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -30,6 +30,15 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. Web tools are used for web search and fetching. +### Web Fetcher +General settings for fetching and processing webpage content. + +| Config | Type | Default | Description | +|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Enable the webpage fetching capability. | +| `fetch_limit_bytes` | int | 10485760 | Maximum size of the webpage payload to fetch, in bytes (default is 10MB). | +| `format` | string | "plaintext" | Output format of the fetched content. Options: `plaintext` or `markdown` (recommended). | + ### Brave | Config | Type | Default | Description | @@ -84,6 +93,22 @@ By default, PicoClaw blocks the following dangerous commands: - Git: `git push`, `git force` - Other: `eval`, `source *.sh` +### Known Architectural Limitation + +The exec guard only validates the top-level command sent to PicoClaw. It does **not** recursively inspect child +processes spawned by build tools or scripts after that command starts running. + +Examples of workflows that can bypass the direct command guard once the initial command is allowed: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +This means the guard is useful for blocking obviously dangerous direct commands, but it is **not** a full sandbox for +unreviewed build pipelines. If your threat model includes untrusted code in the workspace, use stronger isolation such +as containers, VMs, or an approval flow around build-and-run commands. + ### Configuration Example ```json diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md new file mode 100644 index 000000000..1fefa00d3 --- /dev/null +++ b/docs/vi/chat-apps.md @@ -0,0 +1,427 @@ +# 💬 Cấu Hình Ứng Dụng Chat + +> Quay lại [README](../../README.vi.md) + +## 💬 Ứng Dụng Chat + +Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot hoặc MaixCam + +> **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung. + +| Channel | Setup | +| ------------ | ---------------------------------- | +| **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | +| **WhatsApp** | Easy (native: QR scan; or bridge URL) | +| **Matrix** | Medium (homeserver + bot access token) | +| **QQ** | Easy (AppID + AppSecret) | +| **DingTalk** | Medium (app credentials) | +| **LINE** | Medium (credentials + webhook URL) | +| **WeCom AI Bot** | Medium (Token + AES key) | +| **Feishu** | Medium (App ID + Secret, WebSocket mode) | +| **Slack** | Medium (Bot token + App token) | +| **IRC** | Medium (server + TLS config) | +| **OneBot** | Medium (QQ via OneBot protocol) | +| **MaixCam** | Easy (Sipeed hardware integration) | +| **Pico** | Native PicoClaw protocol | + +
+Telegram (Khuyến nghị) + +**1. Tạo bot** + +* Mở Telegram, tìm `@BotFather` +* Gửi `/newbot`, làm theo hướng dẫn +* Sao chép token + +**2. Cấu hình** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Lấy user ID của bạn từ `@userinfobot` trên Telegram. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +**4. Menu lệnh Telegram (tự động đăng ký khi khởi động)** + +PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`) để 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. + +
+ +
+Discord + +**1. Tạo bot** + +* Truy cập +* Tạo ứng dụng → Bot → Add Bot +* Sao chép bot token + +**2. Bật intents** + +* Trong cài đặt Bot, bật **MESSAGE CONTENT INTENT** +* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu bạn muốn sử dụng danh sách cho phép dựa trên dữ liệu thành viên + +**3. Lấy User ID** +* Cài đặt Discord → Nâng cao → bật **Developer Mode** +* Nhấp chuột phải vào avatar → **Copy User ID** + +**4. Cấu hình** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Mời bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Mở URL mời được tạo và thêm bot vào server của bạn + +**Tùy chọn: Chế độ kích hoạt nhóm** + +Mặc định bot phản hồi tất cả tin nhắn trong kênh server. Để giới hạn phản hồi chỉ khi @mention, thêm: + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Bạn cũng có thể kích hoạt bằng tiền tố từ khóa (ví dụ: `!bot`): + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp (native qua whatsmeow) + +PicoClaw có thể kết nối WhatsApp theo hai cách: + +- **Native (khuyến nghị):** In-process sử dụng [whatsmeow](https://github.com/tulir/whatsmeow). Không cần bridge riêng. Đặt `"use_native": true` và để trống `bridge_url`. Lần chạy đầu tiên, quét mã QR bằng WhatsApp (Thiết bị liên kết). Phiên được lưu trong workspace (ví dụ: `workspace/whatsapp/`). Kênh native là **tùy chọn** để giữ binary mặc định nhỏ; build với `-tags whatsapp_native` (ví dụ: `make build-whatsapp-native` hoặc `go build -tags whatsapp_native ./cmd/...`). +- **Bridge:** Kết nối đến bridge WebSocket bên ngoài. Đặt `bridge_url` (ví dụ: `ws://localhost:3001`) và giữ `use_native` là false. + +**Cấu hình (native)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Nếu `session_store_path` trống, phiên được lưu tại `/whatsapp/`. Chạy `picoclaw gateway`; lần chạy đầu tiên, quét mã QR hiển thị trong terminal bằng WhatsApp → Thiết bị liên kết. + +
+ +
+QQ + +**1. Tạo bot** + +- Truy cập [QQ Open Platform](https://q.qq.com/#) +- Tạo ứng dụng → Lấy **AppID** và **AppSecret** + +**2. Cấu hình** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn truy cập. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Tạo bot** + +* Truy cập [Open Platform](https://open.dingtalk.com/) +* Tạo ứng dụng nội bộ +* Sao chép Client ID và Client Secret + +**2. Cấu hình** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định DingTalk user ID để giới hạn truy cập. + +**3. Chạy** + +```bash +picoclaw gateway +``` +
+ +
+Matrix + +**1. Chuẩn bị tài khoản bot** + +* Sử dụng homeserver ưa thích (ví dụ: `https://matrix.org` hoặc tự host) +* Tạo user bot và lấy access token + +**2. Cấu hình** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +Để xem đầy đủ các tùy chọn (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), xem [Hướng Dẫn Cấu Hình Kênh Matrix](docs/channels/matrix/README.md). + +
+ +
+LINE + +**1. Tạo Tài Khoản LINE Official** + +- Truy cập [LINE Developers Console](https://developers.line.biz/) +- Tạo provider → Tạo kênh Messaging API +- Sao chép **Channel Secret** và **Channel Access Token** + +**2. Cấu hình** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> Webhook LINE được phục vụ trên máy chủ Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). + +**3. Thiết lập Webhook URL** + +LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel: + +```bash +# Ví dụ với ngrok (port mặc định gateway là 18790) +ngrok http 18790 +``` + +Sau đó đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. + +**4. Chạy** + +```bash +picoclaw gateway +``` + +> Trong chat nhóm, bot chỉ phản hồi khi được @mention. Phản hồi trích dẫn tin nhắn gốc. + +
+ +
+WeCom (企业微信) + +PicoClaw hỗ trợ ba loại tích hợp WeCom: + +**Tùy chọn 1: WeCom Bot (Bot)** - Thiết lập dễ hơn, hỗ trợ chat nhóm +**Tùy chọn 2: WeCom App (App Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng +**Tùy chọn 3: WeCom AI Bot (AI Bot)** - AI Bot chính thức, phản hồi streaming, hỗ trợ chat nhóm & riêng + +Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn thiết lập chi tiết. + +**Thiết Lập Nhanh - WeCom Bot:** + +**1. Tạo bot** + +* Truy cập Console Quản Trị WeCom → Chat Nhóm → Thêm Bot Nhóm +* Sao chép URL webhook (định dạng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Cấu hình** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> Webhook WeCom được phục vụ trên máy chủ Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). + +**Thiết Lập Nhanh - WeCom App:** + +**1. Tạo ứng dụng** + +* Truy cập Console Quản Trị WeCom → Quản Lý App → Tạo App +* Sao chép **AgentId** và **Secret** +* Truy cập trang "Công Ty Của Tôi", sao chép **CorpID** + +**2. Cấu hình nhận tin nhắn** + +* Trong chi tiết App, nhấp "Nhận Tin Nhắn" → "Cấu Hình API" +* Đặt URL thành `http://your-server:18790/webhook/wecom-app` +* Tạo **Token** và **EncodingAESKey** + +**3. Cấu hình** + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Chạy** + +```bash +picoclaw gateway +``` + +> **Lưu ý**: Callback webhook WeCom được phục vụ trên port Gateway (mặc định 18790). Sử dụng reverse proxy cho HTTPS. + +**Thiết Lập Nhanh - WeCom AI Bot:** + +**1. Tạo AI Bot** + +* Truy cập Console Quản Trị WeCom → Quản Lý App → AI Bot +* Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18791/webhook/wecom-aibot` +* Sao chép **Token** và nhấp "Tạo Ngẫu Nhiên" cho **EncodingAESKey** + +**2. Cấu hình** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +> **Lưu ý**: WeCom AI Bot sử dụng giao thức streaming pull — không lo timeout phản hồi. Tác vụ dài (>30 giây) tự động chuyển sang gửi qua `response_url` push. + +
diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md new file mode 100644 index 000000000..22b9bd509 --- /dev/null +++ b/docs/vi/configuration.md @@ -0,0 +1,217 @@ +# ⚙️ Hướng Dẫn Cấu Hình + +> Quay lại [README](../../README.vi.md) + +## ⚙️ Cấu Hình + +File cấu hình: `~/.picoclaw/config.json` + +### Biến Môi Trường + +Bạn có thể ghi đè các đường dẫn mặc định bằng biến môi trường. Điều này hữu ích cho cài đặt portable, triển khai container, hoặc chạy picoclaw như dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau. + +| Biến | Mô tả | Đường Dẫn Mặc Định | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Chỉ định trực tiếp cho picoclaw file `config.json` nào cần tải, bỏ qua tất cả vị trí khác. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` | + +**Ví dụ:** + +```bash +# Chạy picoclaw với file cấu hình cụ thể +# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Chạy picoclaw với tất cả dữ liệu lưu tại /opt/picoclaw +# Cấu hình sẽ được tải từ mặc định ~/.picoclaw/config.json +# Workspace sẽ được tạo tại /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Sử dụng cả hai cho thiết lập tùy chỉnh hoàn toàn +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Bố Cục Workspace + +PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Phiên hội thoại và lịch sử +├── memory/ # Bộ nhớ dài hạn (MEMORY.md) +├── state/ # Trạng thái bền vững (kênh cuối, v.v.) +├── cron/ # Cơ sở dữ liệu tác vụ lên lịch +├── skills/ # Skill tùy chỉnh +├── AGENTS.md # Hướng dẫn hành vi agent +├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) +├── IDENTITY.md # Danh tính agent +├── SOUL.md # Linh hồn agent +└── USER.md # Tùy chọn người dùng +``` + +### Nguồn Skill + +Mặc định, skill được tải từ: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin) + +Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skill builtin với: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### 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. +- Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường. +- Lệnh đã đăng ký nhưng không được hỗ trợ trên kênh hiện tại (ví dụ `/show` trên WhatsApp) trả về lỗi rõ ràng cho người dùng và dừng xử lý tiếp. + +### 🔒 Sandbox Bảo Mật + +PicoClaw chạy trong môi trường sandbox mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong workspace đã cấu hình. + +#### Cấu Hình Mặc Định + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Tùy chọn | Mặc định | Mô tả | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent | +| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace | + +#### Công Cụ Được Bảo Vệ + +Khi `restrict_to_workspace: true`, các công cụ sau được sandbox: + +| Công cụ | Chức năng | Giới hạn | +| ------------- | ---------------- | -------------------------------------- | +| `read_file` | Đọc file | Chỉ file trong workspace | +| `write_file` | Ghi file | Chỉ file trong workspace | +| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace | +| `edit_file` | Sửa file | Chỉ file trong workspace | +| `append_file` | Nối vào file | Chỉ file trong workspace | +| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace | + +#### Bảo Vệ Exec Bổ Sung + +Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` chặn các lệnh nguy hiểm sau: + +* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt +* `format`, `mkfs`, `diskpart` — Định dạng đĩa +* `dd if=` — Tạo ảnh đĩa +* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp đĩa +* `shutdown`, `reboot`, `poweroff` — Tắt hệ thống +* Fork bomb `:(){ :|:& };:` + +### Kiểm Soát Truy Cập File + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | +| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | + +### Bảo Mật Exec + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | + +> **Lưu ý Bảo Mật:** Bảo vệ symlink được bật mặc định — tất cả đường dẫn file được giải quyết qua `filepath.EvalSymlinks` trước khi so khớp whitelist, ngăn chặn tấn công thoát qua symlink. + +#### Hạn Chế Đã Biết: Tiến Trình Con Từ Công Cụ Build + +Guard bảo mật exec chỉ kiểm tra dòng lệnh mà PicoClaw khởi chạy trực tiếp. Nó không kiểm tra đệ quy các tiến trình con được tạo bởi công cụ phát triển được phép như `make`, `go run`, `cargo`, `npm run`, hoặc script build tùy chỉnh. + +Điều này có nghĩa là lệnh cấp cao nhất vẫn có thể biên dịch hoặc khởi chạy binary khác sau khi vượt qua kiểm tra guard ban đầu. Trong thực tế, hãy coi script build, Makefile, script package, và binary được tạo như mã thực thi cần cùng mức độ review như lệnh shell trực tiếp. + +Cho môi trường rủi ro cao hơn: + +* Review script build trước khi thực thi. +* Ưu tiên phê duyệt/review thủ công cho quy trình biên dịch và chạy. +* Chạy PicoClaw trong container hoặc VM nếu bạn cần cách ly mạnh hơn guard tích hợp. + +#### Ví Dụ Lỗi + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Tắt Giới Hạn (Rủi Ro Bảo Mật) + +Nếu bạn cần agent truy cập đường dẫn ngoài workspace: + +**Phương pháp 1: File cấu hình** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Phương pháp 2: Biến môi trường** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập bất kỳ đường dẫn nào trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát. + +#### Tính Nhất Quán Ranh Giới Bảo Mật + +Cài đặt `restrict_to_workspace` áp dụng nhất quán trên tất cả đường dẫn thực thi: + +| Đường Dẫn Thực Thi | Ranh Giới Bảo Mật | +| -------------------- | ---------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Kế thừa cùng giới hạn ✅ | +| Heartbeat tasks | Kế thừa cùng giới hạn ✅ | + +Tất cả đường dẫn chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật qua subagent hoặc tác vụ lên lịch. + +### Heartbeat (Tác Vụ Định Kỳ) + +PicoClaw có thể thực hiện tác vụ định kỳ tự động. Tạo file `HEARTBEAT.md` trong workspace: + +```markdown +# Tác Vụ Định Kỳ + +- Kiểm tra email cho tin nhắn quan trọng +- Xem lịch cho sự kiện sắp tới +- Kiểm tra dự báo thời tiết +``` + +Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực thi các tác vụ sử dụng công cụ có sẵn. + +#### Tác Vụ Bất Đồng Bộ Với Spawn + +Cho tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**: + +```markdown +# Tác Vụ Định Kỳ +``` diff --git a/docs/vi/docker.md b/docs/vi/docker.md new file mode 100644 index 000000000..519ace5ba --- /dev/null +++ b/docs/vi/docker.md @@ -0,0 +1,166 @@ +# 🐳 Docker và Bắt Đầu Nhanh + +> Quay lại [README](../../README.vi.md) + +## 🐳 Docker Compose + +Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy. + +```bash +# 1. Clone repo này +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Lần chạy đầu tiên — tự động tạo docker/data/config.json rồi thoát +docker compose -f docker/docker-compose.yml --profile gateway up +# Container hiển thị "First-run setup complete." và dừng lại. + +# 3. Cấu hình API key của bạn +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Khởi động +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Người dùng Docker**: Mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ host. Nếu bạn cần truy cập các health endpoint hoặc mở port, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường hoặc cập nhật `config.json`. + +```bash +# 5. Kiểm tra log +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Dừng +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Chế Độ Launcher (Web Console) + +Image `launcher` bao gồm cả 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. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway. + +> [!WARNING] +> Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng. + +### Chế Độ Agent (One-shot) + +```bash +# Đặt câu hỏi +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Chế độ tương tác +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Cập Nhật + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Bắt Đầu Nhanh + +> [!TIP] +> Cấu hình API Key trong `~/.picoclaw/config.json`. Lấy API Key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là tùy chọn — lấy miễn phí [Tavily API](https://tavily.com) (1000 truy vấn miễn phí/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng). + +**1. Khởi tạo** + +```bash +picoclaw onboard +``` + +**2. Cấu hình** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "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", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **Mới**: Định dạng cấu hình `model_list` cho phép thêm provider mà không cần thay đổi code. Xem [Cấu Hình Mô Hình](#cấu-hình-mô-hình-model_list) để biết chi tiết. +> `request_timeout` là tùy chọn và tính bằng giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sử dụng timeout mặc định (120s). + +**3. Lấy API Key** + +* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Tìm kiếm Web** (tùy chọn): + * [Brave Search](https://brave.com/search/api) - Trả phí ($5/1000 truy vấn, ~$5-6/tháng) + * [Perplexity](https://www.perplexity.ai) - Tìm kiếm bằng AI với giao diện chat + * [SearXNG](https://github.com/searxng/searxng) - Công cụ tìm kiếm tổng hợp tự host (miễn phí, không cần API key) + * [Tavily](https://tavily.com) - Tối ưu cho AI Agent (1000 yêu cầu/tháng) + * DuckDuckGo - Fallback tích hợp (không cần API key) + +> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ. + +**4. Chat** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +Vậy là xong! Bạn có một trợ lý AI hoạt động trong 2 phút. + +--- diff --git a/docs/vi/providers.md b/docs/vi/providers.md new file mode 100644 index 000000000..f7543eec3 --- /dev/null +++ b/docs/vi/providers.md @@ -0,0 +1,434 @@ +# 🔌 Nhà Cung Cấp và Cấu Hình Mô Hình + +> Quay lại [README](../../README.vi.md) + +### Nhà Cung Cấp + +> [!NOTE] +> Groq cung cấp chuyển đổi giọng nói miễn phí qua Whisper. Nếu được cấu hình, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển đổi ở cấp agent. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | + +### Cấu Hình Mô Hình (model_list) + +> **Có gì mới?** PicoClaw hiện sử dụng cách tiếp cận cấu hình **tập trung vào mô hình**. Chỉ cần chỉ định định dạng `vendor/model` (ví dụ: `zhipu/glm-4.7`) để thêm provider mới — **không cần thay đổi code!** + +Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn provider linh hoạt: + +- **Agent khác nhau, provider khác nhau**: Mỗi agent có thể sử dụng provider LLM riêng +- **Fallback mô hình**: Cấu hình mô hình chính và dự phòng cho khả năng phục hồi +- **Cân bằng tải**: Phân phối yêu cầu qua nhiều endpoint +- **Cấu hình tập trung**: Quản lý tất cả provider tại một nơi + +#### 📋 Tất Cả Vendor Được Hỗ Trợ + +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | 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 | - | + +#### Cấu Hình Cơ Bản + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-openai-key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-zhipu-key" + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +#### Ví Dụ Theo Vendor + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-..." +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_key": "sk-..." +} +``` + +**Anthropic (với API key)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" +} +``` + +> Chạy `picoclaw auth login --provider anthropic` để dán API token. + +**Anthropic Messages API (định dạng native)** + +Để truy cập trực tiếp API Anthropic hoặc endpoint tùy chỉnh chỉ hỗ trợ định dạng message native của Anthropic: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com" +} +``` + +> Sử dụng giao thức `anthropic-messages` khi: +> - Sử dụng proxy bên thứ ba chỉ hỗ trợ endpoint native `/v1/messages` của Anthropic (không tương thích OpenAI `/v1/chat/completions`) +> - Kết nối đến dịch vụ như MiniMax, Synthetic yêu cầu định dạng message native của Anthropic +> - Giao thức `anthropic` hiện tại trả về lỗi 404 (cho thấy endpoint không hỗ trợ định dạng tương thích OpenAI) +> +> **Lưu ý:** Giao thức `anthropic` sử dụng định dạng tương thích OpenAI (`/v1/chat/completions`), trong khi `anthropic-messages` sử dụng định dạng native của Anthropic (`/v1/messages`). Chọn dựa trên định dạng endpoint hỗ trợ. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**Proxy/API Tùy Chỉnh** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_key": "sk-...", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_key": "sk-..." +} +``` + +PicoClaw chỉ loại bỏ tiền tố ngoài `litellm/` trước khi gửi yêu cầu, nên alias proxy như `litellm/lite-gpt4` gửi `lite-gpt4`, trong khi `litellm/openai/gpt-4o` gửi `openai/gpt-4o`. + +#### Cân Bằng Tải + +Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự động round-robin giữa chúng: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_key": "sk-key1" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_key": "sk-key2" + } + ] +} +``` + +#### 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 cũ (ngừng hỗ trợ):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**Cấu hình mới (khuyến nghị):** + +```json +{ + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "glm-4.7" + } + } +} +``` + +Để xem hướng dẫn di chuyển chi tiết, xem [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). + +### Kiến Trúc Provider + +PicoClaw định tuyến provider theo họ giao thức: + +- Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM. +- Giao thức 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`). + +
+Zhipu + +**1. Lấy API key và URL base** + +* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Cấu hình** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Ví dụ cấu hình đầy đủ + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 So Sánh API Key + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/vi/spawn-tasks.md b/docs/vi/spawn-tasks.md new file mode 100644 index 000000000..78f728040 --- /dev/null +++ b/docs/vi/spawn-tasks.md @@ -0,0 +1,61 @@ +# 🔄 Tác Vụ Bất Đồng Bộ và Spawn + +> Quay lại [README](../../README.vi.md) + +## Tác Vụ Nhanh (phản hồi trực tiếp) + +- Báo cáo thời gian hiện tại + +## Tác Vụ Dài (sử dụng spawn cho bất đồng bộ) + +- Tìm kiếm web tin tức AI và tóm tắt +- Kiểm tra email và báo cáo tin nhắn quan trọng +``` + +**Hành vi chính:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### Cách Giao Tiếp Subagent Hoạt Động + +``` +Heartbeat được kích hoạt + ↓ +Agent đọc HEARTBEAT.md + ↓ +Cho tác vụ dài: spawn subagent + ↓ ↓ +Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập + ↓ ↓ +Tất cả tác vụ hoàn thành Subagent sử dụng công cụ "message" + ↓ ↓ +Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp +``` + +Subagent có quyền truy cập công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng độc lập mà không cần qua agent chính. + +**Cấu hình:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Biến môi trường:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt +* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian diff --git a/docs/vi/tools_configuration.md b/docs/vi/tools_configuration.md new file mode 100644 index 000000000..6cc4dc8b6 --- /dev/null +++ b/docs/vi/tools_configuration.md @@ -0,0 +1,336 @@ +# 🔧 Cấu Hình Công Cụ + +> Quay lại [README](../../README.vi.md) + +Cấu hình công cụ của PicoClaw nằm trong trường `tools` của `config.json`. + +## Cấu trúc thư mục + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Công cụ Web + +Các công cụ web được sử dụng để tìm kiếm và tải nội dung web. + +### Web Fetcher +Cài đặt chung để tải và xử lý nội dung trang web. + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Bật khả năng tải trang web. | +| `fetch_limit_bytes` | int | 10485760 | Kích thước tối đa của payload trang web cần tải, tính bằng byte (mặc định là 10MB). | +| `format` | string | "plaintext" | Định dạng đầu ra của nội dung đã tải. Tùy chọn: `plaintext` hoặc `markdown` (khuyến nghị). | + +### Brave + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|--------|----------|----------------------------| +| `enabled` | bool | false | Bật tìm kiếm Brave | +| `api_key` | string | - | Khóa API Brave Search | +| `max_results` | int | 5 | Số kết quả tối đa | + +### DuckDuckGo + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|------|----------|-------------------------------| +| `enabled` | bool | true | Bật tìm kiếm DuckDuckGo | +| `max_results` | int | 5 | Số kết quả tối đa | + +### Perplexity + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|--------|----------|-------------------------------| +| `enabled` | bool | false | Bật tìm kiếm Perplexity | +| `api_key` | string | - | Khóa API Perplexity | +| `max_results` | int | 5 | Số kết quả tối đa | + +## Công cụ Exec + +Công cụ exec được sử dụng để thực thi các lệnh shell. + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|--------------------------|-------|----------|------------------------------------------------| +| `enable_deny_patterns` | bool | true | Bật chặn lệnh nguy hiểm mặc định | +| `custom_deny_patterns` | array | [] | Mẫu từ chối tùy chỉnh (biểu thức chính quy) | + +### Chức năng + +- **`enable_deny_patterns`**: Đặt thành `false` để tắt hoàn toàn các mẫu chặn lệnh nguy hiểm mặc định +- **`custom_deny_patterns`**: Thêm các mẫu regex từ chối tùy chỉnh; các lệnh khớp sẽ bị chặn + +### Các mẫu lệnh bị chặn mặc định + +Theo mặc định, PicoClaw chặn các lệnh nguy hiểm sau: + +- Lệnh xóa: `rm -rf`, `del /f/q`, `rmdir /s` +- Thao tác đĩa: `format`, `mkfs`, `diskpart`, `dd if=`, ghi vào `/dev/sd*` +- Thao tác hệ thống: `shutdown`, `reboot`, `poweroff` +- Thay thế lệnh: `$()`, `${}`, dấu backtick +- Pipe đến shell: `| sh`, `| bash` +- Leo thang đặc quyền: `sudo`, `chmod`, `chown` +- Điều khiển tiến trình: `pkill`, `killall`, `kill -9` +- Thao tác từ xa: `curl | sh`, `wget | sh`, `ssh` +- Quản lý gói: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Container: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Khác: `eval`, `source *.sh` + +### Hạn chế kiến trúc đã biết + +Bộ bảo vệ exec chỉ xác thực lệnh cấp cao nhất được gửi đến PicoClaw. Nó **không** kiểm tra đệ quy các tiến trình con được tạo bởi các công cụ build hoặc script sau khi lệnh đó bắt đầu chạy. + +Ví dụ về các quy trình có thể bỏ qua bộ bảo vệ lệnh trực tiếp sau khi lệnh ban đầu được cho phép: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +Điều này có nghĩa là bộ bảo vệ hữu ích để chặn các lệnh trực tiếp rõ ràng nguy hiểm, nhưng nó **không phải** là sandbox đầy đủ cho các pipeline build chưa được xem xét. Nếu mô hình mối đe dọa của bạn bao gồm mã không đáng tin cậy trong workspace, hãy sử dụng cách ly mạnh hơn như container, VM hoặc quy trình phê duyệt xung quanh các lệnh build và chạy. + +### Ví dụ cấu hình + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Công cụ Cron + +Công cụ cron được sử dụng để lên lịch các tác vụ định kỳ. + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|--------------------------|------|----------|-----------------------------------------------------| +| `exec_timeout_minutes` | int | 5 | Thời gian chờ thực thi tính bằng phút, 0 nghĩa là không giới hạn | + +## Công cụ MCP + +Công cụ MCP cho phép tích hợp với các máy chủ Model Context Protocol bên ngoài. + +### Khám phá công cụ (tải chậm) + +Khi kết nối với nhiều máy chủ MCP, việc hiển thị hàng trăm công cụ cùng lúc có thể làm cạn kiệt cửa sổ ngữ cảnh của LLM và tăng chi phí API. Tính năng **Discovery** giải quyết vấn đề này bằng cách giữ các công cụ MCP *ẩn* theo mặc định. + +Thay vì tải tất cả các công cụ, LLM được cung cấp một công cụ tìm kiếm nhẹ (sử dụng khớp từ khóa BM25 hoặc Regex). Khi LLM cần một khả năng cụ thể, nó tìm kiếm trong thư viện ẩn. Các công cụ khớp sau đó được tạm thời "mở khóa" và đưa vào ngữ cảnh trong số lượt được cấu hình (`ttl`). + +### Cấu hình toàn cục + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|-------------|--------|----------|-----------------------------------------------| +| `enabled` | bool | false | Bật tích hợp MCP toàn cục | +| `discovery` | object | `{}` | Cấu hình khám phá công cụ (xem bên dưới) | +| `servers` | object | `{}` | Ánh xạ tên máy chủ đến cấu hình máy chủ | + +### Cấu hình Discovery (`discovery`) + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------------|------|----------|-----------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | Nếu true, các công cụ MCP bị ẩn và được tải theo yêu cầu qua tìm kiếm. Nếu false, tất cả công cụ được tải | +| `ttl` | int | 5 | Số lượt hội thoại mà một công cụ đã khám phá vẫn được mở khóa | +| `max_search_results` | int | 5 | Số công cụ tối đa được trả về cho mỗi truy vấn tìm kiếm | +| `use_bm25` | bool | true | Bật công cụ tìm kiếm ngôn ngữ tự nhiên/từ khóa (`tool_search_tool_bm25`). **Cảnh báo**: tiêu tốn nhiều tài nguyên hơn tìm kiếm regex | +| `use_regex` | bool | false | Bật công cụ tìm kiếm mẫu regex (`tool_search_tool_regex`) | + +> **Lưu ý:** Nếu `discovery.enabled` là `true`, bạn **phải** bật ít nhất một công cụ tìm kiếm (`use_bm25` hoặc `use_regex`), +> nếu không ứng dụng sẽ không khởi động được. + +### Cấu hình từng máy chủ + +| Cấu hình | Kiểu | Bắt buộc | Mô tả | +|------------|--------|----------|--------------------------------------------| +| `enabled` | bool | có | Bật máy chủ MCP này | +| `type` | string | không | Loại truyền tải: `stdio`, `sse`, `http` | +| `command` | string | stdio | Lệnh thực thi cho truyền tải stdio | +| `args` | array | không | Đối số lệnh cho truyền tải stdio | +| `env` | object | không | Biến môi trường cho tiến trình stdio | +| `env_file` | string | không | Đường dẫn đến tệp môi trường cho tiến trình stdio | +| `url` | string | sse/http | URL endpoint cho truyền tải `sse`/`http` | +| `headers` | object | không | Header HTTP cho truyền tải `sse`/`http` | + +### Hành vi truyền tải + +- Nếu bỏ qua `type`, truyền tải được tự động phát hiện: + - `url` được đặt → `sse` + - `command` được đặt → `stdio` +- `http` và `sse` đều sử dụng `url` + `headers` tùy chọn. +- `env` và `env_file` chỉ được áp dụng cho máy chủ `stdio`. + +### Ví dụ cấu hình + +#### 1) Máy chủ MCP Stdio + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) Máy chủ MCP từ xa SSE/HTTP + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) Thiết lập MCP quy mô lớn với khám phá công cụ được bật + +*Trong ví dụ này, LLM chỉ thấy `tool_search_tool_bm25`. Nó sẽ tìm kiếm và mở khóa động các công cụ Github hoặc Postgres chỉ khi được người dùng yêu cầu.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + +## Công cụ Skills + +Công cụ skills cấu hình khám phá và cài đặt kỹ năng thông qua các registry như ClawHub. + +### Registry + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|------------------------------------|--------|-----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Bật registry ClawHub | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL cơ sở ClawHub | +| `registries.clawhub.auth_token` | string | `""` | Token Bearer tùy chọn để có giới hạn tốc độ cao hơn | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Đường dẫn API tìm kiếm | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Đường dẫn API Skills | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Đường dẫn API tải xuống | + +### Ví dụ cấu hình + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## Biến môi trường + +Tất cả các tùy chọn cấu hình có thể được ghi đè qua biến môi trường với định dạng `PICOCLAW_TOOLS_
_`: + +Ví dụ: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +Lưu ý: Cấu hình kiểu map lồng nhau (ví dụ `tools.mcp.servers..*`) được cấu hình trong `config.json` thay vì qua biến môi trường. diff --git a/docs/vi/troubleshooting.md b/docs/vi/troubleshooting.md new file mode 100644 index 000000000..d74153aa3 --- /dev/null +++ b/docs/vi/troubleshooting.md @@ -0,0 +1,45 @@ +# 🐛 Khắc Phục Sự Cố + +> Quay lại [README](../../README.vi.md) + +## "model ... not found in model_list" hoặc OpenRouter "free is not a valid model ID" + +**Triệu chứng:** Bạn thấy một trong các lỗi sau: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter trả về 400: `"free is not a valid model ID"` + +**Nguyên nhân:** Trường `model` trong mục `model_list` của bạn là giá trị được gửi đến API. Đối với OpenRouter, bạn phải sử dụng ID mô hình **đầy đủ**, không phải dạng viết tắt. + +- **Sai:** `"model": "free"` → OpenRouter nhận được `free` và từ chối. +- **Đúng:** `"model": "openrouter/free"` → OpenRouter nhận được `openrouter/free` (định tuyến tự động tầng miễn phí). + +**Cách sửa:** Trong `~/.picoclaw/config.json` (hoặc đường dẫn cấu hình của bạn): + +1. **agents.defaults.model** phải khớp với một `model_name` trong `model_list` (ví dụ: `"openrouter-free"`). +2. **model** của mục đó phải là ID mô hình OpenRouter hợp lệ, ví dụ: + - `"openrouter/free"` – tầng miễn phí tự động + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Ví dụ: + +```json +{ + "agents": { + "defaults": { + "model": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Lấy khóa của bạn tại [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md new file mode 100644 index 000000000..4957fbcca --- /dev/null +++ b/docs/zh/chat-apps.md @@ -0,0 +1,574 @@ +# 💬 聊天应用配置 + +> 返回 [README](../../README.zh.md) + +## 💬 聊天应用集成 (Chat Apps) + +PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 + +> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。 + +### 核心渠道 + +| 渠道 | 设置难度 | 特性说明 | 文档链接 | +| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) | +| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) | +| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](../channels/whatsapp/README.zh.md) | +| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) | +| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) | +| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) | +| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](../channels/dingtalk/README.zh.md) | +| **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) | +| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) | +| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) | +| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | - | +| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) | +| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) | +| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | | + +--- + +
+Telegram(推荐) + +**1. 创建 Bot** + +* 打开 Telegram,搜索 `@BotFather` +* 发送 `/newbot`,按提示操作 +* 复制 Token + +**2. 配置** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> 通过 Telegram 上的 `@userinfobot` 获取你的 User ID。 + +**3. 运行** + +```bash +picoclaw gateway +``` + +**4. Telegram 命令菜单(启动时自动注册)** + +PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 +Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 + +如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 + +
+ +
+Discord + +**1. 创建 Bot** + +* 前往 +* 创建应用 → Bot → 添加 Bot +* 复制 Bot Token + +**2. 启用 Intents** + +* 在 Bot 设置中启用 **MESSAGE CONTENT INTENT** +* (可选)启用 **SERVER MEMBERS INTENT**(如需基于成员数据的白名单) + +**3. 获取 User ID** + +* Discord 设置 → 高级 → 启用 **开发者模式** +* 右键点击头像 → **复制用户 ID** + +**4. 配置** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. 邀请 Bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* 打开生成的邀请链接,将 Bot 添加到服务器 + +**可选:群组触发模式** + +默认情况下 Bot 会回复服务器频道中的所有消息。如需仅在 @提及时回复: + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +也可通过关键词前缀触发(如 `!bot`): + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. 运行** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp(原生 whatsmeow) + +PicoClaw 支持两种 WhatsApp 连接方式: + +- **原生(推荐):** 进程内使用 [whatsmeow](https://github.com/tulir/whatsmeow),无需独立 Bridge。设置 `"use_native": true` 并留空 `bridge_url`。首次运行时用 WhatsApp 扫描 QR 码(关联设备)。会话存储在工作区下(如 `workspace/whatsapp/`)。原生渠道为**可选**构建,使用 `-tags whatsapp_native` 编译(如 `make build-whatsapp-native` 或 `go build -tags whatsapp_native ./cmd/...`)。 +- **Bridge:** 连接外部 WebSocket Bridge。设置 `bridge_url`(如 `ws://localhost:3001`),保持 `use_native` 为 false。 + +**配置(原生)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +如果 `session_store_path` 为空,会话存储在 `/whatsapp/`。运行 `picoclaw gateway`;首次运行时在终端扫描 QR 码(WhatsApp → 关联设备)。 + +
+ +
+Matrix + +**1. 准备 Bot 账号** + +* 使用你的 homeserver(如 `https://matrix.org` 或自建) +* 创建 Bot 用户并获取 access token + +**2. 配置** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 运行** + +```bash +picoclaw gateway +``` + +完整选项(`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`)请参考 [Matrix 渠道配置指南](../channels/matrix/README.md)。 + +
+ +
+QQ + +**1. 创建 Bot** + +- 前往 [QQ 开放平台](https://q.qq.com/#) +- 创建应用 → 获取 **AppID** 和 **AppSecret** + +**2. 配置** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` 留空表示允许所有用户,或指定 QQ 号限制访问。 + +**3. 运行** + +```bash +picoclaw gateway +``` + +
+ +
+Slack + +**1. 创建 Slack App** + +* 前往 [Slack API](https://api.slack.com/apps) 创建应用 +* 启用 **Socket Mode** +* 获取 **Bot Token** 和 **App-Level Token** + +**2. 配置** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-YOUR_BOT_TOKEN", + "app_token": "xapp-YOUR_APP_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 运行** + +```bash +picoclaw gateway +``` + +
+ +
+IRC + +**1. 配置** + +```json +{ + "channels": { + "irc": { + "enabled": true, + "server": "irc.libera.chat:6697", + "nick": "picoclaw-bot", + "use_tls": true, + "channels_to_join": ["#your-channel"], + "allow_from": [] + } + } +} +``` + +**2. 运行** + +```bash +picoclaw gateway +``` + +
+ +
+钉钉 (DingTalk) + +**1. 创建 Bot** + +* 前往 [开放平台](https://open.dingtalk.com/) +* 创建内部应用 +* 复制 Client ID 和 Client Secret + +**2. 配置** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` 留空表示允许所有用户,或指定钉钉用户 ID 限制访问。 + +**3. 运行** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. 创建 LINE Official Account** + +- 前往 [LINE Developers Console](https://developers.line.biz/) +- 创建 Provider → 创建 Messaging API Channel +- 复制 **Channel Secret** 和 **Channel Access Token** + +**2. 配置** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> LINE Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。 + +**3. 设置 Webhook URL** + +LINE 要求 HTTPS Webhook。使用反向代理或隧道: + +```bash +# 示例:使用 ngrok(Gateway 默认端口 18790) +ngrok http 18790 +``` + +然后在 LINE Developers Console 中将 Webhook URL 设置为 `https://your-domain/webhook/line` 并启用 **Use webhook**。 + +**4. 运行** + +```bash +picoclaw gateway +``` + +> 在群聊中,Bot 仅在被 @提及时回复。回复会引用原始消息。 + +
+ +
+飞书 (Feishu) + +**1. 创建应用** + +* 前往 [飞书开放平台](https://open.feishu.cn/) +* 创建企业自建应用 +* 获取 **App ID** 和 **App Secret** + +**2. 配置** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +**3. 运行** + +```bash +picoclaw gateway +``` + +
+ +
+企业微信 (WeCom) + +PicoClaw 支持三种企业微信集成方式: + +**方式 1: 群机器人 (Bot)** — 设置简单,支持群聊 +**方式 2: 自建应用 (App)** — 功能更多,支持主动推送,仅私聊 +**方式 3: 智能机器人 (AI Bot)** — 官方 AI Bot,流式回复,支持群聊和私聊 + +详细设置请参考 [企业微信 AI Bot 配置指南](../channels/wecom/wecom_aibot/README.zh.md)。 + +**快速设置 — 群机器人:** + +**1. 创建 Bot** + +* 企业微信管理后台 → 群聊 → 添加群机器人 +* 复制 Webhook URL(格式:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. 配置** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> WeCom Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。 + +**快速设置 — 自建应用:** + +**1. 创建应用** + +* 企业微信管理后台 → 应用管理 → 创建应用 +* 复制 **AgentId** 和 **Secret** +* 前往"我的企业"页面,复制 **CorpID** + +**2. 配置接收消息** + +* 在应用详情中,点击"接收消息" → "设置 API" +* 设置 URL 为 `http://your-server:18790/webhook/wecom-app` +* 生成 **Token** 和 **EncodingAESKey** + +**3. 配置** + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. 运行** + +```bash +picoclaw gateway +``` + +> **注意**: WeCom Webhook 回调挂载在 Gateway 端口(默认 18790)。使用反向代理配置 HTTPS。 + +**快速设置 — 智能机器人 (AI Bot):** + +**1. 创建 AI Bot** + +* 企业微信管理后台 → 应用管理 → AI Bot +* 在 AI Bot 设置中配置回调 URL:`http://your-server:18791/webhook/wecom-aibot` +* 复制 **Token** 并点击"随机生成" **EncodingAESKey** + +**2. 配置** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮你的?" + } + } +} +``` + +**3. 运行** + +```bash +picoclaw gateway +``` + +> **注意**: 企业微信 AI Bot 使用流式拉取协议,无回复超时问题。长任务(>30 秒)会自动切换到 `response_url` 推送投递。 + +
+ +
+OneBot + +**1. 配置** + +兼容 NapCat / Go-CQHTTP 等 OneBot 实现。 + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "allow_from": [] + } + } +} +``` + +**2. 运行** + +```bash +picoclaw gateway +``` + +
+ +
+MaixCam + +专为 Sipeed AI 摄像头硬件设计的集成通道。 + +```json +{ + "channels": { + "maixcam": { + "enabled": true + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md new file mode 100644 index 000000000..d3f810208 --- /dev/null +++ b/docs/zh/configuration.md @@ -0,0 +1,256 @@ +# ⚙️ 配置指南 + +> 返回 [README](../../README.zh.md) + +## ⚙️ 配置详解 + +配置文件路径: `~/.picoclaw/config.json` + +### 环境变量 + +你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。 + +| 变量 | 描述 | 默认路径 | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` | + +**示例:** + +```bash +# 使用特定的配置文件运行 picoclaw +# 工作区路径将从该配置文件中读取 +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# 在 /opt/picoclaw 中存储所有数据运行 picoclaw +# 配置将从默认的 ~/.picoclaw/config.json 加载 +# 工作区将在 /opt/picoclaw/workspace 创建 +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# 同时使用两者进行完全自定义设置 +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### 工作区布局 (Workspace Layout) + +PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # 对话会话和历史 +├── memory/ # 长期记忆 (MEMORY.md) +├── state/ # 持久化状态 (最后一次频道等) +├── cron/ # 定时任务数据库 +├── skills/ # 自定义技能 +├── AGENTS.md # Agent 行为指南 +├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) +├── IDENTITY.md # Agent 身份设定 +├── SOUL.md # Agent 灵魂/性格 +└── USER.md # 用户偏好 +``` + +### 技能来源 (Skill Sources) + +默认情况下,技能会按以下顺序加载: + +1. `~/.picoclaw/workspace/skills`(工作区) +2. `~/.picoclaw/skills`(全局) +3. `/skills`(内置) + +在高级/测试场景下,可通过以下环境变量覆盖内置技能目录: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### 统一命令执行策略 + +- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 +- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。 +- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 +- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 + +### 🔒 安全沙箱 (Security Sandbox) + +PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区内的文件和执行命令。 + +#### 默认配置 + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| 选项 | 默认值 | 描述 | +| ----------------------- | ----------------------- | ----------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Agent 的工作目录 | +| `restrict_to_workspace` | `true` | 限制文件/命令访问在工作区内 | + +#### 受保护的工具 + +当 `restrict_to_workspace: true` 时,以下工具会被沙箱化: + +| 工具 | 功能 | 限制 | +| ------------- | ------------ | ------------------------------ | +| `read_file` | 读取文件 | 仅限工作区内的文件 | +| `write_file` | 写入文件 | 仅限工作区内的文件 | +| `list_dir` | 列出目录 | 仅限工作区内的目录 | +| `edit_file` | 编辑文件 | 仅限工作区内的文件 | +| `append_file` | 追加文件 | 仅限工作区内的文件 | +| `exec` | 执行命令 | 命令路径必须在工作区内 | + +#### 额外的 Exec 保护 + +即使 `restrict_to_workspace: false`,`exec` 工具也会阻止以下危险命令: + +* `rm -rf`、`del /f`、`rmdir /s` — 批量删除 +* `format`、`mkfs`、`diskpart` — 磁盘格式化 +* `dd if=` — 磁盘镜像 +* 写入 `/dev/sd[a-z]` — 直接磁盘写入 +* `shutdown`、`reboot`、`poweroff` — 系统关机 +* Fork bomb `:(){ :|:& };:` + +### 文件访问控制 + +| 配置键 | 类型 | 默认值 | 描述 | +|--------|------|--------|------| +| `tools.allow_read_paths` | string[] | `[]` | 允许在工作区外读取的额外路径 | +| `tools.allow_write_paths` | string[] | `[]` | 允许在工作区外写入的额外路径 | + +### Exec 安全配置 + +| 配置键 | 类型 | 默认值 | 描述 | +|--------|------|--------|------| +| `tools.exec.allow_remote` | bool | `false` | 允许从远程渠道(Telegram/Discord 等)执行 exec 工具 | +| `tools.exec.enable_deny_patterns` | bool | `true` | 启用危险命令拦截 | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | 自定义阻止的正则表达式模式 | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | 自定义允许的正则表达式模式 | + +> **安全提示:** Symlink 保护默认启用——所有文件路径在白名单匹配前都会通过 `filepath.EvalSymlinks` 解析,防止符号链接逃逸攻击。 + +#### 已知限制:构建工具的子进程 + +exec 安全守卫仅检查 PicoClaw 直接启动的命令行。它不会递归检查由 `make`、`go run`、`cargo`、`npm run` 或自定义构建脚本等开发工具产生的子进程。 + +这意味着顶层命令通过初始守卫检查后,仍可以编译或启动其他二进制文件。实际上,应将构建脚本、Makefile、包脚本和生成的二进制文件视为与直接 shell 命令同等级别的可执行代码进行审查。 + +对于高风险环境: + +* 执行前审查构建脚本。 +* 对编译并运行的工作流优先使用审批/手动审查。 +* 如果需要比内置守卫更强的隔离,请在容器或虚拟机中运行 PicoClaw。 + +#### 错误示例 + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### 禁用限制(安全风险) + +如果需要 Agent 访问工作区外的路径: + +**方法 1: 配置文件** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**方法 2: 环境变量** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **警告**: 禁用此限制将允许 Agent 访问系统上的任何路径。仅在受控环境中谨慎使用。 + +#### 安全边界一致性 + +`restrict_to_workspace` 设置在所有执行路径中一致应用: + +| 执行路径 | 安全边界 | +| ---------------- | ---------------------------- | +| 主 Agent | `restrict_to_workspace` ✅ | +| 子 Agent / Spawn | 继承相同限制 ✅ | +| 心跳任务 | 继承相同限制 ✅ | + +所有路径共享相同的工作区限制——无法通过子 Agent 或定时任务绕过安全边界。 + +### 心跳 / 周期性任务 (Heartbeat) + +PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 + +#### 使用 Spawn 的异步任务 + +对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**关键行为:** + +| 特性 | 描述 | +| ---------------- | ---------------------------------------- | +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | + +**配置:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| 选项 | 默认值 | 描述 | +| ---------- | ------ | ---------------------------- | +| `enabled` | `true` | 启用/禁用心跳 | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | + +**环境变量:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 diff --git a/docs/zh/docker.md b/docs/zh/docker.md new file mode 100644 index 000000000..d2e582d12 --- /dev/null +++ b/docs/zh/docker.md @@ -0,0 +1,168 @@ +# 🐳 Docker 与快速开始 + +> 返回 [README](../../README.zh.md) + +## 🐳 Docker Compose + +您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 + +```bash +# 1. 克隆仓库 +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 首次运行 — 自动生成 docker/data/config.json 后退出 +docker compose -f docker/docker-compose.yml --profile gateway up +# 容器打印 "First-run setup complete." 后自动停止 + +# 3. 填写 API Key 等配置 +vim docker/data/config.json # 设置 provider API key、Bot Token 等 + +# 4. 正式启动 +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 + +```bash +# 5. 查看日志 +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. 停止 +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher 模式 (Web 控制台) + +`launcher` 镜像包含所有三个二进制文件(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。 + +> [!WARNING] +> Web 控制台尚不支持身份验证。请勿将其暴露到公网。 + +### Agent 模式 (一次性运行) + +```bash +# 提问 +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?" + +# 交互模式 +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### 更新镜像 + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +--- + +## 🚀 快速开始 + +> [!TIP] +> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。 + +**1. 初始化 (Initialize)** + +```bash +picoclaw onboard +``` + +**2. 配置 (Configure)** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "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", + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "your-anthropic-key" + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.md#模型配置-model_list)章节。 +> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 + +**3. 获取 API Key** + +* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **网络搜索** (可选): + * [Brave Search](https://brave.com/search/api) - 付费 ($5/1000 次查询,约 $5-6/月) + * [Perplexity](https://www.perplexity.ai) - AI 驱动的搜索与聊天界面 + * [SearXNG](https://github.com/searxng/searxng) - 自建元搜索引擎(免费,无需 API Key) + * [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) + * DuckDuckGo - 内置回退(无需 API Key) + +> **注意**: 完整的配置模板请参考 `config.example.json`。 + +**4. 对话 (Chat)** + +```bash +picoclaw agent -m "2+2 等于几?" +``` + +就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 + +--- diff --git a/docs/zh/providers.md b/docs/zh/providers.md new file mode 100644 index 000000000..5b7a4cc2a --- /dev/null +++ b/docs/zh/providers.md @@ -0,0 +1,428 @@ +# 🔌 提供商与模型配置 + +> 返回 [README](../../README.zh.md) + +### 提供商 (Providers) + +> [!NOTE] +> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。 + +| 提供商 | 用途 | 获取 API Key | +| -------------------- | ---------------------------- | -------------------------------------------------------------------- | +| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (智谱直连) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid 直连) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot 直连) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax 直连) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian 直连) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral 直连) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat 直连) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) | + +### 模型配置 (model_list) + +> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** + +该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: + +- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider +- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性 +- **负载均衡**:在多个 API 端点之间分配请求 +- **集中化配置**:在一个地方管理所有 provider + +#### 📋 所有支持的厂商 + +| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | +| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | +| **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) | +| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | + +#### 基础配置示例 + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-openai-key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key" + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-zhipu-key" + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +#### 各厂商配置示例 + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**火山引擎(Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-..." +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_key": "sk-..." +} +``` + +**Anthropic (使用 OAuth)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "auth_method": "oauth" +} +``` + +> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 + +**Anthropic Messages API(原生格式)** + +用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com" +} +``` + +> 使用 `anthropic-messages` 协议的场景: +> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`) +> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务 +> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式) +> +> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。 + +**Ollama (本地)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**自定义代理/API** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_key": "sk-...", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_key": "sk-..." +} +``` + +PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/lite-gpt4` 会发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 会发送 `openai/gpt-4o`。 + +#### 负载均衡 + +为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_key": "sk-key1" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_key": "sk-key2" + } + ] +} +``` + +#### 从旧的 `providers` 配置迁移 + +旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 + +**旧配置(已弃用):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**新配置(推荐):** + +```json +{ + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "glm-4.7" + } + } +} +``` + +详细的迁移指南请参考 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。 + +### Provider 架构 + +PicoClaw 按协议族路由 Provider: + +- OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 +- Anthropic 协议:Claude 原生 API 行为。 +- Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。 + +这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。 + +
+智谱 (Zhipu) 配置示例 + +**1. 获取 API key 和 base URL** + +- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. 配置** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. 运行** + +```bash +picoclaw agent -m "你好" +``` + +
+ +
+完整配置示例 + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 API Key 对比 + +| 服务 | 价格 | 适用场景 | +| --- | --- | --- | +| **OpenRouter** | 免费: 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | +| **火山引擎 CodingPlan** | ¥9.9/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) | +| **智谱 (Zhipu)** | 免费: 200K tokens/月 | 适合中国用户 | +| **Brave Search** | $5/1000 次查询 | 网络搜索功能 | +| **SearXNG** | 免费(自建) | 隐私优先的元搜索引擎(70+ 搜索引擎) | +| **Groq** | 免费额度可用 | 极速推理 (Llama, Mixtral) | +| **Cerebras** | 免费额度可用 | 极速推理 (Llama, Qwen 等) | +| **LongCat** | 免费: 最多 5M tokens/天 | 极速推理 | +| **ModelScope (魔搭)** | 免费: 2000 次请求/天 | 推理 (Qwen, GLM, DeepSeek 等) | diff --git a/docs/zh/spawn-tasks.md b/docs/zh/spawn-tasks.md new file mode 100644 index 000000000..c6721fceb --- /dev/null +++ b/docs/zh/spawn-tasks.md @@ -0,0 +1,68 @@ +# 🔄 异步任务与 Spawn + +> 返回 [README](../../README.zh.md) + +### 使用 Spawn 的异步任务 + +对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**关键行为:** + +| 特性 | 描述 | +| ---------------- | ---------------------------------------- | +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | + +#### 子 Agent 通信原理 + +``` +心跳触发 (Heartbeat triggers) + ↓ +Agent 读取 HEARTBEAT.md + ↓ +对于长任务: spawn 子 Agent + ↓ ↓ +继续下一个任务 子 Agent 独立工作 + ↓ ↓ +所有任务完成 子 Agent 使用 "message" 工具 + ↓ ↓ +响应 HEARTBEAT_OK 用户直接收到结果 +``` + +子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 + +**配置:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| 选项 | 默认值 | 描述 | +| ---------- | ------ | ---------------------------- | +| `enabled` | `true` | 启用/禁用心跳 | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | + +**环境变量:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 diff --git a/docs/zh/tools_configuration.md b/docs/zh/tools_configuration.md new file mode 100644 index 000000000..ff88b6707 --- /dev/null +++ b/docs/zh/tools_configuration.md @@ -0,0 +1,336 @@ +# 🔧 工具配置 + +> 返回 [README](../../README.zh.md) + +PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 + +## 目录结构 + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Web 工具 + +Web 工具用于网页搜索和抓取。 + +### Web Fetcher +用于抓取和处理网页内容的通用设置。 + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------------|--------|---------------|----------------------------------------------------------------------------------------| +| `enabled` | bool | true | 启用网页抓取功能。 | +| `fetch_limit_bytes` | int | 10485760 | 抓取网页负载的最大大小,单位为字节(默认 10MB)。 | +| `format` | string | "plaintext" | 抓取内容的输出格式。选项:`plaintext` 或 `markdown`(推荐)。 | + +### Brave + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|--------|--------|--------------------| +| `enabled` | bool | false | 启用 Brave 搜索 | +| `api_key` | string | - | Brave Search API 密钥 | +| `max_results` | int | 5 | 最大结果数 | + +### DuckDuckGo + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|------|--------|-----------------------| +| `enabled` | bool | true | 启用 DuckDuckGo 搜索 | +| `max_results` | int | 5 | 最大结果数 | + +### Perplexity + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|--------|--------|-----------------------| +| `enabled` | bool | false | 启用 Perplexity 搜索 | +| `api_key` | string | - | Perplexity API 密钥 | +| `max_results` | int | 5 | 最大结果数 | + +## Exec 工具 + +Exec 工具用于执行 shell 命令。 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------------|-------|--------|--------------------------------| +| `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 | +| `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) | + +### 功能说明 + +- **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式 +- **`custom_deny_patterns`**:添加自定义拒绝正则模式;匹配的命令将被拦截 + +### 默认拦截的命令模式 + +默认情况下,PicoClaw 会拦截以下危险命令: + +- 删除命令:`rm -rf`、`del /f/q`、`rmdir /s` +- 磁盘操作:`format`、`mkfs`、`diskpart`、`dd if=`、写入 `/dev/sd*` +- 系统操作:`shutdown`、`reboot`、`poweroff` +- 命令替换:`$()`、`${}`、反引号 +- 管道到 shell:`| sh`、`| bash` +- 权限提升:`sudo`、`chmod`、`chown` +- 进程控制:`pkill`、`killall`、`kill -9` +- 远程操作:`curl | sh`、`wget | sh`、`ssh` +- 包管理:`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user` +- 容器:`docker run`、`docker exec` +- Git:`git push`、`git force` +- 其他:`eval`、`source *.sh` + +### 已知架构限制 + +exec 守卫仅验证发送给 PicoClaw 的顶层命令。它**不会**递归检查该命令启动后由构建工具或脚本生成的子进程。 + +以下工作流在初始命令被允许后可以绕过直接命令守卫: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +这意味着守卫对于拦截明显危险的直接命令很有用,但它**不是**未审查构建管道的完整沙箱。如果你的威胁模型包括工作区中的不受信任代码,请使用更强的隔离措施,如容器、虚拟机或围绕构建和运行命令的审批流程。 + +### 配置示例 + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Cron 工具 + +Cron 工具用于调度周期性任务。 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------------|------|--------|-------------------------------------| +| `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 | + +## MCP 工具 + +MCP 工具支持与外部 Model Context Protocol 服务器集成。 + +### 工具发现(延迟加载) + +当连接多个 MCP 服务器时,同时暴露数百个工具可能会耗尽 LLM 的上下文窗口并增加 API 成本。**Discovery** 功能通过默认*隐藏* MCP 工具来解决此问题。 + +LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用 BM25 关键词匹配或正则表达式)。当 LLM 需要特定功能时,它会搜索隐藏的工具库。匹配的工具随后被临时"解锁"并注入上下文中,持续配置的轮数(`ttl`)。 + +### 全局配置 + +| 配置项 | 类型 | 默认值 | 描述 | +|-------------|--------|--------|--------------------------------------| +| `enabled` | bool | false | 全局启用 MCP 集成 | +| `discovery` | object | `{}` | 工具发现配置(见下文) | +| `servers` | object | `{}` | 服务器名称到服务器配置的映射 | + +### Discovery 配置(`discovery`) + +| 配置项 | 类型 | 默认值 | 描述 | +|----------------------|------|--------|---------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | 如果为 true,MCP 工具将被隐藏并按需通过搜索加载。如果为 false,所有工具都会被加载 | +| `ttl` | int | 5 | 已发现工具保持解锁状态的对话轮数 | +| `max_search_results` | int | 5 | 每次搜索查询返回的最大工具数 | +| `use_bm25` | bool | true | 启用自然语言/关键词搜索工具(`tool_search_tool_bm25`)。**警告**:比正则搜索消耗更多资源 | +| `use_regex` | bool | false | 启用正则模式搜索工具(`tool_search_tool_regex`) | + +> **注意:** 如果 `discovery.enabled` 为 `true`,你**必须**启用至少一个搜索引擎(`use_bm25` 或 `use_regex`), +> 否则应用程序将无法启动。 + +### 单服务器配置 + +| 配置项 | 类型 | 必需 | 描述 | +|------------|--------|----------|------------------------------------| +| `enabled` | bool | 是 | 启用此 MCP 服务器 | +| `type` | string | 否 | 传输类型:`stdio`、`sse`、`http` | +| `command` | string | stdio | stdio 传输的可执行命令 | +| `args` | array | 否 | stdio 传输的命令参数 | +| `env` | object | 否 | stdio 进程的环境变量 | +| `env_file` | string | 否 | stdio 进程的环境文件路径 | +| `url` | string | sse/http | `sse`/`http` 传输的端点 URL | +| `headers` | object | 否 | `sse`/`http` 传输的 HTTP 头 | + +### 传输行为 + +- 如果省略 `type`,传输方式将自动检测: + - 设置了 `url` → `sse` + - 设置了 `command` → `stdio` +- `http` 和 `sse` 都使用 `url` + 可选的 `headers`。 +- `env` 和 `env_file` 仅应用于 `stdio` 服务器。 + +### 配置示例 + +#### 1) Stdio MCP 服务器 + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) 远程 SSE/HTTP MCP 服务器 + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) 启用工具发现的大规模 MCP 设置 + +*在此示例中,LLM 只会看到 `tool_search_tool_bm25`。它将仅在用户请求时动态搜索并解锁 Github 或 Postgres 工具。* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + +## Skills 工具 + +Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 + +### 注册表 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------------------------|--------|----------------------|--------------------------------------| +| `registries.clawhub.enabled` | bool | true | 启用 ClawHub 注册表 | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub 基础 URL | +| `registries.clawhub.auth_token` | string | `""` | 可选的 Bearer 令牌,用于更高速率限制 | +| `registries.clawhub.search_path` | string | `/api/v1/search` | 搜索 API 路径 | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API 路径 | +| `registries.clawhub.download_path` | string | `/api/v1/download` | 下载 API 路径 | + +### 配置示例 + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## 环境变量 + +所有配置选项都可以通过格式为 `PICOCLAW_TOOLS_
_` 的环境变量覆盖: + +例如: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +注意:嵌套的映射式配置(例如 `tools.mcp.servers..*`)在 `config.json` 中配置,而非通过环境变量。 diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md new file mode 100644 index 000000000..a3329ee35 --- /dev/null +++ b/docs/zh/troubleshooting.md @@ -0,0 +1,45 @@ +# 🐛 疑难解答 + +> 返回 [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** 必须匹配 `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": "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 5f0b6cadc..ac288e56d 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,11 @@ module github.com/sipeed/picoclaw go 1.25.7 require ( + fyne.io/systray v1.12.0 github.com/adhocore/gronx v1.19.6 - github.com/anthropics/anthropic-sdk-go v1.22.1 + github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/bwmarrin/discordgo v0.29.0 - github.com/caarlos0/env/v11 v11.3.1 + github.com/caarlos0/env/v11 v11.4.0 github.com/ergochat/irc-go v0.5.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 @@ -17,7 +18,7 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.3.1 - github.com/mymmrac/telego v1.6.0 + github.com/mymmrac/telego v1.7.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 @@ -26,7 +27,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/term v0.40.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -42,6 +44,7 @@ require ( 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/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 @@ -59,7 +62,6 @@ require ( go.mau.fi/libsignal v0.2.1 // indirect go.mau.fi/util v0.9.6 // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect @@ -73,7 +75,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/github/copilot-sdk/go v0.1.23 + github.com/github/copilot-sdk/go v0.1.32 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 @@ -87,10 +89,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect - github.com/valyala/fastjson v1.6.7 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.48.0 golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index addbab56c..f0e3fc132 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= @@ -11,8 +13,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= -github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/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= @@ -23,8 +25,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= -github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= +github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -38,6 +40,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= @@ -52,8 +56,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= -github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= +github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo= +github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= 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= @@ -62,6 +66,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -136,8 +142,8 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= -github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= -github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= +github.com/mymmrac/telego v1.7.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= @@ -216,8 +222,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= -github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= -github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -270,8 +276,8 @@ golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -354,6 +360,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 3eb6e2a02..3e70625e1 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -54,7 +54,7 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil } func getGlobalConfigDir() string { - if home := os.Getenv("PICOCLAW_HOME"); home != "" { + if home := os.Getenv(config.EnvHome); home != "" { return home } home, err := os.UserHomeDir() @@ -67,7 +67,7 @@ func getGlobalConfigDir() string { func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project // Use the skills/ directory under the current working directory - builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS")) + builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills)) if builtinSkillsDir == "" { wd, _ := os.Getwd() builtinSkillsDir = filepath.Join(wd, "skills") @@ -597,7 +597,23 @@ func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo { // // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching -func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { +func formatCurrentSenderLine(senderID, senderDisplayName string) string { + senderID = strings.TrimSpace(senderID) + senderDisplayName = strings.TrimSpace(senderDisplayName) + + switch { + case senderDisplayName != "" && senderID != "": + return fmt.Sprintf("Current sender: %s (ID: %s)", senderDisplayName, senderID) + case senderDisplayName != "": + return fmt.Sprintf("Current sender: %s", senderDisplayName) + case senderID != "": + return fmt.Sprintf("Current sender: %s", senderID) + default: + return "" + } +} + +func (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) @@ -607,6 +623,9 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { if channel != "" && chatID != "" { fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) } + if senderLine := formatCurrentSenderLine(senderID, senderDisplayName); senderLine != "" { + fmt.Fprintf(&sb, "\n\n## Current Sender\n%s", senderLine) + } return sb.String() } @@ -616,7 +635,7 @@ func (cb *ContextBuilder) BuildMessages( summary string, currentMessage string, media []string, - channel, chatID string, + channel, chatID, senderID, senderDisplayName string, ) []providers.Message { messages := []providers.Message{} @@ -632,7 +651,7 @@ func (cb *ContextBuilder) BuildMessages( staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID) + dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 707510820..c26976c3c 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1") + msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "") systemCount := 0 for _, m := range msgs { @@ -126,6 +126,68 @@ func TestSingleSystemMessage(t *testing.T) { } } +func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "IDENTITY.md": "# Identity\nTest agent.", + }) + defer os.RemoveAll(tmpDir) + + cb := NewContextBuilder(tmpDir) + + tests := []struct { + name string + senderID string + senderDisplayName string + wantLine string + wantSection bool + }{ + { + name: "both id and display name", + senderID: "feishu:ou_xxx", + senderDisplayName: "Zhang San", + wantLine: "Current sender: Zhang San (ID: feishu:ou_xxx)", + wantSection: true, + }, + { + name: "display name only", + senderDisplayName: "Alice", + wantLine: "Current sender: Alice", + wantSection: true, + }, + { + name: "id only", + senderID: "discord:123", + wantLine: "Current sender: discord:123", + wantSection: true, + }, + { + name: "no sender info", + wantSection: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName) + sys := msgs[0].Content + + if tt.wantSection { + if !strings.Contains(sys, "## Current Sender") { + t.Fatalf("system prompt missing Current Sender section:\n%s", sys) + } + if !strings.Contains(sys, tt.wantLine) { + t.Fatalf("system prompt missing sender line %q:\n%s", tt.wantLine, sys) + } + return + } + + if strings.Contains(sys, "## Current Sender") { + t.Fatalf("system prompt should omit Current Sender section:\n%s", sys) + } + }) + } +} + // TestMtimeAutoInvalidation verifies that the cache detects source file changes // via mtime without requiring explicit InvalidateCache(). // Fix: original implementation had no auto-invalidation — edits to bootstrap files, @@ -576,7 +638,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { } // Also exercise BuildMessages concurrently - msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat") + msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "") if len(msgs) < 2 { errs <- "BuildMessages returned fewer than 2 messages" return @@ -664,6 +726,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test") + _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "") } } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 3bd0fa7e0..a1605d371 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/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" @@ -79,7 +80,7 @@ func NewAgentInstance( readRestrict := restrict && !defaults.AllowReadOutsideWorkspace // Compile path whitelist patterns from config. - allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths) + allowReadPaths := buildAllowReadPatterns(cfg) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) toolsRegistry := tools.NewToolRegistry() @@ -95,7 +96,7 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) } if cfg.Tools.IsToolEnabled("exec") { - execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) + execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths) if err != nil { log.Fatalf("Critical error: unable to initialize exec tool: %v", err) } @@ -339,6 +340,29 @@ func compilePatterns(patterns []string) []*regexp.Regexp { return compiled } +func buildAllowReadPatterns(cfg *config.Config) []*regexp.Regexp { + var configured []string + if cfg != nil { + configured = cfg.Tools.AllowReadPaths + } + + compiled := compilePatterns(configured) + mediaDirPattern := regexp.MustCompile(mediaTempDirPattern()) + for _, pattern := range compiled { + if pattern.String() == mediaDirPattern.String() { + return compiled + } + } + + return append(compiled, mediaDirPattern) +} + +func mediaTempDirPattern() string { + sep := regexp.QuoteMeta(string(os.PathSeparator)) + return "^" + regexp.QuoteMeta(filepath.Clean(media.TempDir())) + "(?:" + sep + "|$)" +} + + func expandHome(path string) string { if path == "" { return path diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 4f41ecd1c..5a13c8f1b 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -1,10 +1,14 @@ package agent import ( + "context" "os" + "path/filepath" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { @@ -160,3 +164,85 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { }) } } + +func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { + workspace := t.TempDir() + mediaDir := media.TempDir() + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + t.Fatalf("MkdirAll(mediaDir) error = %v", err) + } + + mediaFile, err := os.CreateTemp(mediaDir, "instance-tool-*.txt") + if err != nil { + t.Fatalf("CreateTemp(mediaDir) error = %v", err) + } + mediaPath := mediaFile.Name() + if _, err := mediaFile.WriteString("attachment content"); err != nil { + mediaFile.Close() + t.Fatalf("WriteString(mediaFile) error = %v", err) + } + if err := mediaFile.Close(); err != nil { + t.Fatalf("Close(mediaFile) error = %v", err) + } + t.Cleanup(func() { _ = os.Remove(mediaPath) }) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + RestrictToWorkspace: true, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + ListDir: config.ToolConfig{Enabled: true}, + Exec: config.ExecConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + EnableDenyPatterns: true, + AllowRemote: true, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + readTool, ok := agent.Tools.Get("read_file") + if !ok { + t.Fatal("read_file tool not registered") + } + readResult := readTool.Execute(context.Background(), map[string]any{"path": mediaPath}) + if readResult.IsError { + t.Fatalf("read_file should allow media temp dir, got: %s", readResult.ForLLM) + } + if !strings.Contains(readResult.ForLLM, "attachment content") { + t.Fatalf("read_file output missing media content: %s", readResult.ForLLM) + } + + listTool, ok := agent.Tools.Get("list_dir") + if !ok { + t.Fatal("list_dir tool not registered") + } + listResult := listTool.Execute(context.Background(), map[string]any{"path": mediaDir}) + if listResult.IsError { + t.Fatalf("list_dir should allow media temp dir, got: %s", listResult.ForLLM) + } + if !strings.Contains(listResult.ForLLM, filepath.Base(mediaPath)) { + t.Fatalf("list_dir output missing media file: %s", listResult.ForLLM) + } + + execTool, ok := agent.Tools.Get("exec") + if !ok { + t.Fatal("exec tool not registered") + } + execResult := execTool.Execute(context.Background(), map[string]any{ + "command": "cat " + filepath.Base(mediaPath), + "working_dir": mediaDir, + }) + if execResult.IsError { + t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM) + } + if !strings.Contains(execResult.ForLLM, "attachment content") { + t.Fatalf("exec output missing media content: %s", execResult.ForLLM) + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 512c93f5c..3e4ae6b7b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -82,6 +82,10 @@ type processOptions struct { 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) Media []string // media:// refs from inbound message @@ -170,6 +174,8 @@ func registerSharedTools( provider providers.LLMProvider, al *AgentLoop, ) { + allowReadPaths := buildAllowReadPatterns(cfg) + for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) if !ok { @@ -224,7 +230,12 @@ func registerSharedTools( } if cfg.Tools.IsToolEnabled("web_fetch") { - fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) + 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{ "agent_id": agentID, @@ -271,6 +282,7 @@ func registerSharedTools( cfg.Agents.Defaults.RestrictToWorkspace, cfg.Agents.Defaults.GetMaxMediaSize(), nil, + allowReadPaths, ) agent.Tools.Register(sendFileTool) } @@ -1064,6 +1076,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) ChatID: msg.ChatID, + SenderID: msg.SenderID, + + SenderDisplayName: msg.Sender.DisplayName, + UserMessage: msg.Content, Media: msg.Media, @@ -1145,6 +1161,7 @@ func (al *AgentLoop) callLLMWithRetry( ch, sErr := sp.ChatStream(streamCtx, *messages, toolDefs, model, llmOpts) if sErr != nil { return nil, sErr + } resp, repetition, sErr := consumeStreamWithRepetitionDetection(ch, streamCancel, 1000, onChunk) if sErr != nil { @@ -1243,8 +1260,10 @@ func (al *AgentLoop) callLLMWithRetry( *messages = agent.ContextBuilder.BuildMessages( newHistory, newSummary, "", nil, opts.Channel, opts.ChatID, + opts.SenderID, opts.SenderDisplayName, ) continue + } break } @@ -1356,6 +1375,29 @@ func (al *AgentLoop) publishToolMedia(ctx context.Context, result *tools.ToolRes }) } + +// 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 { diff --git a/pkg/agent/loop_run.go b/pkg/agent/loop_run.go index 8e0a82620..5f5dfe88d 100644 --- a/pkg/agent/loop_run.go +++ b/pkg/agent/loop_run.go @@ -236,11 +236,11 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt history, summary, opts.UserMessage, - opts.Media, - opts.Channel, opts.ChatID, + opts.SenderID, + opts.SenderDisplayName, ) // Resolve media:// refs: images→base64 data URLs, non-images→local paths in content @@ -337,12 +337,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if phaseLoop > 0 { messages = agent.ContextBuilder.BuildMessages( - agent.Sessions.GetHistory(opts.SessionKey), - agent.Sessions.GetSummary(opts.SessionKey), - "", opts.Media, opts.Channel, opts.ChatID, + opts.SenderID, opts.SenderDisplayName, ) messages = append(messages, providers.Message{ diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index b165f53c4..179cfa860 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -30,6 +30,28 @@ func (f *fakeChannel) IsAllowed(string) bool { func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } func (f *fakeChannel) ReasoningChannelID() string { return f.id } +type recordingProvider struct { + lastMessages []providers.Message +} + +func (r *recordingProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + r.lastMessages = append([]providers.Message(nil), messages...) + return &providers.LLMResponse{ + Content: "Mock response", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (r *recordingProvider) GetDefaultModel() string { + return "mock-model" +} + func newTestAgentLoop( t *testing.T, ) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { @@ -54,6 +76,59 @@ func newTestAgentLoop( return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } } +func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "discord", + SenderID: "discord:123", + Sender: bus.SenderInfo{ + DisplayName: "Alice", + }, + ChatID: "group-1", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + wantSender := "## Current Sender\nCurrent sender: Alice (ID: discord:123)" + if !strings.Contains(systemPrompt, wantSender) { + t.Fatalf("system prompt missing sender context %q:\n%s", wantSender, systemPrompt) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "hello" { + t.Fatalf("last provider message = %+v, want unchanged user message", lastMessage) + } +} + func TestRecordLastChannel(t *testing.T) { al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) defer cleanup() @@ -922,10 +997,25 @@ func TestHandleReasoning(t *testing.T) { al, msgBus := newLoop(t) al.handleReasoning(context.Background(), "reasoning", "telegram", "") - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if msg, ok := msgBus.SubscribeOutbound(ctx); ok { - t.Fatalf("expected no outbound message, got %+v", msg) + for { + select { + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + t.Fatalf("expected no outbound message, got %+v", msg) + } + if msg.Content == "reasoning" { + t.Fatalf("expected no message for empty chatID, got %+v", msg) + } + return + case <-ctx.Done(): + t.Log("expected an outbound message, got none within timeout") + return + default: + // Continue to check for message + time.Sleep(5 * time.Millisecond) // Avoid busy loop + } } }) @@ -933,9 +1023,7 @@ func TestHandleReasoning(t *testing.T) { al, msgBus := newLoop(t) al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1") - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) + msg, ok := <-msgBus.OutboundChan() if !ok { t.Fatal("expected an outbound message") } @@ -949,35 +1037,52 @@ func TestHandleReasoning(t *testing.T) { reasoning := "hello telegram reasoning" al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if !ok { - t.Fatal("expected outbound message") - } + for { + select { + case <-ctx.Done(): + t.Fatal("expected an outbound message, got none within timeout") + return + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + t.Fatal("expected outbound message") + } - if msg.Channel != "telegram" { - t.Fatalf("expected telegram channel message, got %+v", msg) - } - if msg.ChatID != "tg-chat" { - t.Fatalf("expected chatID tg-chat, got %+v", msg) - } - if msg.Content != reasoning { - t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning) + if msg.Channel != "telegram" { + t.Fatalf("expected telegram channel message, got %+v", msg) + } + if msg.ChatID != "tg-chat" { + t.Fatalf("expected chatID tg-chat, got %+v", msg) + } + if msg.Content != reasoning { + t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning) + } + return + } } }) t.Run("expired ctx", func(t *testing.T) { al, msgBus := newLoop(t) reasoning := "hello telegram reasoning" - ctx, cancel := context.WithCancel(context.Background()) - cancel() - al.handleReasoning(ctx, reasoning, "telegram", "tg-chat") - ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if ok { - t.Fatalf("expected no outbound message, got %+v", msg) + al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") + + consumeCtx, consumeCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer consumeCancel() + + for { + select { + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + t.Fatalf("expected no outbound message, but received: %+v", msg) + } + t.Logf("Received unexpected outbound message: %+v", msg) + return + case <-consumeCtx.Done(): + t.Fatalf("failed: no message received within timeout") + return + } } }) @@ -1017,20 +1122,23 @@ func TestHandleReasoning(t *testing.T) { // Drain the bus and verify the reasoning message was NOT published // (it should have been dropped due to timeout). - drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer drainCancel() - foundReasoning := false + timeer := time.After(1 * time.Second) for { - msg, ok := msgBus.SubscribeOutbound(drainCtx) - if !ok { - break + select { + case <-timeer: + t.Logf( + "no reasoning message received after draining bus for 1s, as expected,length=%d", + len(msgBus.OutboundChan()), + ) + return + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + break + } + if msg.Content == "should timeout" { + t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") + } } - if msg.Content == "should timeout" { - foundReasoning = true - } - } - if foundReasoning { - t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") } }) } @@ -1318,3 +1426,84 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } + +// --- Native search helper tests --- + +type nativeSearchProvider struct { + supported bool +} + +func (p *nativeSearchProvider) Chat( + ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, + model string, opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (p *nativeSearchProvider) GetDefaultModel() string { return "test-model" } + +func (p *nativeSearchProvider) SupportsNativeSearch() bool { return p.supported } + +type plainProvider struct{} + +func (p *plainProvider) Chat( + ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, + model string, opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (p *plainProvider) GetDefaultModel() string { return "test-model" } + +func TestIsNativeSearchProvider_Supported(t *testing.T) { + if !isNativeSearchProvider(&nativeSearchProvider{supported: true}) { + t.Fatal("expected true for provider that supports native search") + } +} + +func TestIsNativeSearchProvider_NotSupported(t *testing.T) { + if isNativeSearchProvider(&nativeSearchProvider{supported: false}) { + t.Fatal("expected false for provider that does not support native search") + } +} + +func TestIsNativeSearchProvider_NoInterface(t *testing.T) { + if isNativeSearchProvider(&plainProvider{}) { + t.Fatal("expected false for provider that does not implement NativeSearchCapable") + } +} + +func TestFilterClientWebSearch_RemovesWebSearch(t *testing.T) { + defs := []providers.ToolDefinition{ + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "web_search"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, + } + result := filterClientWebSearch(defs) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } + for _, td := range result { + if td.Function.Name == "web_search" { + t.Fatal("web_search should be filtered out") + } + } +} + +func TestFilterClientWebSearch_NoWebSearch(t *testing.T) { + defs := []providers.ToolDefinition{ + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, + } + result := filterClientWebSearch(defs) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } +} + +func TestFilterClientWebSearch_EmptyInput(t *testing.T) { + result := filterClientWebSearch(nil) + if len(result) != 0 { + t.Fatalf("len(result) = %d, want 0", len(result)) + } +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 2e55d4877..f7813ca57 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -6,6 +6,7 @@ import ( "path/filepath" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -39,7 +40,7 @@ func (c *AuthCredential) NeedsRefresh() bool { } func authFilePath() string { - if home := os.Getenv("PICOCLAW_HOME"); home != "" { + if home := os.Getenv(config.EnvHome); home != "" { return filepath.Join(home, "auth.json") } home, _ := os.UserHomeDir() diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index f5ff9587d..6158b1936 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -3,6 +3,7 @@ package bus import ( "context" "errors" + "sync" "sync/atomic" "github.com/sipeed/picoclaw/pkg/logger" @@ -17,8 +18,11 @@ type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage outboundMedia chan OutboundMediaMessage - done chan struct{} - closed atomic.Bool + + closeOnce sync.Once + done chan struct{} + closed atomic.Bool + wg sync.WaitGroup } func NewMessageBus() *MessageBus { @@ -30,23 +34,43 @@ func NewMessageBus() *MessageBus { } } -func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { +func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error { + // check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock if mb.closed.Load() { return ErrBusClosed } - if err := ctx.Err(); err != nil { - return err - } + + // check again,before sending message, to avoid sending to closed channel select { - case mb.inbound <- msg: - return nil - case <-mb.done: - return ErrBusClosed case <-ctx.Done(): return ctx.Err() + case <-mb.done: + return ErrBusClosed + default: + } + + mb.wg.Add(1) + defer mb.wg.Done() + + select { + case ch <- msg: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-mb.done: + return ErrBusClosed } } +func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { + return publish(ctx, mb, mb.inbound, msg) +} + +func (mb *MessageBus) InboundChan() <-chan InboundMessage { + return mb.inbound +} + +// ConsumeInbound blocks until an inbound message is available or the context is cancelled. func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) { select { case msg, ok := <-mb.inbound: @@ -59,22 +83,14 @@ func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) } func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { - if mb.closed.Load() { - return ErrBusClosed - } - if err := ctx.Err(); err != nil { - return err - } - select { - case mb.outbound <- msg: - return nil - case <-mb.done: - return ErrBusClosed - case <-ctx.Done(): - return ctx.Err() - } + return publish(ctx, mb, mb.outbound, msg) } +func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { + return mb.outbound +} + +// SubscribeOutbound blocks until an outbound message is available or the context is cancelled. func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) { select { case msg, ok := <-mb.outbound: @@ -87,71 +103,46 @@ func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, b } func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { - if mb.closed.Load() { - return ErrBusClosed - } - if err := ctx.Err(); err != nil { - return err - } - select { - case mb.outboundMedia <- msg: - return nil - case <-mb.done: - return ErrBusClosed - case <-ctx.Done(): - return ctx.Err() - } + return publish(ctx, mb, mb.outboundMedia, msg) } -func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) { - select { - case msg, ok := <-mb.outboundMedia: - return msg, ok - case <-mb.done: - return OutboundMediaMessage{}, false - case <-ctx.Done(): - return OutboundMediaMessage{}, false - } +func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { + return mb.outboundMedia } func (mb *MessageBus) Close() { - if mb.closed.CompareAndSwap(false, true) { + mb.closeOnce.Do(func() { + // notify all blocked publishers to exit close(mb.done) - // Drain buffered channels so messages aren't silently lost. - // Channels are NOT closed to avoid send-on-closed panics from concurrent publishers. + // because every publisher will check mb.closed before acquiring wg + // so we can be sure that new publishers will not be added new messages after this point + mb.closed.Store(true) + + // wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited + mb.wg.Wait() + + // close channels safely + close(mb.inbound) + close(mb.outbound) + close(mb.outboundMedia) + + // clean up any remaining messages in channels drained := 0 - for { - select { - case <-mb.inbound: - drained++ - default: - goto doneInbound - } + for range mb.inbound { + drained++ } - doneInbound: - for { - select { - case <-mb.outbound: - drained++ - default: - goto doneOutbound - } + for range mb.outbound { + drained++ } - doneOutbound: - for { - select { - case <-mb.outboundMedia: - drained++ - default: - goto doneMedia - } + for range mb.outboundMedia { + drained++ } - doneMedia: + if drained > 0 { logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ "count": drained, }) } - } + }) } diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go index e07b8c7fe..9b6324ca6 100644 --- a/pkg/bus/bus_test.go +++ b/pkg/bus/bus_test.go @@ -24,7 +24,7 @@ func TestPublishConsume(t *testing.T) { t.Fatalf("PublishInbound failed: %v", err) } - got, ok := mb.ConsumeInbound(ctx) + got, ok := <-mb.InboundChan() if !ok { t.Fatal("ConsumeInbound returned ok=false") } @@ -52,7 +52,7 @@ func TestPublishOutboundSubscribe(t *testing.T) { t.Fatalf("PublishOutbound failed: %v", err) } - got, ok := mb.SubscribeOutbound(ctx) + got, ok := <-mb.OutboundChan() if !ok { t.Fatal("SubscribeOutbound returned ok=false") } @@ -108,27 +108,48 @@ func TestPublishOutbound_BusClosed(t *testing.T) { func TestConsumeInbound_ContextCancel(t *testing.T) { mb := NewMessageBus() + defer mb.Close() - ctx, cancel := context.WithCancel(context.Background()) - cancel() + for i := range defaultBusBufferSize { + if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil { + t.Fatalf("fill failed at %d: %v", i, err) + } + } - _, ok := mb.ConsumeInbound(ctx) - if ok { - t.Fatal("expected ok=false when context is canceled") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"}) + + select { + case <-ctx.Done(): + t.Log("context canceled, as expected") + + case msg, ok := <-mb.InboundChan(): + if !ok { + t.Fatal("expected ok=false when context is canceled") + } + if msg.Content == "ContextCancel" { + t.Fatalf("expected content 'ContextCancel', got %q", msg.Content) + } } } func TestConsumeInbound_BusClosed(t *testing.T) { mb := NewMessageBus() - mb.Close() - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() + timer := time.AfterFunc(100*time.Millisecond, func() { + mb.Close() + }) - _, ok := mb.ConsumeInbound(ctx) - if ok { - t.Fatal("expected ok=false when bus is closed") + select { + case <-timer.C: + t.Log("context canceled, as expected") + + case _, ok := <-mb.InboundChan(): + if ok { + t.Fatal("expected ok=false when context is canceled") + } } } @@ -136,10 +157,7 @@ func TestSubscribeOutbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - _, ok := mb.SubscribeOutbound(ctx) + _, ok := <-mb.OutboundChan() if ok { t.Fatal("expected ok=false when bus is closed") } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5dbbcf0af..3aea67b12 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -29,11 +29,17 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +// errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked +// tenant_access_token. The Lark SDK's built-in retry does not clear its cache +// on this error, so we do it ourselves. +const errCodeTenantTokenInvalid = 99991663 + type FeishuChannel struct { *channels.BaseChannel - config config.FeishuConfig - client *lark.Client - wsClient *larkws.Client + config config.FeishuConfig + client *lark.Client + wsClient *larkws.Client + tokenCache *tokenCache // custom cache that supports invalidation botOpenID atomic.Value // stores string; populated lazily for @mention detection @@ -47,10 +53,16 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + tc := newTokenCache() + opts := []lark.ClientOptionFunc{lark.WithTokenCache(tc)} + if cfg.IsLark { + opts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl)) + } ch := &FeishuChannel{ BaseChannel: base, config: cfg, - client: lark.NewClient(cfg.AppID, cfg.AppSecret), + tokenCache: tc, + client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...), } ch.SetOwner(ch) return ch, nil @@ -75,10 +87,15 @@ func (c *FeishuChannel) Start(ctx context.Context) error { c.mu.Lock() c.cancel = cancel + domain := lark.FeishuBaseUrl + if c.config.IsLark { + domain = lark.LarkBaseUrl + } c.wsClient = larkws.NewClient( c.config.AppID, c.config.AppSecret, larkws.WithEventHandler(dispatcher), + larkws.WithDomain(domain), ) wsClient := c.wsClient c.mu.Unlock() @@ -147,6 +164,7 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return fmt.Errorf("feishu edit: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -186,6 +204,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", fmt.Errorf("feishu placeholder send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) } @@ -226,6 +245,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st return func() {}, fmt.Errorf("feishu react: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) logger.ErrorCF("feishu", "Reaction API error", map[string]any{ "emoji": chosenEmoji, "message_id": messageID, @@ -451,6 +471,7 @@ func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error { return fmt.Errorf("bot info parse: %w", err) } if result.Code != 0 { + c.invalidateTokenOnAuthError(result.Code) return fmt.Errorf("bot info api error (code=%d)", result.Code) } if result.Bot.OpenID == "" { @@ -593,6 +614,7 @@ func (c *FeishuChannel) downloadResource( return "" } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) logger.ErrorCF("feishu", "Resource download api error", map[string]any{ "code": resp.Code, "msg": resp.Msg, @@ -618,7 +640,7 @@ func (c *FeishuChannel) downloadResource( } // Write to the shared picoclaw_media directory using a unique name to avoid collisions. - mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + mediaDir := media.TempDir() if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ "error": mkdirErr.Error(), @@ -705,6 +727,7 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } @@ -730,6 +753,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F return fmt.Errorf("feishu image upload: %w", err) } if !uploadResp.Success() { + c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { @@ -754,6 +778,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F return fmt.Errorf("feishu image send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -784,6 +809,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi return fmt.Errorf("feishu file upload: %w", err) } if !uploadResp.Success() { + c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.FileKey == nil { @@ -808,6 +834,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi return fmt.Errorf("feishu file send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -830,3 +857,14 @@ func extractFeishuSenderID(sender *larkim.EventSender) string { return "" } + +// invalidateTokenOnAuthError clears the cached tenant_access_token when the +// Feishu API reports it as invalid (99991663), so the next request fetches a +// fresh one. The Lark SDK's built-in retry does not clear the cache, causing +// all API calls to fail until the token naturally expires (~2 hours). +func (c *FeishuChannel) invalidateTokenOnAuthError(code int) { + if code == errCodeTenantTokenInvalid { + c.tokenCache.InvalidateAll() + logger.WarnCF("feishu", "Invalidated cached token due to auth error", nil) + } +} diff --git a/pkg/channels/feishu/token_cache.go b/pkg/channels/feishu/token_cache.go new file mode 100644 index 000000000..00acbc084 --- /dev/null +++ b/pkg/channels/feishu/token_cache.go @@ -0,0 +1,52 @@ +package feishu + +import ( + "context" + "sync" + "time" +) + +// tokenCache implements larkcore.Cache with an extra InvalidateAll method. +// This works around a bug in the Lark SDK v3 where the built-in token retry +// loop does not clear stale tokens from cache on auth errors. +type tokenCache struct { + mu sync.RWMutex + store map[string]*tokenEntry +} + +type tokenEntry struct { + value string + expireAt time.Time +} + +func newTokenCache() *tokenCache { + return &tokenCache{store: make(map[string]*tokenEntry)} +} + +func (c *tokenCache) Set(_ context.Context, key, value string, ttl time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + c.store[key] = &tokenEntry{value: value, expireAt: time.Now().Add(ttl)} + return nil +} + +func (c *tokenCache) Get(_ context.Context, key string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.store[key] + if !ok { + return "", nil + } + if e.expireAt.Before(time.Now()) { + delete(c.store, key) + return "", nil + } + return e.value, nil +} + +// InvalidateAll removes all cached tokens, forcing fresh acquisition. +func (c *tokenCache) InvalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + clear(c.store) +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 8e663ff86..716f6dcbb 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "math" + "net/http" "sync" "time" @@ -76,17 +77,18 @@ type channelWorker struct { } type Manager struct { - managerExt // fork-specific fields (see manager_ext.go) - + managerExt // fork-specific fields (statusMsgIDs, taskMsgIDs, statusEditTimes) channels map[string]Channel workers map[string]*channelWorker bus *bus.MessageBus config *config.Config mediaStore media.MediaStore dispatchTask *asyncTask + mux *http.ServeMux + httpServer *http.Server mu sync.RWMutex - placeholders sync.Map // "channel:chatID" → placeholderEntry - typingStops sync.Map // "channel:chatID" → typingEntry + placeholders sync.Map // "channel:chatID" → placeholderID (string) + typingStops sync.Map // "channel:chatID" → func() reactionUndos sync.Map // "channel:chatID" → reactionEntry } @@ -101,7 +103,7 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) } -// SendPlaceholder sends a "Thinking..." placeholder for the given channel/chatID +// SendPlaceholder sends a "Thinking…" placeholder for the given channel/chatID // and records it for later editing. Returns true if a placeholder was sent. func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool { m.mu.RLock() @@ -124,11 +126,6 @@ func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) b // RecordTypingStop registers a typing stop function for later invocation. // Implements PlaceholderRecorder. -// -// If a previous typing indicator exists for the same chat, it is stopped -// immediately before the new one is recorded. This prevents the next -// preSend (which finalizes the *previous* processing cycle) from -// consuming the *new* message's typing entry. func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { key := channel + ":" + chatID entry := typingEntry{stop: stop, createdAt: time.Now()} @@ -141,11 +138,6 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { // RecordReactionUndo registers a reaction undo function for later invocation. // Implements PlaceholderRecorder. -// -// If a previous reaction exists for the same chat, it is undone immediately -// before the new one is recorded. Same rationale as RecordTypingStop: the -// old entry belongs to the previous processing cycle and must not leak into -// the next preSend call. func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { key := channel + ":" + chatID if v, loaded := m.reactionUndos.Load(key); loaded { @@ -156,8 +148,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()}) } -// preSend handles typing stop, reaction undo, and placeholder/status editing before sending a message. -// Returns true if the message was edited into an existing message (skip Send). +// preSend handles typing stop, reaction undo, and placeholder editing before sending a message. +// Returns true if the message was edited into a placeholder (skip Send). func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { key := name + ":" + msg.ChatID @@ -350,32 +342,41 @@ func (m *Manager) initChannels() error { return nil } -// SetupHTTPServer registers channel webhook handlers and health checkers -// onto the provided health server's mux. The health server owns the HTTP -// listener; the channel manager no longer creates its own http.Server. -func (m *Manager) SetupHTTPServer(_ string, healthServer *health.Server) { - if healthServer == nil { - return +// SetupHTTPServer creates a shared HTTP server with the given listen address. +// It registers health endpoints from the health server and discovers channels +// that implement WebhookHandler and/or HealthChecker to register their handlers. +func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { + m.mux = http.NewServeMux() + + // Register health endpoints + if healthServer != nil { + healthServer.RegisterOnMux(m.mux) } - mux := healthServer.Mux() // Discover and register webhook handlers and health checkers for name, ch := range m.channels { if wh, ok := ch.(WebhookHandler); ok { - mux.Handle(wh.WebhookPath(), wh) + m.mux.Handle(wh.WebhookPath(), wh) logger.InfoCF("channels", "Webhook handler registered", map[string]any{ "channel": name, "path": wh.WebhookPath(), }) } if hc, ok := ch.(HealthChecker); ok { - mux.HandleFunc(hc.HealthPath(), hc.HealthHandler) + m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler) logger.InfoCF("channels", "Health endpoint registered", map[string]any{ "channel": name, "path": hc.HealthPath(), }) } } + + m.httpServer = &http.Server{ + Addr: addr, + Handler: m.mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } } func (m *Manager) StartAll(ctx context.Context) error { @@ -384,7 +385,6 @@ func (m *Manager) StartAll(ctx context.Context) error { if len(m.channels) == 0 { logger.WarnC("channels", "No channels enabled") - return errors.New("no channels enabled") } logger.InfoC("channels", "Starting all channels") @@ -417,6 +417,20 @@ func (m *Manager) StartAll(ctx context.Context) error { // Start the TTL janitor that cleans up stale typing/placeholder entries go m.runTTLJanitor(dispatchCtx) + // 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(), + }) + } + }() + } + logger.InfoC("channels", "All channels started") return nil } @@ -427,6 +441,18 @@ func (m *Manager) StopAll(ctx context.Context) error { logger.InfoC("channels", "Stopping all channels") + // Shutdown shared HTTP server first + if m.httpServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := m.httpServer.Shutdown(shutdownCtx); err != nil { + logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{ + "error": err.Error(), + }) + } + m.httpServer = nil + } + // Cancel dispatcher if m.dispatchTask != nil { m.dispatchTask.cancel() @@ -598,7 +624,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork func dispatchLoop[M any]( ctx context.Context, m *Manager, - subscribe func(context.Context) (M, bool), + ch <-chan M, getChannel func(M) string, enqueue func(context.Context, *channelWorker, M) bool, startMsg, stopMsg, unknownMsg, noWorkerMsg string, @@ -606,35 +632,41 @@ func dispatchLoop[M any]( logger.InfoC("channels", startMsg) for { - msg, ok := subscribe(ctx) - if !ok { + select { + case <-ctx.Done(): logger.InfoC("channels", stopMsg) return - } - channel := getChannel(msg) - - // Silently skip internal channels - if constants.IsInternalChannel(channel) { - continue - } - - m.mu.RLock() - _, exists := m.channels[channel] - w, wExists := m.workers[channel] - m.mu.RUnlock() - - if !exists { - logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel}) - continue - } - - if wExists && w != nil { - if !enqueue(ctx, w, msg) { + case msg, ok := <-ch: + if !ok { + logger.InfoC("channels", stopMsg) return } - } else if exists { - logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel}) + + channel := getChannel(msg) + + // Silently skip internal channels + if constants.IsInternalChannel(channel) { + continue + } + + m.mu.RLock() + _, exists := m.channels[channel] + w, wExists := m.workers[channel] + m.mu.RUnlock() + + if !exists { + logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel}) + continue + } + + if wExists && w != nil { + if !enqueue(ctx, w, msg) { + return + } + } else if exists { + logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel}) + } } } } @@ -642,7 +674,7 @@ func dispatchLoop[M any]( func (m *Manager) dispatchOutbound(ctx context.Context) { dispatchLoop( ctx, m, - m.bus.SubscribeOutbound, + m.bus.OutboundChan(), func(msg bus.OutboundMessage) string { return msg.Channel }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { select { @@ -662,7 +694,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { func (m *Manager) dispatchOutboundMedia(ctx context.Context) { dispatchLoop( ctx, m, - m.bus.SubscribeOutboundMedia, + m.bus.OutboundMediaChan(), func(msg bus.OutboundMediaMessage) string { return msg.Channel }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { select { @@ -796,32 +828,6 @@ func (m *Manager) runTTLJanitor(ctx context.Context) { } return true }) - m.statusMsgIDs.Range(func(key, value any) bool { - if entry, ok := value.(statusMsgEntry); ok { - if now.Sub(entry.createdAt) > statusMsgTTL { - m.statusMsgIDs.Delete(key) - } - } - return true - }) - m.taskMsgIDs.Range(func(key, value any) bool { - if entry, ok := value.(statusMsgEntry); ok { - if now.Sub(entry.createdAt) > taskMsgTTL { - m.taskMsgIDs.Delete(key) - } - } - return true - }) - // Clean up stale edit-time entries (only needed for a few seconds, - // but janitor runs infrequently so use a generous TTL). - m.statusEditTimes.Range(func(key, value any) bool { - if t, ok := value.(time.Time); ok { - if now.Sub(t) > statusMsgTTL { - m.statusEditTimes.Delete(key) - } - } - return true - }) } } } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index bec5dfdac..4cbe95c5c 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -35,8 +35,6 @@ const ( roomKindCacheTTL = 5 * time.Minute roomKindCacheCleanupPeriod = 1 * time.Minute roomKindCacheMaxEntries = 2048 - - matrixMediaTempDirName = "picoclaw_media" ) var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) @@ -1105,7 +1103,7 @@ func (c *MatrixChannel) stripSelfMention(text string) string { } func matrixMediaTempDir() (string, error) { - mediaDir := filepath.Join(os.TempDir(), matrixMediaTempDirName) + mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { return "", err } diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 07a35c021..7484c8d87 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -15,6 +15,7 @@ import ( "maunium.net/go/mautrix/id" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) func TestMatrixLocalpartMentionRegexp(t *testing.T) { @@ -165,7 +166,7 @@ func TestMatrixMediaTempDir(t *testing.T) { if err != nil { t.Fatalf("matrixMediaTempDir failed: %v", err) } - if filepath.Base(dir) != matrixMediaTempDirName { + if filepath.Base(dir) != media.TempDirName { t.Fatalf("unexpected media dir base: %q", filepath.Base(dir)) } diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 8d8b62a67..206e71f92 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -251,7 +251,13 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { return } - conn, err := c.upgrader.Upgrade(w, r, nil) + // Echo the matched subprotocol back so the browser accepts the upgrade. + var responseHeader http.Header + if proto := c.matchedSubprotocol(r); proto != "" { + responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}} + } + + conn, err := c.upgrader.Upgrade(w, r, responseHeader) if err != nil { logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{ "error": err.Error(), @@ -282,8 +288,10 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { go c.readLoop(pc) } -// authenticate checks the Bearer token from the Authorization header. -// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled. +// authenticate checks the request for a valid token: +// 1. Authorization: Bearer header +// 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) +// 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { token := c.config.Token if token == "" { @@ -298,6 +306,11 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { } } + // Check Sec-WebSocket-Protocol subprotocol ("token.") + if c.matchedSubprotocol(r) != "" { + return true + } + // Check query parameter only when explicitly allowed if c.config.AllowTokenQuery { if r.URL.Query().Get("token") == token { @@ -308,6 +321,18 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { return false } +// matchedSubprotocol returns the "token." subprotocol that matches +// the configured token, or "" if none do. +func (c *PicoChannel) matchedSubprotocol(r *http.Request) string { + token := c.config.Token + for _, proto := range websocket.Subprotocols(r) { + if after, ok := strings.CutPrefix(proto, "token."); ok && after == token { + return proto + } + } + return "" +} + // readLoop reads messages from a WebSocket connection. func (c *PicoChannel) readLoop(pc *picoConn) { defer func() { diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go index 3ceee0d09..b04cf5abd 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -34,11 +34,19 @@ func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - inbound, ok := messageBus.ConsumeInbound(ctx) - if !ok { - t.Fatal("expected inbound message") - } - if inbound.Metadata["account_id"] != "7750283E123456" { - t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456") + for { + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for inbound message") + return + case inbound, ok := <-messageBus.InboundChan(): + if !ok { + t.Fatal("expected inbound message") + } + if inbound.Metadata["account_id"] != "7750283E123456" { + t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456") + } + return + } } } diff --git a/pkg/channels/telegram/parse_markdown_to_md_v2.go b/pkg/channels/telegram/parse_markdown_to_md_v2.go new file mode 100644 index 000000000..8cae312c5 --- /dev/null +++ b/pkg/channels/telegram/parse_markdown_to_md_v2.go @@ -0,0 +1,197 @@ +package telegram + +import ( + "regexp" + "strings" +) + +// mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2 +var mdV2SpecialChars = map[rune]bool{ + '*': true, + '_': true, + '[': true, + ']': true, + '(': true, + ')': true, + '~': true, + '`': true, + '>': true, + '<': true, + '#': true, + '+': true, + '-': true, + '=': true, + '|': true, + '{': true, + '}': true, + '.': true, + '!': true, + '\\': true, +} + +// entityPattern describes one Telegram MarkdownV2 inline entity type. +type entityPattern struct { + re *regexp.Regexp + open string + close string +} + +// allEntityPatterns lists every recognized entity in priority order +// (longer / more-specific delimiters first so they win over shorter ones). +// Each entry's regex is anchored to find the first occurrence in a string. +var allEntityPatterns = []entityPattern{ + // fenced code block — content is completely verbatim + {re: regexp.MustCompile("(?s)```(?:[\\w]*\\n)?[\\s\\S]*?```"), open: "```", close: "```"}, + // inline code — content is completely verbatim + {re: regexp.MustCompile("`(?:[^`\\\n]|\\\\.)*`"), open: "`", close: "`"}, + // expandable block-quote opener **>… + {re: regexp.MustCompile(`(?m)\*\*>(?:[^\n]*)`), open: "**>", close: ""}, + // block-quote line >… + {re: regexp.MustCompile(`(?m)^>(?:[^\n]*)`), open: ">", close: ""}, + // custom emoji / timestamp ![…](…) — must come before plain link + {re: regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`), open: "!", close: ""}, + // inline URL / user mention […](…) + {re: regexp.MustCompile(`\[[^\]]*\]\([^)]*\)`), open: "[", close: ""}, + // spoiler ||…|| — before single | so it wins + {re: regexp.MustCompile(`\|\|(?:[^|\\\n]|\\.)*\|\|`), open: "||", close: "||"}, + // underline __…__ — before single _ so it wins + {re: regexp.MustCompile(`__(?:[^_\\\n]|\\.)*__`), open: "__", close: "__"}, + // bold *…* + {re: regexp.MustCompile(`\*(?:[^*\\\n]|\\.)*\*`), open: "*", close: "*"}, + // italic _…_ + {re: regexp.MustCompile(`_(?:[^_\\\n]|\\.)*_`), open: "_", close: "_"}, + // strikethrough ~…~ + {re: regexp.MustCompile(`~(?:[^~\\\n]|\\.)*~`), open: "~", close: "~"}, +} + +// verbatimEntities are entity types whose inner content must never be +// touched (code blocks, URLs, quotes, custom emoji). +// Their content is passed through completely unchanged. +var verbatimEntities = map[string]bool{ + "```": true, + "`": true, + "**>": true, + ">": true, + "!": true, + "[": true, +} + +// markdownToTelegramMarkdownV2 converts a Markdown string into a string safe +// for sending with Telegram's MarkdownV2 parse mode. +// +// Rules: +// - Markdown headings (# … ######) are converted to *bold*. +// - **bold** Markdown syntax is converted to *bold*. +// - Recognized Telegram MarkdownV2 entity spans are preserved; their inner +// content is processed recursively so that nested valid entities are kept +// intact while stray special characters are escaped. +// - All plain-text segments have their MarkdownV2 special characters escaped. +// +// Reference: https://core.telegram.org/bots/api#formatting-options +func markdownToTelegramMarkdownV2(text string) string { + // 1. Convert Markdown headings → *escaped heading text* + text = reHeading.ReplaceAllStringFunc(text, func(match string) string { + sub := reHeading.FindStringSubmatch(match) + if len(sub) < 2 { + return match + } + // The heading content is fresh plain text — escape everything + // including * so the resulting *…* bold span stays valid. + return "*" + escapeMarkdownV2(sub[1]) + "*" + }) + + // 2. Convert **bold** → *bold* + text = reBoldStar.ReplaceAllString(text, "*$1*") + + // 3. Recursively escape the full string. + return processText(text) +} + +// processText walks `text`, finds the leftmost / longest matching entity, +// escapes the gap before it, processes the entity (recursing into its inner +// content when appropriate), then continues with the remainder. +func processText(text string) string { + if text == "" { + return "" + } + + // Find the leftmost match among all entity patterns. + bestStart := -1 + bestEnd := -1 + var bestPat *entityPattern + + for i := range allEntityPatterns { + p := &allEntityPatterns[i] + loc := p.re.FindStringIndex(text) + if loc == nil { + continue + } + if bestStart == -1 || loc[0] < bestStart || + (loc[0] == bestStart && (loc[1]-loc[0]) > (bestEnd-bestStart)) { + bestStart = loc[0] + bestEnd = loc[1] + bestPat = p + } + } + + if bestPat == nil { + // No entity found — escape everything. + return escapeMarkdownV2(text) + } + + var b strings.Builder + + // Plain text before the entity. + if bestStart > 0 { + b.WriteString(escapeMarkdownV2(text[:bestStart])) + } + + // The matched entity span. + matched := text[bestStart:bestEnd] + + if verbatimEntities[bestPat.open] { + // Code blocks, URLs, quotes: pass through completely untouched. + b.WriteString(matched) + } else { + // Inline formatting (bold, italic, underline, strikethrough, spoiler): + // keep the delimiters and recursively process the inner content so that + // nested entities survive but stray specials get escaped. + openLen := len(bestPat.open) + closeLen := len(bestPat.close) + inner := matched[openLen : len(matched)-closeLen] + + b.WriteString(bestPat.open) + b.WriteString(processText(inner)) + b.WriteString(bestPat.close) + } + + // Continue with the remainder of the string. + b.WriteString(processText(text[bestEnd:])) + + return b.String() +} + +// escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text +// segment (i.e. a segment that is not part of any recognized entity). +// Already-escaped sequences (backslash + char) are forwarded verbatim to avoid +// double-escaping. +func escapeMarkdownV2(s string) string { + var b strings.Builder + b.Grow(len(s) + 8) + runes := []rune(s) + for i := 0; i < len(runes); i++ { + ch := runes[i] + // Forward an existing escape sequence verbatim. + if ch == '\\' && i+1 < len(runes) { + b.WriteRune(ch) + b.WriteRune(runes[i+1]) + i++ + continue + } + if mdV2SpecialChars[ch] { + b.WriteByte('\\') + } + b.WriteRune(ch) + } + return b.String() +} diff --git a/pkg/channels/telegram/parse_markdown_to_md_v2_test.go b/pkg/channels/telegram/parse_markdown_to_md_v2_test.go new file mode 100644 index 000000000..fd68a9b83 --- /dev/null +++ b/pkg/channels/telegram/parse_markdown_to_md_v2_test.go @@ -0,0 +1,68 @@ +package telegram + +import ( + _ "embed" + "testing" + + "github.com/stretchr/testify/require" +) + +//go:embed testdata/md2_all_formats.txt +var md2AllFormats string + +func Test_markdownToTelegramMarkdownV2(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "heading -> bolding", + input: `## HeadingH2 #`, + expected: "*HeadingH2 \\#*", + }, + { + name: "strikethrough", + input: "~strikethroughMD~", + expected: "~strikethroughMD~", + }, + { + name: "inline URL", + input: "[inline URL](http://www.example.com/)", + expected: "[inline URL](http://www.example.com/)", + }, + { + name: "all telegram formats", + input: md2AllFormats, + expected: md2AllFormats, + }, + { + name: "empty", + input: "", + expected: "", + }, + { + name: "one letter", + input: "o", + expected: "o", + }, + { + name: "", + input: "*Last update: ~10 24h*", + expected: "*Last update: \\~10 24h*", + }, + { + name: "", + input: "", + expected: "\\", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actual := markdownToTelegramMarkdownV2(tc.input) + + require.EqualValues(t, tc.expected, actual) + }) + } +} diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go new file mode 100644 index 000000000..bdaa51807 --- /dev/null +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -0,0 +1,111 @@ +package telegram + +import ( + "fmt" + "strings" +) + +func markdownToTelegramHTML(text string) string { + if text == "" { + return "" + } + + codeBlocks := extractCodeBlocks(text) + text = codeBlocks.text + + inlineCodes := extractInlineCodes(text) + text = inlineCodes.text + + text = reHeading.ReplaceAllString(text, "$1") + + text = reBlockquote.ReplaceAllString(text, "$1") + + text = escapeHTML(text) + + text = reLink.ReplaceAllString(text, `$1`) + + text = reBoldStar.ReplaceAllString(text, "$1") + + text = reBoldUnder.ReplaceAllString(text, "$1") + + text = reItalic.ReplaceAllStringFunc(text, func(s string) string { + match := reItalic.FindStringSubmatch(s) + if len(match) < 2 { + return s + } + return "" + match[1] + "" + }) + + text = reStrike.ReplaceAllString(text, "$1") + + text = reListItem.ReplaceAllString(text, "• ") + + for i, code := range inlineCodes.codes { + escaped := escapeHTML(code) + text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) + } + + for i, code := range codeBlocks.codes { + escaped := escapeHTML(code) + text = strings.ReplaceAll( + text, + fmt.Sprintf("\x00CB%d\x00", i), + fmt.Sprintf("
%s
", escaped), + ) + } + + return text +} + +type codeBlockMatch struct { + text string + codes []string +} + +func extractCodeBlocks(text string) codeBlockMatch { + matches := reCodeBlock.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, match := range matches { + codes = append(codes, match[1]) + } + + i := 0 + text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder + }) + + return codeBlockMatch{text: text, codes: codes} +} + +type inlineCodeMatch struct { + text string + codes []string +} + +func extractInlineCodes(text string) inlineCodeMatch { + matches := reInlineCode.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, match := range matches { + codes = append(codes, match[1]) + } + + i := 0 + text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00IC%d\x00", i) + i++ + return placeholder + }) + + return inlineCodeMatch{text: text, codes: codes} +} + +func escapeHTML(text string) string { + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + return text +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 34ee46b7b..9d0325093 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -3,6 +3,7 @@ package telegram import ( "context" "fmt" + "io" "net/http" "net/url" "os" @@ -26,7 +27,7 @@ import ( ) var ( - reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) + reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`) reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) @@ -169,6 +170,8 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return channels.ErrNotRunning } + useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) @@ -187,22 +190,65 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err chunk := queue[0] queue = queue[1:] - htmlContent := markdownToTelegramHTML(chunk) + content := parseContent(chunk, useMarkdownV2) - if len([]rune(htmlContent)) > 4096 { - ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent))) + if len([]rune(content)) > 4096 { + runeChunk := []rune(chunk) + ratio := float64(len(runeChunk)) / float64(len([]rune(content))) smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin - if smallerLen < 100 { - smallerLen = 100 + + // Guarantee progress: if estimated length is >= chunk length, force it smaller + if smallerLen >= len(runeChunk) { + smallerLen = len(runeChunk) - 1 } - // Push sub-chunks back to the front of the queue for - // re-validation instead of sending them blindly. + + if smallerLen <= 0 { + if err := c.sendChunk(ctx, sendChunkParams{ + chatID: chatID, + threadID: threadID, + content: content, + replyToID: replyToID, + mdFallback: chunk, + useMarkdownV2: useMarkdownV2, + }); err != nil { + return err + } + replyToID = "" + continue + } + + // Use the estimated smaller length as a guide for SplitMessage. + // SplitMessage will find natural break points (newlines/spaces) and respect code blocks. subChunks := channels.SplitMessage(chunk, smallerLen) - queue = append(subChunks, queue...) + + // Safety fallback: If SplitMessage failed to shorten the chunk, force a manual hard split. + if len(subChunks) == 1 && subChunks[0] == chunk { + part1 := string(runeChunk[:smallerLen]) + part2 := string(runeChunk[smallerLen:]) + subChunks = []string{part1, part2} + } + + // Filter out empty chunks to avoid sending empty messages to Telegram. + nonEmpty := make([]string, 0, len(subChunks)) + for _, s := range subChunks { + if s != "" { + nonEmpty = append(nonEmpty, s) + } + } + + // Push sub-chunks back to the front of the queue + queue = append(nonEmpty, queue...) continue } - if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil { + if err := c.sendChunk(ctx, sendChunkParams{ + chatID: chatID, + threadID: threadID, + content: content, + replyToID: replyToID, + mdFallback: chunk, + useMarkdownV2: useMarkdownV2, + }); err != nil { return err } // Only the first chunk should be a reply; subsequent chunks are normal messages. @@ -212,17 +258,31 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } -// sendHTMLChunk sends a single HTML message, falling back to the original -// markdown as plain text on parse failure so users never see raw HTML tags. -func (c *TelegramChannel) sendHTMLChunk( - ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string, -) error { - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML - tgMsg.MessageThreadID = threadID +type sendChunkParams struct { + chatID int64 + threadID int + content string + replyToID string + mdFallback string + useMarkdownV2 bool +} - if replyToID != "" { - if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil { +// sendChunk sends a single HTML/MarkdownV2 message, falling back to the original +// markdown as plain text on parse failure so users never see raw HTML/MarkdownV2 tags. +func (c *TelegramChannel) sendChunk( + ctx context.Context, + params sendChunkParams, +) error { + tgMsg := tu.Message(tu.ID(params.chatID), params.content) + tgMsg.MessageThreadID = params.threadID + if params.useMarkdownV2 { + tgMsg.WithParseMode(telego.ModeMarkdownV2) + } else { + tgMsg.WithParseMode(telego.ModeHTML) + } + + if params.replyToID != "" { + if mid, parseErr := strconv.Atoi(params.replyToID); parseErr == nil { tgMsg.ReplyParameters = &telego.ReplyParameters{ MessageID: mid, } @@ -230,15 +290,15 @@ func (c *TelegramChannel) sendHTMLChunk( } if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ - "error": err.Error(), - }) - tgMsg.Text = mdFallback + 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) } } + return nil } @@ -279,6 +339,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 cid, _, err := parseTelegramChatID(chatID) if err != nil { return err @@ -287,10 +348,19 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag if err != nil { return err } - htmlContent := markdownToTelegramHTML(content) - editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent) - editMsg.ParseMode = telego.ModeHTML + parsedContent := parseContent(content, useMarkdownV2) + editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent) + if useMarkdownV2 { + editMsg.WithParseMode(telego.ModeMarkdownV2) + } else { + editMsg.WithParseMode(telego.ModeHTML) + } _, err = c.bot.EditMessageText(ctx, editMsg) + if err != nil { + logParseFailed(err, useMarkdownV2) + _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + } + return err } @@ -367,6 +437,20 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Caption: part.Caption, } _, 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) + } + + docParams := &telego.SendDocumentParams{ + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Document: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendDocument(ctx, docParams) + } case "audio": params := &telego.SendAudioParams{ ChatID: tu.ID(chatID), @@ -624,6 +708,14 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) return c.downloadFileWithInfo(file, ext) } +func parseContent(text string, useMarkdownV2 bool) string { + if useMarkdownV2 { + return markdownToTelegramMarkdownV2(text) + } + + return markdownToTelegramHTML(text) +} + // parseTelegramChatID splits "chatID/threadID" into its components. // Returns threadID=0 when no "/" is present (non-forum messages). func parseTelegramChatID(chatID string) (int64, int, error) { @@ -643,109 +735,18 @@ func parseTelegramChatID(chatID string) (int64, int, error) { return cid, tid, nil } -func markdownToTelegramHTML(text string) string { - if text == "" { - return "" +func logParseFailed(err error, useMarkdownV2 bool) { + parsingName := "HTML" + if useMarkdownV2 { + parsingName = "MarkdownV2" } - codeBlocks := extractCodeBlocks(text) - text = codeBlocks.text - - inlineCodes := extractInlineCodes(text) - text = inlineCodes.text - - text = reHeading.ReplaceAllString(text, "$1") - - text = reBlockquote.ReplaceAllString(text, "$1") - - text = escapeHTML(text) - - text = reLink.ReplaceAllString(text, `$1`) - - text = reBoldStar.ReplaceAllString(text, "$1") - - text = reBoldUnder.ReplaceAllString(text, "$1") - - text = reItalic.ReplaceAllStringFunc(text, func(s string) string { - match := reItalic.FindStringSubmatch(s) - if len(match) < 2 { - return s - } - return "" + match[1] + "" - }) - - text = reStrike.ReplaceAllString(text, "$1") - - text = reListItem.ReplaceAllString(text, "• ") - - for i, code := range inlineCodes.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) - } - - for i, code := range codeBlocks.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll( - text, - fmt.Sprintf("\x00CB%d\x00", i), - fmt.Sprintf("
%s
", escaped), - ) - } - - return text -} - -type codeBlockMatch struct { - text string - codes []string -} - -func extractCodeBlocks(text string) codeBlockMatch { - matches := reCodeBlock.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00CB%d\x00", i) - i++ - return placeholder - }) - - return codeBlockMatch{text: text, codes: codes} -} - -type inlineCodeMatch struct { - text string - codes []string -} - -func extractInlineCodes(text string) inlineCodeMatch { - matches := reInlineCode.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00IC%d\x00", i) - i++ - return placeholder - }) - - return inlineCodeMatch{text: text, codes: codes} -} - -func escapeHTML(text string) string { - text = strings.ReplaceAll(text, "&", "&") - text = strings.ReplaceAll(text, "<", "<") - text = strings.ReplaceAll(text, ">", ">") - return text + logger.ErrorCF("telegram", + fmt.Sprintf("%s parse failed, falling back to plain text", parsingName), + map[string]any{ + "error": err.Error(), + }, + ) } // isBotMentioned checks if the bot is mentioned in the message via entities. diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go index 1ea4a4824..0eb1de5ea 100644 --- a/pkg/channels/telegram/telegram_dispatch_test.go +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -3,7 +3,6 @@ package telegram import ( "context" "testing" - "time" "github.com/mymmrac/telego" @@ -36,10 +35,7 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) + inbound, ok := <-messageBus.InboundChan() if !ok { t.Fatal("expected inbound message to be forwarded") } diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 0d5b985fe..614b2ca7f 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -108,22 +108,24 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond) defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) - if tc.wantForwarded { - if !ok { - t.Fatal("expected inbound message to be forwarded") + select { + case <-ctx.Done(): + if tc.wantForwarded { + t.Fatal("timeout waiting for message to be forwarded") + return } - if inbound.Content != tc.wantContent { - t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent) + case inbound, ok := <-messageBus.InboundChan(): + if tc.wantForwarded { + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != tc.wantContent { + t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent) + } + return } - return - } - - if ok { - t.Fatalf("expected message to be filtered, got content=%q", inbound.Content) } }) } diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index c2186d0a3..6bf1077af 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" + "io" + "os" + "path/filepath" "strings" "testing" - "time" "github.com/mymmrac/telego" ta "github.com/mymmrac/telego/telegoapi" @@ -15,6 +17,8 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc" @@ -38,8 +42,20 @@ func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) // stubConstructor implements ta.RequestConstructor for testing. type stubConstructor struct{} +type multipartCall struct { + Parameters map[string]string + FileSizes map[string]int +} + func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) { - return &ta.RequestData{}, nil + b, err := json.Marshal(parameters) + if err != nil { + return nil, err + } + return &ta.RequestData{ + ContentType: "application/json", + BodyRaw: b, + }, nil } func (s *stubConstructor) MultipartRequest( @@ -49,6 +65,36 @@ func (s *stubConstructor) MultipartRequest( return &ta.RequestData{}, nil } +type multipartRecordingConstructor struct { + stubConstructor + calls []multipartCall +} + +func (s *multipartRecordingConstructor) MultipartRequest( + parameters map[string]string, + files map[string]ta.NamedReader, +) (*ta.RequestData, error) { + call := multipartCall{ + Parameters: make(map[string]string, len(parameters)), + FileSizes: make(map[string]int, len(files)), + } + for k, v := range parameters { + call.Parameters[k] = v + } + for field, file := range files { + if file == nil { + continue + } + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + call.FileSizes[field] = len(data) + } + s.calls = append(s.calls, call) + return &ta.RequestData{}, nil +} + // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { t.Helper() @@ -60,11 +106,19 @@ func successResponse(t *testing.T) *ta.Response { // newTestChannel creates a TelegramChannel with a mocked bot for unit testing. func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { + return newTestChannelWithConstructor(t, caller, &stubConstructor{}) +} + +func newTestChannelWithConstructor( + t *testing.T, + caller *stubCaller, + constructor ta.RequestConstructor, +) *TelegramChannel { t.Helper() bot, err := telego.NewBot(testToken, telego.WithAPICaller(caller), - telego.WithRequestConstructor(&stubConstructor{}), + telego.WithRequestConstructor(constructor), telego.WithDiscardLogger(), ) require.NoError(t, err) @@ -78,9 +132,96 @@ func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { BaseChannel: base, bot: bot, chatIDs: make(map[string]int64), + config: config.DefaultConfig(), } } +func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) { + constructor := &multipartRecordingConstructor{} + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + switch { + case strings.Contains(url, "sendPhoto"): + return nil, errors.New(`api: 400 "Bad Request: PHOTO_INVALID_DIMENSIONS"`) + case strings.Contains(url, "sendDocument"): + return successResponse(t), nil + default: + t.Fatalf("unexpected API call: %s", url) + return nil, nil + } + }, + } + ch := newTestChannelWithConstructor(t, caller, constructor) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "woodstock-en-10s.png") + content := []byte("fake-png-content") + require.NoError(t, os.WriteFile(localPath, content, 0o644)) + + ref, err := store.Store( + localPath, + media.MediaMeta{Filename: "woodstock-en-10s.png", ContentType: "image/png"}, + "scope-1", + ) + require.NoError(t, err) + + err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "12345", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: ref, + Caption: "caption", + }}, + }) + + require.NoError(t, err) + require.Len(t, caller.calls, 2) + assert.Contains(t, caller.calls[0].URL, "sendPhoto") + assert.Contains(t, caller.calls[1].URL, "sendDocument") + require.Len(t, constructor.calls, 2) + assert.Equal(t, len(content), constructor.calls[0].FileSizes["photo"]) + assert.Equal(t, len(content), constructor.calls[1].FileSizes["document"]) + assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"]) +} + +func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) { + constructor := &multipartRecordingConstructor{} + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return nil, errors.New("api: 500 \"server exploded\"") + }, + } + ch := newTestChannelWithConstructor(t, caller, constructor) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "image.png") + require.NoError(t, os.WriteFile(localPath, []byte("fake-png-content"), 0o644)) + + ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1") + require.NoError(t, err) + + err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "12345", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: ref, + }}, + }) + + require.Error(t, err) + assert.ErrorIs(t, err, channels.ErrTemporary) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendPhoto") + require.Len(t, constructor.calls, 1) + assert.NotContains(t, caller.calls[0].URL, "sendDocument") +} + func TestSend_EmptyContent(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { @@ -235,6 +376,55 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { ) } +func TestSend_HTMLOverflow_WordBoundary(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + // We want to force a split near index ~2600 while keeping markdown length <= 4000. + // Prefix of 430 bold units (6 chars each) = 2580 chars. + // Expansion per unit is +3 chars when converted to HTML, so 2580 + 430*3 = 3870. + prefix := strings.Repeat("**a** ", 430) + targetWord := "TARGETWORDTHATSTAYSTOGETHER" + // Suffix of 230 bold units (6 chars each) = 1380 chars. + // Total markdown length: 2580 (prefix) + 27 (target word) + 1380 (suffix) = 3987 <= 4000. + // HTML expansion adds ~3 chars per bold unit: (430 + 230)*3 = 1980 extra chars, + // so total HTML length comfortably exceeds 4096. + suffix := strings.Repeat(" **b**", 230) + content := prefix + targetWord + suffix + + // Ensure the test content matches the intended boundary conditions. + assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test") + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "123456", + Content: content, + }) + + assert.NoError(t, err) + + foundFullWord := false + for i, call := range caller.calls { + var params map[string]any + err := json.Unmarshal(call.Data.BodyRaw, ¶ms) + require.NoError(t, err) + text, _ := params["text"].(string) + + hasWord := strings.Contains(text, targetWord) + t.Logf("Chunk %d length: %d, contains target word: %v", i, len(text), hasWord) + + if hasWord { + foundFullWord = true + break + } + } + + assert.True(t, foundFullWord, "The target word should not be split between chunks") +} + func TestSend_NotRunning(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { @@ -355,10 +545,7 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { err := ch.handleMessage(context.Background(), msg) require.NoError(t, err) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) + inbound, ok := <-messageBus.InboundChan() require.True(t, ok, "expected inbound message") // Composite chatID should include thread ID @@ -397,10 +584,7 @@ func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { err := ch.handleMessage(context.Background(), msg) require.NoError(t, err) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) + inbound, ok := <-messageBus.InboundChan() require.True(t, ok) // Plain chatID without thread suffix @@ -443,10 +627,7 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { err := ch.handleMessage(context.Background(), msg) require.NoError(t, err) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) + inbound, ok := <-messageBus.InboundChan() require.True(t, ok) // chatID should NOT include thread suffix for non-forum groups diff --git a/pkg/channels/telegram/testdata/md2_all_formats.txt b/pkg/channels/telegram/testdata/md2_all_formats.txt new file mode 100644 index 000000000..f78fcc72f --- /dev/null +++ b/pkg/channels/telegram/testdata/md2_all_formats.txt @@ -0,0 +1,31 @@ +*bold \*text* +_italic \*text_ +__underline__ +~strikethrough~ +||spoiler|| +*bold _italic bold ~italic bold strikethrough ||italic bold strikethrough spoiler||~ __underline italic bold___ bold* +[inline URL](http://www.example.com/) +[inline mention of a user](tg://user?id=123456789) +![👍](tg://emoji?id=5368324170671202286) +![22:45 tomorrow](tg://time?unix=1647531900&format=wDT) +![22:45 tomorrow](tg://time?unix=1647531900&format=t) +![22:45 tomorrow](tg://time?unix=1647531900&format=r) +![22:45 tomorrow](tg://time?unix=1647531900) +`inline fixed-width code` +``` +pre-formatted fixed-width code block +``` +```python +pre-formatted fixed-width code block written in the Python programming language +``` +>Block quotation started +>Block quotation continued +>Block quotation continued +>Block quotation continued +>The last line of the block quotation +**>The expandable block quotation started right after the previous block quotation +>It is separated from the previous block quotation by an empty bold entity +>Expandable block quotation continued +>Hidden by default part of the expandable block quotation started +>Expandable block quotation continued +>The last line of the expandable block quotation with the expandability mark|| diff --git a/pkg/channels/whatsapp/whatsapp_command_test.go b/pkg/channels/whatsapp/whatsapp_command_test.go index ee8aa4a52..2d85d74f8 100644 --- a/pkg/channels/whatsapp/whatsapp_command_test.go +++ b/pkg/channels/whatsapp/whatsapp_command_test.go @@ -3,7 +3,6 @@ package whatsapp import ( "context" "testing" - "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -25,10 +24,7 @@ func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T "content": "/help", }) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) + inbound, ok := <-messageBus.InboundChan() if !ok { t.Fatal("expected inbound message to be forwarded") } diff --git a/pkg/channels/whatsapp_native/whatsapp_command_test.go b/pkg/channels/whatsapp_native/whatsapp_command_test.go index cc2dcb619..e51bec392 100644 --- a/pkg/channels/whatsapp_native/whatsapp_command_test.go +++ b/pkg/channels/whatsapp_native/whatsapp_command_test.go @@ -43,14 +43,19 @@ func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - inbound, ok := messageBus.ConsumeInbound(ctx) - if !ok { - t.Fatal("expected inbound message to be forwarded") - } - if inbound.Channel != "whatsapp_native" { - t.Fatalf("channel=%q", inbound.Channel) - } - if inbound.Content != "/new" { - t.Fatalf("content=%q", inbound.Content) + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for message to be forwarded") + return + case inbound, ok := <-messageBus.InboundChan(): + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Channel != "whatsapp_native" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.Content != "/new" { + t.Fatalf("content=%q", inbound.Content) + } } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9649d519f..72a5aa656 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -318,6 +320,7 @@ type TelegramConfig struct { WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"` SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"` HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"` + UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } type FeishuConfig struct { @@ -331,6 +334,7 @@ type FeishuConfig struct { 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"` } type DiscordConfig struct { @@ -538,6 +542,7 @@ type ProvidersConfig struct { Minimax ProviderConfig `json:"minimax"` LongCat ProviderConfig `json:"longcat"` ModelScope ProviderConfig `json:"modelscope"` + Novita ProviderConfig `json:"novita"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -566,7 +571,8 @@ func (p ProvidersConfig) IsEmpty() bool { 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.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" && + p.Novita.APIKey == "" && p.Novita.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig @@ -596,7 +602,9 @@ type OpenAIProviderConfig struct { // ModelConfig represents a model-centric provider configuration. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only. // The model field uses protocol prefix format: [protocol/]model-identifier -// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot +// Supported protocols 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 @@ -604,9 +612,11 @@ type ModelConfig struct { 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 - Proxy string `json:"proxy,omitempty"` // HTTP proxy URL + 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 @@ -633,8 +643,9 @@ func (c *ModelConfig) Validate() error { } type GatewayConfig struct { - Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` - Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + 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"` } type ToolDiscoveryConfig struct { @@ -700,15 +711,24 @@ type WebToolsConfig struct { Perplexity PerplexityConfig ` json:"perplexity"` SearXNG SearXNGConfig ` json:"searxng"` GLMSearch GLMSearchConfig ` json:"glm_search"` + // 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" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` + Proxy string `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 CronToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` - ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` + ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout + AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"` } type ExecConfig struct { @@ -758,6 +778,7 @@ type ToolsConfig struct { 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_"` @@ -847,10 +868,27 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + if passphrase := credential.PassphraseProvider(); passphrase != "" { + for _, m := range cfg.ModelList { + if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") { + fmt.Fprintf(os.Stderr, + "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", + m.ModelName) + } + } + } + if err := env.Parse(cfg); err != nil { return nil, err } + if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil { + return nil, 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() @@ -867,6 +905,59 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values +// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or +// empty). Returns (nil, error) if any key fails to encrypt — callers must treat +// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk. +// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig +// and leave JSON marshaling to the caller. +func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) { + sealed := make([]ModelConfig, len(models)) + copy(sealed, models) + changed := false + for i := range sealed { + m := &sealed[i] + if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") { + continue + } + encrypted, err := credential.Encrypt(passphrase, "", m.APIKey) + if err != nil { + return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err) + } + m.APIKey = encrypted + changed = true + } + if !changed { + return nil, nil + } + return sealed, nil +} + +// resolveAPIKeys decrypts or dereferences each api_key in models in-place. +// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). +// Also resolves api_keys array if present. +func resolveAPIKeys(models []ModelConfig, configDir string) error { + cr := credential.NewResolver(configDir) + for i := range models { + // Resolve single APIKey + resolved, err := cr.Resolve(models[i].APIKey) + if err != nil { + return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err) + } + models[i].APIKey = resolved + + // Resolve APIKeys array + for j, key := range models[i].APIKeys { + resolved, err := cr.Resolve(key) + if err != nil { + return fmt.Errorf("model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err) + } + models[i].APIKeys[j] = resolved + } + } + return nil +} + func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { @@ -881,12 +972,22 @@ func (c *Config) migrateChannelConfigs() { } func SaveConfig(path string, cfg *Config) error { + if passphrase := credential.PassphraseProvider(); passphrase != "" { + sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase) + if err != nil { + return err + } + if sealed != nil { + tmp := *cfg + tmp.ModelList = sealed + cfg = &tmp + } + } + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } - - // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(path, data, 0o600) } @@ -968,7 +1069,7 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { } // Multiple configs - use round-robin for load balancing - idx := rrCounter.Add(1) % uint64(len(matches)) + idx := (rrCounter.Add(1) - 1) % uint64(len(matches)) return &matches[idx], nil } @@ -1036,6 +1137,89 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string { 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_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]} +// - {"model_name": "gpt-4__key_1", "api_key": "k2"} +// - {"model_name": "gpt-4__key_2", "api_key": "k3"} +func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { + var expanded []ModelConfig + + for _, m := range models { + keys := MergeAPIKeys(m.APIKey, m.APIKeys) + + // Single key or no keys: keep as-is + if len(keys) <= 1 { + // Ensure APIKey is set from APIKeys if needed + if m.APIKey == "" && len(keys) == 1 { + m.APIKey = keys[0] + } + m.APIKeys = nil // Clear APIKeys to avoid confusion + expanded = append(expanded, m) + continue + } + + // Multiple keys: expand + originalName := m.ModelName + + // Create entries for additional keys (key_1, key_2, ...) + var fallbackNames []string + for i := 1; i < len(keys); i++ { + suffix := fmt.Sprintf("__key_%d", i) + expandedName := originalName + suffix + + // Create a copy for the additional key + additionalEntry := ModelConfig{ + ModelName: expandedName, + Model: m.Model, + APIBase: m.APIBase, + APIKey: 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, + } + expanded = append(expanded, additionalEntry) + fallbackNames = append(fallbackNames, expandedName) + } + + // Create the primary entry with first key and fallbacks + primaryEntry := ModelConfig{ + ModelName: originalName, + Model: m.Model, + APIBase: m.APIBase, + APIKey: keys[0], + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + } + + // Prepend new fallbacks to existing ones + if len(fallbackNames) > 0 { + primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...) + } else if len(m.Fallbacks) > 0 { + primaryEntry.Fallbacks = m.Fallbacks + } + + expanded = append(expanded, primaryEntry) + } + + return expanded +} + func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": @@ -1066,6 +1250,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.ReadFile.Enabled case "spawn": return t.Spawn.Enabled + case "spawn_status": + return t.SpawnStatus.Enabled case "spi": return t.SPI.Enabled case "subagent": diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c5bdbf3c3..588c04645 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -7,8 +7,22 @@ import ( "runtime" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/credential" ) +// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets +// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required +// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig. +func mustSetupSSHKey(t *testing.T) { + t.Helper() + keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key") + if err := credential.GenerateSSHKey(keyPath); err != nil { + t.Fatalf("mustSetupSSHKey: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath) +} + func TestAgentModelConfig_UnmarshalString(t *testing.T) { var m AgentModelConfig if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil { @@ -63,6 +77,22 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) { } } +func TestProvidersConfig_IsEmpty(t *testing.T) { + var empty ProvidersConfig + if !empty.IsEmpty() { + t.Fatal("empty ProvidersConfig should report empty") + } + + novita := ProvidersConfig{ + Novita: ProviderConfig{ + APIKey: "test-key", + }, + } + if novita.IsEmpty() { + t.Fatal("ProvidersConfig with novita settings should not report empty") + } +} + func TestAgentConfig_FullParse(t *testing.T) { jsonData := `{ "agents": { @@ -253,6 +283,9 @@ func TestDefaultConfig_Gateway(t *testing.T) { if cfg.Gateway.Port == 0 { t.Error("Gateway port should have default value") } + if cfg.Gateway.HotReload { + t.Error("Gateway hot reload should be disabled by default") + } } // TestDefaultConfig_Providers verifies provider structure @@ -384,6 +417,45 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { } } +func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Web.PreferNative { + t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true") + } +} + +func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Web.PreferNative { + t.Fatal("PreferNative should remain true when unset in config file") + } +} + +func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":false}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Tools.Web.PreferNative { + t.Fatal("PreferNative should be false when disabled in config file") + } +} + func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Exec.AllowRemote { @@ -391,6 +463,13 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { } } +func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Cron.AllowCommand { + t.Fatal("DefaultConfig().Tools.Cron.AllowCommand should be true") + } +} + func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -423,6 +502,22 @@ func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { } } +func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"cron":{"exec_timeout_minutes":5}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Cron.AllowCommand { + t.Fatal("tools.cron.allow_command should remain true when unset in config file") + } +} + func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -482,13 +577,19 @@ func TestDefaultConfig_DMScope(t *testing.T) { } func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { - // Unset to ensure we test the default t.Setenv("PICOCLAW_HOME", "") - // Set a known home for consistent test results - t.Setenv("HOME", "/tmp/home") + + var fakeHome string + if runtime.GOOS == "windows" { + fakeHome = `C:\tmp\home` + t.Setenv("USERPROFILE", fakeHome) + } else { + fakeHome = "/tmp/home" + t.Setenv("HOME", fakeHome) + } cfg := DefaultConfig() - want := filepath.Join("/tmp/home", ".picoclaw", "workspace") + want := filepath.Join(fakeHome, ".picoclaw", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -499,7 +600,7 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home") cfg := DefaultConfig() - want := "/custom/picoclaw/home/workspace" + want := filepath.Join("/custom/picoclaw/home", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -621,3 +722,338 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { } }) } + +// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext +// api_key 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 = `{"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) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + // In-memory value must be the resolved plaintext. + if cfg.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext") + } + // The file on disk must remain unchanged — LoadConfig must not write anything. + raw, _ := os.ReadFile(cfgPath) + if string(raw) != original { + t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw)) + } +} + +// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext +// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext. +func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + cfg := DefaultConfig() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Disk must contain enc://, not the raw key. + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("saved file should contain enc://, got:\n%s", string(raw)) + } + if strings.Contains(string(raw), "sk-plaintext") { + t.Errorf("saved file must not contain the plaintext key") + } + + // A fresh load must decrypt back to the original plaintext. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + if cfg2.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext") + } +} + +// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left +// unchanged when PICOCLAW_KEY_PASSPHRASE is not set. +func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if strings.Contains(string(raw), "enc://") { + t.Error("config file must not be modified when no passphrase is set") + } +} + +// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not +// converted to enc:// values (they are resolved at runtime by the Resolver). +func TestLoadConfig_FileRefNotSealed(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + keyFile := filepath.Join(dir, "openai.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "file://openai.key") { + t.Error("file:// reference should be preserved unchanged in the config file") + } + if strings.Contains(string(raw), "enc://") { + t.Error("file:// reference must not be converted to enc://") + } +} + +// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys +// and leaves already-encrypted (enc://) and file:// entries unchanged. +func TestSaveConfig_MixedKeys(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + // Pre-encrypt one key so we have a genuine enc:// value to put in the config. + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + // Extract the enc:// value from the saved file. + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { + t.Fatalf("setup: could not parse saved config: %v", err) + } + alreadyEncrypted := tmp.ModelList[0].APIKey + if !strings.HasPrefix(alreadyEncrypted, "enc://") { + t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted) + } + + // Build a config with three models: + // 1. plaintext → must be encrypted by SaveConfig + // 2. enc:// → must be left unchanged (already encrypted) + // 3. file:// → must be left unchanged (file reference) + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + cfg := &Config{ + ModelList: []ModelConfig{ + {ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"}, + {ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted}, + {ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"}, + }, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ = os.ReadFile(cfgPath) + s := string(raw) + + // 1. Plaintext must be encrypted. + if strings.Contains(s, "sk-new-plaintext") { + t.Error("plaintext key must not appear in saved file") + } + // 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged). + if !strings.Contains(s, alreadyEncrypted) { + t.Error("pre-existing enc:// entry must be preserved unchanged") + } + // 3. file:// must be preserved. + if !strings.Contains(s, "file://api.key") { + t.Error("file:// reference must be preserved unchanged") + } + + // Now load and verify all three decrypt/resolve correctly. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + byName := make(map[string]string) + for _, m := range cfg2.ModelList { + byName[m.ModelName] = m.APIKey + } + if byName["plain"] != "sk-new-plaintext" { + t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext") + } + if byName["enc"] != "sk-already-plain" { + t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain") + } + if byName["file"] != "sk-from-file" { + t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file") + } +} + +// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE +// is not set, enc:// entries cause LoadConfig to return an error, while plaintext +// and file:// entries in the same config are not affected. +func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // First encrypt a key so we have a real enc:// value. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil { + t.Fatalf("setup parse: %v", err) + } + encValue := tmp.ModelList[0].APIKey + + // Write a mixed config: enc:// + plaintext + file:// + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + mixed, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue}, + {"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"}, + {"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"}, + }, + }) + if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil { + t.Fatalf("setup write: %v", err) + } + + // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + _, err := LoadConfig(cfgPath) + if err == nil { + t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set") + } + if !strings.Contains(err.Error(), "passphrase required") { + t.Errorf("error should mention passphrase required, got: %v", err) + } +} + +// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext +// api_keys using credential.PassphraseProvider() rather than os.Getenv directly. +// This matters for the launcher, which clears the environment variable and redirects +// PassphraseProvider to an in-memory SecureStore. +func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty — passphrase must come from PassphraseProvider only. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + // Replace PassphraseProvider with an in-memory function (simulating SecureStore). + const testPassphrase = "provider-passphrase" + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + cfg := DefaultConfig() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) + } +} + +// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys +// using credential.PassphraseProvider() rather than os.Getenv directly. +func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty throughout. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + const testPassphrase = "provider-passphrase" + const plainKey = "sk-secret" + + // First, encrypt the key using the same passphrase. + encrypted, err := credential.Encrypt(testPassphrase, "", plainKey) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + raw, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted}, + }, + }) + if err = os.WriteFile(cfgPath, raw, 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Redirect PassphraseProvider — env var is empty, so without this the load would fail. + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.ModelList[0].APIKey != plainKey { + t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 189af0a84..d2bd580fd 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -15,7 +15,7 @@ func DefaultConfig() *Config { // Determine the base path for the workspace. // Priority: $PICOCLAW_HOME > ~/.picoclaw var homePath string - if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { + if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { homePath = picoclawHome } else { userHome, _ := os.UserHomeDir() @@ -58,6 +58,7 @@ func DefaultConfig() *Config { Enabled: true, Text: "Thinking... 💭", }, + UseMarkdownV2: false, }, Feishu: FeishuConfig{ Enabled: false, @@ -386,8 +387,9 @@ func DefaultConfig() *Config { }, }, Gateway: GatewayConfig{ - Host: "127.0.0.1", - Port: 18790, + Host: "127.0.0.1", + Port: 18790, + HotReload: false, }, Tools: ToolsConfig{ MediaCleanup: MediaCleanupConfig{ @@ -401,8 +403,10 @@ func DefaultConfig() *Config { ToolConfig: ToolConfig{ Enabled: true, }, + PreferNative: true, Proxy: "", FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default + Format: "plaintext", Brave: BraveConfig{ Enabled: false, APIKey: "", @@ -443,6 +447,7 @@ func DefaultConfig() *Config { Enabled: true, }, ExecTimeoutMinutes: 5, + AllowCommand: true, }, Exec: ExecConfig{ ToolConfig: ToolConfig{ @@ -512,6 +517,9 @@ func DefaultConfig() *Config { Spawn: ToolConfig{ Enabled: true, }, + SpawnStatus: ToolConfig{ + Enabled: false, + }, SPI: ToolConfig{ Enabled: false, // Hardware tool - Linux only }, diff --git a/pkg/config/envkeys.go b/pkg/config/envkeys.go new file mode 100644 index 000000000..b04ff19f5 --- /dev/null +++ b/pkg/config/envkeys.go @@ -0,0 +1,37 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +// Runtime environment variable keys for the picoclaw process. +// These control the location of files and binaries at runtime and are read +// directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the +// PICOCLAW_ prefix. Reference these constants instead of inline string +// literals to keep all supported knobs visible in one place and to prevent +// typos. +const ( + // EnvHome overrides the base directory for all picoclaw data + // (config, workspace, skills, auth store, …). + // Default: ~/.picoclaw + EnvHome = "PICOCLAW_HOME" + + // EnvConfig overrides the full path to the JSON config file. + // Default: $PICOCLAW_HOME/config.json + EnvConfig = "PICOCLAW_CONFIG" + + // EnvBuiltinSkills overrides the directory from which built-in + // skills are loaded. + // Default: /skills + EnvBuiltinSkills = "PICOCLAW_BUILTIN_SKILLS" + + // EnvBinary overrides the path to the picoclaw executable. + // Used by the web launcher when spawning the gateway subprocess. + // Default: resolved from the same directory as the current executable. + EnvBinary = "PICOCLAW_BINARY" + + // EnvGatewayHost overrides the host address for the gateway server. + // Default: "127.0.0.1" + EnvGatewayHost = "PICOCLAW_GATEWAY_HOST" +) diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index da6e506f8..9bc600ed9 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -80,6 +80,36 @@ func TestGetModelConfig_RoundRobin(t *testing.T) { } } +func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { + rrCounter.Store(0) + + cfg := &Config{ + ModelList: []ModelConfig{ + {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, + {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, + {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"}, + }, + } + + wantOrder := []string{ + "openai/gpt-4o-1", + "openai/gpt-4o-2", + "openai/gpt-4o-3", + "openai/gpt-4o-1", + "openai/gpt-4o-2", + } + + for i, want := range wantOrder { + result, err := cfg.GetModelConfig("lb-model") + if err != nil { + t.Fatalf("GetModelConfig() call %d error = %v", i, err) + } + if result.Model != want { + t.Fatalf("GetModelConfig() call %d model = %q, want %q", i, result.Model, want) + } + } +} + func TestGetModelConfig_Concurrent(t *testing.T) { cfg := &Config{ ModelList: []ModelConfig{ diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go new file mode 100644 index 000000000..b899b991c --- /dev/null +++ b/pkg/config/multikey_test.go @@ -0,0 +1,291 @@ +package config + +import ( + "testing" +) + +func TestExpandMultiKeyModels_SingleKey(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKey: "single-key", + }, + } + + result := ExpandMultiKeyModels(models) + + if len(result) != 1 { + t.Fatalf("expected 1 model, got %d", len(result)) + } + + if result[0].ModelName != "gpt-4" { + t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName) + } + + if result[0].APIKey != "single-key" { + t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey) + } + + if len(result[0].Fallbacks) != 0 { + t.Errorf("expected no fallbacks, got %v", result[0].Fallbacks) + } +} + +func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "glm-4.7", + Model: "zhipu/glm-4.7", + APIBase: "https://api.example.com", + APIKeys: []string{"key1", "key2", "key3"}, + }, + } + + result := ExpandMultiKeyModels(models) + + // Should expand to 3 models + if len(result) != 3 { + t.Fatalf("expected 3 models, got %d", len(result)) + } + + // First entry should be the primary with key1 and fallbacks + primary := result[2] // Primary is added last + if primary.ModelName != "glm-4.7" { + t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName) + } + if primary.APIKey != "key1" { + t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey) + } + if len(primary.Fallbacks) != 2 { + t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) + } + if primary.Fallbacks[0] != "glm-4.7__key_1" { + t.Errorf("expected first fallback 'glm-4.7__key_1', got %q", primary.Fallbacks[0]) + } + if primary.Fallbacks[1] != "glm-4.7__key_2" { + t.Errorf("expected second fallback 'glm-4.7__key_2', got %q", primary.Fallbacks[1]) + } + + // Second entry should be key2 + second := result[0] + if second.ModelName != "glm-4.7__key_1" { + t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName) + } + if second.APIKey != "key2" { + t.Errorf("expected second api_key 'key2', got %q", second.APIKey) + } + + // Third entry should be key3 + third := result[1] + if third.ModelName != "glm-4.7__key_2" { + t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName) + } + if third.APIKey != "key3" { + t.Errorf("expected third api_key 'key3', got %q", third.APIKey) + } +} + +func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKey: "key0", + APIKeys: []string{"key1", "key2"}, + }, + } + + result := ExpandMultiKeyModels(models) + + // Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys) + if len(result) != 3 { + t.Fatalf("expected 3 models, got %d", len(result)) + } + + // Primary should use key0 + primary := result[2] + if primary.APIKey != "key0" { + t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey) + } + if len(primary.Fallbacks) != 2 { + t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) + } +} + +func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: []string{"key1", "key2"}, + Fallbacks: []string{"claude-3"}, + }, + } + + result := ExpandMultiKeyModels(models) + + primary := result[1] + // With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total + if len(primary.Fallbacks) != 2 { + t.Fatalf("expected 2 fallbacks, got %d: %v", len(primary.Fallbacks), primary.Fallbacks) + } + + // Key fallbacks should come first, then existing fallbacks + if primary.Fallbacks[0] != "gpt-4__key_1" { + t.Errorf("expected first fallback 'gpt-4__key_1', got %q", primary.Fallbacks[0]) + } + if primary.Fallbacks[1] != "claude-3" { + t.Errorf("expected second fallback 'claude-3', got %q", primary.Fallbacks[1]) + } +} + +func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKey: "", + APIKeys: []string{}, + }, + } + + result := ExpandMultiKeyModels(models) + + // Should keep as-is with no changes + if len(result) != 1 { + t.Fatalf("expected 1 model, got %d", len(result)) + } + + if result[0].ModelName != "gpt-4" { + t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName) + } +} + +func TestExpandMultiKeyModels_Deduplication(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKey: "key1", + APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1 + }, + } + + result := ExpandMultiKeyModels(models) + + // Should only create 2 models (deduplicated keys) + if len(result) != 2 { + t.Fatalf("expected 2 models (deduplicated), got %d", len(result)) + } + + primary := result[1] + if primary.APIKey != "key1" { + t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey) + } + if len(primary.Fallbacks) != 1 { + t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks)) + } +} + +func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIBase: "https://api.example.com", + APIKeys: []string{"key1", "key2"}, + Proxy: "http://proxy:8080", + RPM: 60, + MaxTokensField: "max_completion_tokens", + RequestTimeout: 30, + ThinkingLevel: "high", + }, + } + + result := ExpandMultiKeyModels(models) + + // Check primary entry preserves all fields + primary := result[1] + if primary.APIBase != "https://api.example.com" { + t.Errorf("expected api_base preserved, got %q", primary.APIBase) + } + if primary.Proxy != "http://proxy:8080" { + t.Errorf("expected proxy preserved, got %q", primary.Proxy) + } + if primary.RPM != 60 { + t.Errorf("expected rpm preserved, got %d", primary.RPM) + } + if primary.MaxTokensField != "max_completion_tokens" { + t.Errorf("expected max_tokens_field preserved, got %q", primary.MaxTokensField) + } + if primary.RequestTimeout != 30 { + t.Errorf("expected request_timeout preserved, got %d", primary.RequestTimeout) + } + if primary.ThinkingLevel != "high" { + t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel) + } + + // Check additional entry also preserves fields + additional := result[0] + 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) + } +} + +func TestMergeAPIKeys(t *testing.T) { + tests := []struct { + name string + apiKey string + apiKeys []string + expected []string + }{ + { + name: "both empty", + apiKey: "", + apiKeys: nil, + expected: nil, + }, + { + name: "only apiKey", + apiKey: "key1", + apiKeys: nil, + expected: []string{"key1"}, + }, + { + name: "only apiKeys", + apiKey: "", + apiKeys: []string{"key1", "key2"}, + expected: []string{"key1", "key2"}, + }, + { + name: "both with overlap", + apiKey: "key1", + apiKeys: []string{"key1", "key2", "key3"}, + expected: []string{"key1", "key2", "key3"}, + }, + { + name: "with whitespace", + apiKey: " key1 ", + apiKeys: []string{" key2 ", " key1 "}, + expected: []string{"key1", "key2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MergeAPIKeys(tt.apiKey, tt.apiKeys) + if len(result) != len(tt.expected) { + t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result)) + } + for i, k := range result { + if k != tt.expected[i] { + t.Errorf("expected key[%d] = %q, got %q", i, tt.expected[i], k) + } + } + }) + } +} diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go new file mode 100644 index 000000000..b65c19446 --- /dev/null +++ b/pkg/credential/credential.go @@ -0,0 +1,342 @@ +// Package credential resolves API credential values for model_list entries. +// +// An API key is a form of authorization credential. This package centralizes +// how raw credential strings—plaintext or file references—are resolved into +// their actual values, keeping that logic out of the config loader. +// +// Supported formats for the api_key field: +// +// - Plaintext: "sk-abc123" → returned as-is +// - File ref: "file://filename.key" → content read from configDir/filename.key +// - Encrypted: "enc://" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE +// - Empty: "" → returned as-is (auth_method=oauth etc.) +// +// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). +// An SSH private key is required for both encryption and decryption. +// Key derivation: +// +// HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info) +// +// SSH key path resolution priority: +// +// 1. sshKeyPath argument to Encrypt (explicit) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform) +package credential + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// PassphraseEnvVar is the environment variable that holds the encryption passphrase. +// Other packages (e.g. config) reference this constant to avoid duplicating the string. +const PassphraseEnvVar = "PICOCLAW_KEY_PASSPHRASE" + +// PassphraseProvider is the function used to retrieve the passphrase for enc:// +// credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the +// process environment. Replace it at startup to use a different source, such as +// an in-memory SecureStore, so that all LoadConfig() calls everywhere share the +// same passphrase source without needing os.Environ. +// +// Example (launcher main.go): +// +// credential.PassphraseProvider = apiHandler.passphraseStore.Get +var PassphraseProvider func() string = func() string { + return os.Getenv(PassphraseEnvVar) +} + +// ErrPassphraseRequired is returned when an enc:// credential is encountered but +// no passphrase is available from PassphraseProvider. Callers can detect this +// with errors.Is to distinguish a missing-passphrase condition from other errors. +var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required") + +// ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted, +// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is. +var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)") + +// SSHKeyPathEnvVar is the environment variable that specifies the path to the +// SSH private key used for enc:// credential encryption and decryption. +const SSHKeyPathEnvVar = "PICOCLAW_SSH_KEY_PATH" + +// picoclawHome is a package-local copy of config.EnvHome. It is kept here to +// avoid a circular import between pkg/credential and pkg/config. +const picoclawHome = "PICOCLAW_HOME" + +const ( + fileScheme = "file://" + encScheme = "enc://" + hkdfInfo = "picoclaw-credential-v1" + saltLen = 16 + nonceLen = 12 + keyLen = 32 +) + +// Resolver resolves raw credential strings for model_list api_key fields. +// File references are resolved relative to the directory of the config file. +type Resolver struct { + configDir string + resolvedConfigDir string // symlink-resolved form of configDir +} + +// NewResolver returns a Resolver that resolves file:// references relative to +// configDir (typically filepath.Dir of the config file path). +func NewResolver(configDir string) *Resolver { + resolved := configDir + if configDir != "" { + if linkedPath, err := filepath.EvalSymlinks(configDir); err == nil { + resolved = linkedPath + } + } + return &Resolver{configDir: configDir, resolvedConfigDir: resolved} +} + +// Resolve returns the actual credential value for raw: +// +// - "" → "" (no error; auth_method=oauth needs no key) +// - "file://name.key" → trimmed content of configDir/name.key +// - anything else → raw unchanged (plaintext credential) +func (r *Resolver) Resolve(raw string) (string, error) { + if raw == "" { + return "", nil + } + + if strings.HasPrefix(raw, fileScheme) { + fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme)) + if fileName == "" { + return "", fmt.Errorf("credential: file:// reference has no filename") + } + + baseDir := r.resolvedConfigDir + if baseDir == "" { + baseDir = r.configDir + } + keyPath := filepath.Join(baseDir, fileName) + // Resolve symlinks before enforcing containment to prevent escaping via symlinks. + realKeyPath, err := filepath.EvalSymlinks(keyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err) + } + if !isWithinDir(realKeyPath, baseDir) { + return "", fmt.Errorf("credential: file:// path escapes config directory") + } + data, err := os.ReadFile(realKeyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err) + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath) + } + + return value, nil + } + + if strings.HasPrefix(raw, encScheme) { + return resolveEncrypted(raw) + } + + // Plaintext credential — return unchanged. + return raw, nil +} + +// resolveEncrypted decrypts an enc:// credential using PassphraseProvider. +func resolveEncrypted(raw string) (string, error) { + passphrase := PassphraseProvider() + if passphrase == "" { + return "", ErrPassphraseRequired + } + + sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect + + b64 := strings.TrimPrefix(raw, encScheme) + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("credential: enc:// invalid base64: %w", err) + } + if len(blob) < saltLen+nonceLen+1 { + return "", fmt.Errorf("credential: enc:// payload too short") + } + + salt := blob[:saltLen] + nonce := blob[saltLen : saltLen+nonceLen] + ciphertext := blob[saltLen+nonceLen:] + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: enc:// cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: enc:// gcm init: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrDecryptionFailed, err) + } + return string(plaintext), nil +} + +// Encrypt encrypts plaintext and returns an enc:// credential string. +// +// passphrase is required (PICOCLAW_KEY_PASSPHRASE value). +// sshKeyPath is the SSH private key file to use; pass "" to auto-detect via +// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key. +// An SSH private key must be resolvable or Encrypt returns an error. +func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { + if passphrase == "" { + return "", fmt.Errorf("credential: passphrase must not be empty") + } + sshKeyPath = pickSSHKeyPath(sshKeyPath) + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("credential: failed to generate salt: %w", err) + } + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: gcm init: %w", err) + } + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("credential: failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil) + blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) + blob = append(blob, salt...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + return encScheme + base64.StdEncoding.EncodeToString(blob), nil +} + +// isWithinDir reports whether path is contained within (or equal to) dir. +// Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection. +func isWithinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + return err == nil && filepath.IsLocal(rel) +} + +// allowedSSHKeyPath reports whether path is in a permitted location for SSH key files: +// - exact match with PICOCLAW_SSH_KEY_PATH env var +// - within the PICOCLAW_HOME env var directory +// - within ~/.ssh/ +func allowedSSHKeyPath(path string) bool { + if path == "" { + return true // passphrase-only mode; no file will be read + } + clean := filepath.Clean(path) + + // Exact match with PICOCLAW_SSH_KEY_PATH. + if envPath, ok := os.LookupEnv(SSHKeyPathEnvVar); ok && envPath != "" { + if clean == filepath.Clean(envPath) { + return true + } + } + + // Within PICOCLAW_HOME. + if picoHome := os.Getenv(picoclawHome); picoHome != "" { + if isWithinDir(clean, picoHome) { + return true + } + } + + // Within ~/.ssh/. + if userHome, err := os.UserHomeDir(); err == nil { + if isWithinDir(clean, filepath.Join(userHome, ".ssh")) { + return true + } + } + + return false +} + +// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key. +// +// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase) +// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +// sshKeyPath must be non-empty; returns an error otherwise. +func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) { + if sshKeyPath == "" { + return nil, fmt.Errorf( + "credential: SSH private key is required but not found" + + " (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)") + } + if !allowedSSHKeyPath(sshKeyPath) { + return nil, fmt.Errorf( + "credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", + sshKeyPath, + ) + } + sshBytes, err := os.ReadFile(sshKeyPath) + if err != nil { + return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err) + } + sshHash := sha256.Sum256(sshBytes) + mac := hmac.New(sha256.New, sshHash[:]) + mac.Write([]byte(passphrase)) + ikm := mac.Sum(nil) + + key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen) + if err != nil { + return nil, fmt.Errorf("credential: HKDF expand failed: %w", err) + } + return key, nil +} + +// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption. +// +// Priority: +// 1. override (non-empty explicit argument) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection) +// +// Returns "" when no key is found; deriveKey will return an error in that case. +func pickSSHKeyPath(override string) string { + if override != "" { + return override + } + if p, ok := os.LookupEnv(SSHKeyPathEnvVar); ok { + return p // respect explicit setting, even if "" + } + return findDefaultSSHKey() +} + +// findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists. +func findDefaultSSHKey() string { + p, err := DefaultSSHKeyPath() + if err != nil { + return "" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} diff --git a/pkg/credential/credential_test.go b/pkg/credential/credential_test.go new file mode 100644 index 000000000..138af3134 --- /dev/null +++ b/pkg/credential/credential_test.go @@ -0,0 +1,283 @@ +package credential_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +func TestResolve_PlainKey(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve("sk-plaintext-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-plaintext-key" { + t.Fatalf("got %q, want %q", got, "sk-plaintext-key") + } +} + +func TestResolve_FileKey_Success(t *testing.T) { + dir := t.TempDir() + keyFile := "openai_plain.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + got, err := r.Resolve("file://" + keyFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-from-file" { + t.Fatalf("got %q, want %q", got, "sk-from-file") + } +} + +func TestResolve_FileKey_NotFound(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("file://missing.key") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestResolve_FileKey_Empty(t *testing.T) { + dir := t.TempDir() + keyFile := "empty.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + _, err := r.Resolve("file://" + keyFile) + if err == nil { + t.Fatal("expected error for empty credential file, got nil") + } +} + +// TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key. +func TestResolve_EncKey_RoundTrip(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase-32bytes-long-ok!" + const plaintext = "sk-encrypted-secret" + + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, "", plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation. +func TestResolve_EncKey_WithSSHKey(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase" + const plaintext = "sk-ssh-protected-secret" + + // Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +func TestResolve_EncKey_NoPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("some-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil") + } +} + +func TestResolve_EncKey_BadCiphertext(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://!!not-valid-base64!!") + if err == nil { + t.Fatal("expected error for invalid enc:// payload, got nil") + } +} + +func TestResolve_EncKey_PayloadTooShort(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + // Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum. + import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://" + import64) + if err == nil { + t.Fatal("expected error for too-short enc:// payload, got nil") + } +} + +func TestResolve_EncKey_WrongPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected decryption error for wrong passphrase, got nil") + } +} + +func TestEncrypt_EmptyPassphrase(t *testing.T) { + _, err := credential.Encrypt("", "", "sk-secret") + if err == nil { + t.Fatal("expected error for empty passphrase, got nil") + } +} + +func TestDeriveKey_SSHKeyNotFound(t *testing.T) { + // Encrypt with a real SSH key path, then try to decrypt with a missing path. + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Register the real key path so allowedSSHKeyPath validation passes for Encrypt. + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Point to a non-existent SSH key so deriveKey's ReadFile fails. + // The path is still under the same dir, so allowedSSHKeyPath passes (exact env match). + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key")) + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when SSH key file is missing, got nil") + } +} + +// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir +// via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path"). +func TestResolve_FileRef_PathTraversal(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + // Create a file outside configDir that the traversal would point to. + outsideFile := filepath.Join(t.TempDir(), "secret.key") + if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(filepath.Dir(cfgPath)) + + cases := []string{ + "file://../../secret.key", + "file://../secret.key", + "file://" + outsideFile, // absolute path + } + for _, raw := range cases { + _, err := r.Resolve(raw) + if err == nil { + t.Errorf("Resolve(%q): expected path traversal error, got nil", raw) + } + } +} + +// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works. +func TestResolve_FileRef_withinConfigDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + r := credential.NewResolver(dir) + got, err := r.Resolve("file://my.key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-valid" { + t.Fatalf("got %q, want %q", got, "sk-valid") + } +} + +// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths +// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/. +func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Make sure none of the allowed env vars point here. + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + t.Setenv("PICOCLAW_HOME", "") + + _, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err == nil { + t.Fatal("expected error for SSH key outside allowed directories, got nil") + } +} diff --git a/pkg/credential/keygen.go b/pkg/credential/keygen.go new file mode 100644 index 000000000..c57564a76 --- /dev/null +++ b/pkg/credential/keygen.go @@ -0,0 +1,62 @@ +package credential + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +// DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key. +// The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform). +func DefaultSSHKeyPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("credential: cannot determine home directory: %w", err) + } + return filepath.Join(home, ".ssh", "picoclaw_ed25519.key"), nil +} + +// GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key +// to path (permissions 0600) and the public key to path+".pub" (permissions 0644). +// The ~/.ssh/ directory is created with 0700 if it does not exist. +// If the files already exist they are overwritten. +func GenerateSSHKey(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("credential: keygen: cannot create directory %q: %w", filepath.Dir(path), err) + } + + pubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("credential: keygen: ed25519 key generation failed: %w", err) + } + + // Marshal private key as OpenSSH PEM. + block, err := ssh.MarshalPrivateKey(privRaw, "") + if err != nil { + return fmt.Errorf("credential: keygen: marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(block) + + if err = os.WriteFile(path, privPEM, 0o600); err != nil { + return fmt.Errorf("credential: keygen: write private key %q: %w", path, err) + } + + // Marshal public key as authorized_keys line. + sshPub, err := ssh.NewPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("credential: keygen: marshal public key: %w", err) + } + pubLine := ssh.MarshalAuthorizedKey(sshPub) + + pubPath := path + ".pub" + if err := os.WriteFile(pubPath, pubLine, 0o644); err != nil { + return fmt.Errorf("credential: keygen: write public key %q: %w", pubPath, err) + } + + return nil +} diff --git a/pkg/credential/keygen_test.go b/pkg/credential/keygen_test.go new file mode 100644 index 000000000..1e21ea0b9 --- /dev/null +++ b/pkg/credential/keygen_test.go @@ -0,0 +1,115 @@ +package credential + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "runtime" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestGenerateSSHKey_CreatesFiles(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + // Private key must exist. + privInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("private key file missing: %v", err) + } + + // Check permissions on non-Windows (Windows does not support Unix permission bits). + if runtime.GOOS != "windows" { + if got := privInfo.Mode().Perm(); got != 0o600 { + t.Errorf("private key permissions = %04o, want 0600", got) + } + } + + // Public key must exist. + pubPath := keyPath + ".pub" + pubInfo, err := os.Stat(pubPath) + if err != nil { + t.Fatalf("public key file missing: %v", err) + } + if runtime.GOOS != "windows" { + if got := pubInfo.Mode().Perm(); got != 0o644 { + t.Errorf("public key permissions = %04o, want 0644", got) + } + } + + // Private key must be parseable as an OpenSSH ed25519 key. + privPEM, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read private key: %v", err) + } + privKey, err := ssh.ParseRawPrivateKey(privPEM) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + if _, ok := privKey.(*ed25519.PrivateKey); !ok { + t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey) + } + + // Public key must be parseable as authorized_keys line. + pubBytes, err := os.ReadFile(pubPath) + if err != nil { + t.Fatalf("read public key: %v", err) + } + pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes) + if err != nil { + t.Fatalf("parse public key: %v", err) + } + if pubKey == nil { + t.Fatal("expected non-nil public key") + } + if len(rest) > 0 { + t.Errorf("unexpected trailing bytes after public key: %d bytes", len(rest)) + } +} + +func TestGenerateSSHKey_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + // Generate twice; second call must not error and must produce a different key. + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("first GenerateSSHKey() error = %v", err) + } + first, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read first key: %v", err) + } + + if err = GenerateSSHKey(keyPath); err != nil { + t.Fatalf("second GenerateSSHKey() error = %v", err) + } + second, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read second key: %v", err) + } + + // Two independently generated Ed25519 keys must differ. + if string(first) == string(second) { + t.Error("expected overwritten key to differ from original") + } +} + +func TestGenerateSSHKey_CreatesDirectory(t *testing.T) { + dir := t.TempDir() + // Nested directory that does not yet exist. + keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + if _, err := os.Stat(keyPath); err != nil { + t.Fatalf("private key not created: %v", err) + } +} diff --git a/pkg/credential/store.go b/pkg/credential/store.go new file mode 100644 index 000000000..9c72974b0 --- /dev/null +++ b/pkg/credential/store.go @@ -0,0 +1,44 @@ +package credential + +import "sync/atomic" + +// SecureStore holds a passphrase in memory. +// +// Uses atomic.Pointer so reads and writes are lock-free. +// The passphrase is never written to disk; callers decide how to +// transport it outside this store (e.g., via cmd.Env or os.Environ). +type SecureStore struct { + val atomic.Pointer[string] +} + +// NewSecureStore creates an empty SecureStore. +func NewSecureStore() *SecureStore { + return &SecureStore{} +} + +// SetString stores the passphrase. An empty string clears the store. +func (s *SecureStore) SetString(passphrase string) { + if passphrase == "" { + s.val.Store(nil) + return + } + s.val.Store(&passphrase) +} + +// Get returns the stored passphrase, or "" if not set. +func (s *SecureStore) Get() string { + if p := s.val.Load(); p != nil { + return *p + } + return "" +} + +// IsSet reports whether a passphrase is currently stored. +func (s *SecureStore) IsSet() bool { + return s.val.Load() != nil +} + +// Clear removes the stored passphrase. +func (s *SecureStore) Clear() { + s.val.Store(nil) +} diff --git a/pkg/credential/store_test.go b/pkg/credential/store_test.go new file mode 100644 index 000000000..63299743a --- /dev/null +++ b/pkg/credential/store_test.go @@ -0,0 +1,81 @@ +package credential + +import ( + "sync" + "testing" +) + +func TestSecureStore_SetGet(t *testing.T) { + s := NewSecureStore() + if s.IsSet() { + t.Error("expected empty store") + } + + s.SetString("hunter2") + if !s.IsSet() { + t.Error("expected store to be set") + } + if got := s.Get(); got != "hunter2" { + t.Errorf("Get() = %q, want %q", got, "hunter2") + } +} + +func TestSecureStore_Clear(t *testing.T) { + s := NewSecureStore() + s.SetString("secret") + s.Clear() + + if s.IsSet() { + t.Error("expected store to be empty after Clear()") + } + if got := s.Get(); got != "" { + t.Errorf("Get() after Clear() = %q, want empty", got) + } +} + +func TestSecureStore_SetOverwrites(t *testing.T) { + s := NewSecureStore() + s.SetString("first") + s.SetString("second") + + if got := s.Get(); got != "second" { + t.Errorf("Get() = %q, want %q", got, "second") + } +} + +func TestSecureStore_EmptyPassphrase(t *testing.T) { + s := NewSecureStore() + s.SetString("") // empty → should not mark as set + + if s.IsSet() { + t.Error("empty passphrase should not mark store as set") + } +} + +func TestSecureStore_ConcurrentSetGet(t *testing.T) { + s := NewSecureStore() + const goroutines = 10 + const iterations = 1000 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + if id%2 == 0 { + s.SetString("even") + } else { + s.SetString("odd") + } + _ = s.Get() + } + }(i) + } + wg.Wait() + + final := s.Get() + if final != "" && final != "even" && final != "odd" { + t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final) + } +} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 04775ac42..77a413133 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -65,6 +65,7 @@ type CronService struct { mu sync.RWMutex running bool stopChan chan struct{} + wakeChan chan struct{} gronx *gronx.Gronx } @@ -73,6 +74,7 @@ func NewCronService(storePath string, onJob JobHandler) *CronService { storePath: storePath, onJob: onJob, gronx: gronx.New(), + wakeChan: make(chan struct{}), } // Initialize and load store on creation cs.loadStore() @@ -97,6 +99,9 @@ func (cs *CronService) Start() error { } cs.stopChan = make(chan struct{}) + if cs.wakeChan == nil { + cs.wakeChan = make(chan struct{}) + } cs.running = true go cs.runLoop(cs.stopChan) @@ -119,14 +124,47 @@ func (cs *CronService) Stop() { } func (cs *CronService) runLoop(stopChan chan struct{}) { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() for { + // every loop, recalculate the next wake time + cs.mu.RLock() + nextWake := cs.getNextWakeMS() + cs.mu.RUnlock() + + var delay time.Duration + now := time.Now().UnixMilli() + + if nextWake == nil { + // no jobs, sleep for a long time (or until a new job is added) + delay = time.Hour + } else { + diff := *nextWake - now + if diff <= 0 { + delay = 0 + } else { + delay = time.Duration(diff) * time.Millisecond + } + } + + timer.Reset(delay) + select { case <-stopChan: return - case <-ticker.C: + case <-cs.wakeChan: // wake on new job or update + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + continue + case <-timer.C: cs.checkJobs() } } @@ -264,22 +302,19 @@ func (cs *CronService) executeJobByID(jobID string) { } func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 { - if schedule.Kind == "at" { + switch schedule.Kind { + case "at": if schedule.AtMS != nil && *schedule.AtMS > nowMS { return schedule.AtMS } return nil - } - - if schedule.Kind == "every" { + case "every": if schedule.EveryMS == nil || *schedule.EveryMS <= 0 { return nil } next := nowMS + *schedule.EveryMS return &next - } - - if schedule.Kind == "cron" { + case "cron": if schedule.Expr == "" { return nil } @@ -294,9 +329,19 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6 nextMS := nextTime.UnixMilli() return &nextMS + default: + log.Printf("[cron] unknown schedule kind '%s'", schedule.Kind) + return nil } +} - return nil +// wake up the loop to re-evaluate next wake time immediately (e.g. after add/update/remove jobs) +func (cs *CronService) notify() { + select { + case cs.wakeChan <- struct{}{}: + default: + // if the channel is full, it means the loop will wake up soon anyway, so we can skip sending + } } func (cs *CronService) recomputeNextRuns() { @@ -400,6 +445,8 @@ func (cs *CronService) AddJob( return nil, err } + cs.notify() + return &job, nil } @@ -411,6 +458,9 @@ func (cs *CronService) UpdateJob(job *CronJob) error { if cs.store.Jobs[i].ID == job.ID { cs.store.Jobs[i] = *job cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli() + + cs.notify() + return cs.saveStoreUnsafe() } } @@ -441,6 +491,8 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool { } } + cs.notify() + return removed } @@ -463,6 +515,9 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob { if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store after enable: %v", err) } + + cs.notify() + return job } } diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go index 1a0dd1829..c55e62174 100644 --- a/pkg/cron/service_test.go +++ b/pkg/cron/service_test.go @@ -1,10 +1,13 @@ package cron import ( + "fmt" "os" "path/filepath" "runtime" + "sync" "testing" + "time" ) func TestSaveStore_FilePermissions(t *testing.T) { @@ -36,3 +39,199 @@ func TestSaveStore_FilePermissions(t *testing.T) { func int64Ptr(v int64) *int64 { return &v } + +func setupService(handler JobHandler) (*CronService, string) { + tmpFile := fmt.Sprintf("test_cron_%d.json", time.Now().UnixNano()) + cs := NewCronService(tmpFile, handler) + return cs, tmpFile +} + +func TestCronService_CRUD(t *testing.T) { + cs, path := setupService(nil) + defer os.Remove(path) + + // Test AddJob + at := time.Now().Add(time.Hour).UnixMilli() + job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to") + if err != nil || job.ID == "" { + t.Fatalf("AddJob failed: %v", err) + } + + // Test ListJobs + if len(cs.ListJobs(true)) != 1 { + t.Error("ListJobs should return 1 job") + } + + // Test UpdateJob + job.Name = "UpdatedName" + err = cs.UpdateJob(job) + if err != nil || cs.store.Jobs[0].Name != "UpdatedName" { + t.Error("UpdateJob failed") + } + + // Test EnableJob + cs.EnableJob(job.ID, false) + if cs.store.Jobs[0].Enabled != false || cs.store.Jobs[0].State.NextRunAtMS != nil { + t.Error("EnableJob(false) failed to clear state") + } + + // Test RemoveJob + removed := cs.RemoveJob(job.ID) + if !removed || len(cs.store.Jobs) != 0 { + t.Error("RemoveJob failed") + } +} + +// 2. Test Cron Expression Calculation Logic +func TestCronService_ComputeNextRun(t *testing.T) { + cs, path := setupService(nil) + defer os.Remove(path) + + now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli() + + tests := []struct { + name string + schedule CronSchedule + wantNil bool + }{ + {"Valid Cron", CronSchedule{Kind: "cron", Expr: "0 * * * *"}, false}, + {"Invalid Cron", CronSchedule{Kind: "cron", Expr: "invalid"}, true}, + {"Every MS", CronSchedule{Kind: "every", EveryMS: int64Ptr(5000)}, false}, + {"At Future", CronSchedule{Kind: "at", AtMS: int64Ptr(now + 1000)}, false}, + {"At Past", CronSchedule{Kind: "at", AtMS: int64Ptr(now - 1000)}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cs.computeNextRun(&tt.schedule, now) + if (got == nil) != tt.wantNil { + t.Errorf("%s: got %v, wantNil %v", tt.name, got, tt.wantNil) + } + }) + } +} + +// 3. Test Execution Flow +func TestCronService_ExecutionFlow(t *testing.T) { + var mu sync.Mutex + executedJobs := make(map[string]bool) + + handler := func(job *CronJob) (string, error) { + mu.Lock() + executedJobs[job.ID] = true + mu.Unlock() + return "ok", nil + } + + cs, path := setupService(handler) + defer os.Remove(path) + + // Start the service + if err := cs.Start(); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer cs.Stop() + + // Add a job then runs 100ms from now + target := time.Now().Add(100 * time.Millisecond).UnixMilli() + job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "") + + // Check for job execution with a timeout + success := false + for range 20 { + mu.Lock() + if executedJobs[job.ID] { + success = true + mu.Unlock() + break + } + mu.Unlock() + time.Sleep(100 * time.Millisecond) + } + + if !success { + t.Error("Job was not executed in time") + } + + // check that the job is removed after execution (DeleteAfterRun = true) + status := cs.Status() + if status["jobs"].(int) != 0 { + t.Errorf("Job should be deleted after run, got count: %v", status["jobs"]) + } +} + +func TestCronService_PersistenceIntegrity(t *testing.T) { + tmpFile := "persist_test.json" + defer os.Remove(tmpFile) + + // write a job and persist + cs1 := NewCronService(tmpFile, nil) + at := int64(2000000000000) + cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "") + + // check file exists + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + t.Fatal("Store file was not created") + } + + // reload and check data integrity + cs2 := NewCronService(tmpFile, nil) + if err := cs2.Load(); err != nil { + t.Fatalf("Failed to load store: %v", err) + } + + jobs := cs2.ListJobs(true) + if len(jobs) != 1 || jobs[0].Name != "PersistMe" { + t.Errorf("Data corruption after reload. Got: %+v", jobs) + } + + // test loading invalid JSON + os.WriteFile(tmpFile, []byte("{invalid json}"), 0o644) + cs3 := NewCronService(tmpFile, nil) + err := cs3.loadStore() + if err == nil { + t.Error("Should return error when loading invalid JSON") + } +} + +func TestCronService_ConcurrentAccess(t *testing.T) { + cs, path := setupService(nil) + defer os.Remove(path) + + cs.Start() + defer cs.Stop() + + var wg sync.WaitGroup + workers := 10 + iterations := 50 + + wg.Add(workers * 2) + + // add jobs concurrently + for i := range workers { + go func(id int) { + defer wg.Done() + for j := range iterations { + at := time.Now().Add(time.Hour).UnixMilli() + cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "") + time.Sleep(100 * time.Microsecond) + } + }(i) + } + + // read and update jobs concurrently + for range workers { + go func() { + defer wg.Done() + for j := range iterations { + jobs := cs.ListJobs(true) + if len(jobs) > 0 { + cs.EnableJob(jobs[0].ID, j%2 == 0) + } + time.Sleep(100 * time.Microsecond) + } + }() + } + + wg.Wait() +} diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/pkg/gateway/gateway.go similarity index 71% rename from cmd/picoclaw/internal/gateway/helpers.go rename to pkg/gateway/gateway.go index 850b6b47f..d7ca19ea0 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/pkg/gateway/gateway.go @@ -12,9 +12,9 @@ import ( "strconv" "strings" "sync" + "syscall" "time" - "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -51,16 +51,13 @@ import ( "github.com/sipeed/picoclaw/pkg/voice" ) -// Timeout constants for service operations const ( - serviceRestartTimeout = 30 * time.Second serviceShutdownTimeout = 30 * time.Second providerReloadTimeout = 30 * time.Second gracefulShutdownTimeout = 15 * time.Second ) -// gatewayServices holds references to all running services -type gatewayServices struct { +type services struct { CronService *cron.CronService HeartbeatService *heartbeat.HeartbeatService ResearchStore *research.ResearchStore @@ -71,19 +68,37 @@ type gatewayServices struct { HealthServer *health.Server } -func gatewayCmd(debug bool, orchestration bool, enableStats bool) error { +type startupBlockedProvider struct { + reason string +} + +func (p *startupBlockedProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + return nil, fmt.Errorf("%s", p.reason) +} + +func (p *startupBlockedProvider) GetDefaultModel() string { + return "" +} + +// Run starts the gateway runtime using the configuration loaded from configPath. +func Run(debug bool, configPath string, orchestration bool, enableStats bool, allowEmptyStartup bool) error { if debug { logger.SetLevel(logger.DEBUG) fmt.Println("🔍 Debug mode enabled") } - configPath := internal.GetConfigPath() - cfg, err := internal.LoadConfig() + cfg, err := config.LoadConfig(configPath) if err != nil { return fmt.Errorf("error loading config: %w", err) } - provider, modelID, err := providers.CreateProvider(cfg) + provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) if err != nil { return fmt.Errorf("error creating provider: %w", err) } @@ -91,7 +106,6 @@ func gatewayCmd(debug bool, orchestration bool, enableStats bool) error { cfg.Agents.Defaults.Orchestration = true } - // Use the resolved model ID from provider creation if modelID != "" { cfg.Agents.Defaults.ModelName = modelID } @@ -99,7 +113,6 @@ func gatewayCmd(debug bool, orchestration bool, enableStats bool) error { msgBus := bus.NewMessageBus() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, enableStats) - // Print agent startup info fmt.Println("\n📦 Agent Status:") startupInfo := agentLoop.GetStartupInfo() toolsInfo := startupInfo["tools"].(map[string]any) @@ -110,11 +123,8 @@ func gatewayCmd(debug bool, orchestration bool, enableStats bool) error { } else { fmt.Println(" • Web search: disabled") } - fmt.Printf(" • Skills: %d/%d available\n", - skillsInfo["available"], - skillsInfo["total"]) + fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"]) - // Log to file as well logger.InfoCF("agent", "Agent initialized", map[string]any{ "tools_count": toolsInfo["count"], @@ -122,8 +132,7 @@ func gatewayCmd(debug bool, orchestration bool, enableStats bool) error { "skills_available": skillsInfo["available"], }) - // Setup and start all services - services, err := setupAndStartServices(cfg, agentLoop, msgBus) + runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus) if err != nil { return err } @@ -136,23 +145,25 @@ func gatewayCmd(debug bool, orchestration bool, enableStats bool) error { go agentLoop.Run(ctx) - // Setup config file watcher for hot reload - configReloadChan, stopWatch := setupConfigWatcherPolling(configPath, debug) + var configReloadChan <-chan *config.Config + stopWatch := func() {} + if cfg.Gateway.HotReload { + configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug) + logger.Info("Config hot reload enabled") + } defer stopWatch() sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - // Main event loop - wait for signals or config changes for { select { case <-sigChan: logger.Info("Shutting down...") - shutdownGateway(services, agentLoop, provider, true) + shutdownGateway(runningServices, agentLoop, provider, true) return nil - case newCfg := <-configReloadChan: - err := handleConfigReload(ctx, agentLoop, newCfg, &provider, services, msgBus) + err := handleConfigReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) if err != nil { logger.Errorf("Config reload failed: %v", err) } @@ -160,17 +171,33 @@ func gatewayCmd(debug bool, orchestration bool, enableStats bool) error { } } -// setupAndStartServices initializes and starts all services +func createStartupProvider( + cfg *config.Config, + allowEmptyStartup bool, +) (providers.LLMProvider, string, error) { + modelName := cfg.Agents.Defaults.GetModelName() + if modelName == "" && allowEmptyStartup { + reason := "no default model configured; gateway started in limited mode" + fmt.Printf("⚠ Warning: %s\n", reason) + logger.WarnCF("gateway", "Gateway started without default model", map[string]any{ + "limited_mode": true, + }) + return &startupBlockedProvider{reason: reason}, "", nil + } + + return providers.CreateProvider(cfg) +} + func setupAndStartServices( cfg *config.Config, agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, -) (*gatewayServices, error) { - services := &gatewayServices{} +) (*services, error) { + runningServices := &services{} - // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - services.CronService = setupCronTool( + var err error + runningServices.CronService, err = setupCronTool( agentLoop, msgBus, cfg.WorkspacePath(), @@ -178,95 +205,68 @@ func setupAndStartServices( execTimeout, cfg, ) - if err := services.CronService.Start(); err != nil { + if err != nil { + return nil, fmt.Errorf("error setting up cron service: %w", err) + } + if err = runningServices.CronService.Start(); err != nil { return nil, fmt.Errorf("error starting cron service: %w", err) } fmt.Println("✓ Cron service started") - // Setup heartbeat service - services.HeartbeatService = heartbeat.NewHeartbeatService( + runningServices.HeartbeatService = heartbeat.NewHeartbeatService( cfg.WorkspacePath(), cfg.Heartbeat.Interval, cfg.Heartbeat.Enabled, ) - services.HeartbeatService.SetHeartbeatThreadID(cfg.Channels.Telegram.HeartbeatThreadID) - services.HeartbeatService.SetBus(msgBus) - agentLoop.SetHeartbeatThreadUpdater(services.HeartbeatService.SetHeartbeatThreadID) - agentLoop.SetConfigSaver(func(c *config.Config) error { - return config.SaveConfig(internal.GetConfigPath(), c) - }) - services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - // Use cli:direct as fallback if no valid channel - if channel == "" || chatID == "" { - channel, chatID = "cli", "direct" - } - // Use ProcessHeartbeat - no session history, each heartbeat is independent - var response string - var err error - response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) - if err != nil { - return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) - } - // Always return SilentResult — the task completion message in runAgentLoop - // already includes the LLM response in the same status bubble. - if response == "HEARTBEAT_OK" { - return tools.SilentResult("Heartbeat OK") - } - return tools.SilentResult(response) - }) - if err := services.HeartbeatService.Start(); err != nil { + runningServices.HeartbeatService.SetHeartbeatThreadID(cfg.Channels.Telegram.HeartbeatThreadID) + runningServices.HeartbeatService.SetBus(msgBus) + agentLoop.SetHeartbeatThreadUpdater(runningServices.HeartbeatService.SetHeartbeatThreadID) + runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop)) + if err = runningServices.HeartbeatService.Start(); err != nil { return nil, fmt.Errorf("error starting heartbeat service: %w", err) } fmt.Println("✓ Heartbeat service started") // Reset heartbeat suppression when a real user message arrives - agentLoop.OnUserMessage = services.HeartbeatService.ResetSuppression + agentLoop.OnUserMessage = runningServices.HeartbeatService.ResetSuppression - // Create media store for file lifecycle management with TTL cleanup - services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ + runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ Enabled: cfg.Tools.MediaCleanup.Enabled, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, }) - // Start the media store if it's a FileMediaStore with cleanup - if fms, ok := services.MediaStore.(*media.FileMediaStore); ok { + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Start() } - // Create channel manager - var err error - services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore) + runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { - // Stop the media store if it's a FileMediaStore with cleanup - if fms, ok := services.MediaStore.(*media.FileMediaStore); ok { + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Stop() } return nil, fmt.Errorf("error creating channel manager: %w", err) } - // Inject channel manager and media store into agent loop - agentLoop.SetChannelManager(services.ChannelManager) - agentLoop.SetMediaStore(services.MediaStore) + agentLoop.SetChannelManager(runningServices.ChannelManager) + agentLoop.SetMediaStore(runningServices.MediaStore) - // Wire up voice transcription if a supported provider is configured. if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { agentLoop.SetTranscriber(transcriber) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) } - enabledChannels := services.ChannelManager.GetEnabledChannels() + enabledChannels := runningServices.ChannelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) } else { fmt.Println("⚠ Warning: No channels enabled") } - // Setup shared HTTP server with health endpoints and webhook handlers addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) - services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - services.ChannelManager.SetupHTTPServer(addr, services.HealthServer) + runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) - if err := services.ChannelManager.StartAll(context.Background()); err != nil { + if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { return nil, fmt.Errorf("error starting channels: %w", err) } @@ -328,7 +328,7 @@ func setupAndStartServices( if b := agentLoop.GetOrchBroadcaster(); b != nil { handler.SetOrchBroadcaster(b) } - handler.RegisterRoutes(services.HealthServer.Mux()) + handler.RegisterRoutes(runningServices.HealthServer.Mux()) // Register dev preview tool for all agents devPreviewTool := tools.NewDevPreviewTool(handler) @@ -346,9 +346,9 @@ func setupAndStartServices( agentLoop.RegisterTool(tools.NewResearchTool(researchStore, cfg.WorkspacePath(), focusTracker)) handler.SetResearchStore(researchStore) handler.SetResearchFocus(focusTracker) - services.ResearchStore = researchStore - services.ResearchFocus = focusTracker - services.HeartbeatService.SetResearchStore(researchStore) + runningServices.ResearchStore = researchStore + runningServices.ResearchFocus = focusTracker + runningServices.HeartbeatService.SetResearchStore(researchStore) fmt.Println("✓ Research store initialized") } @@ -359,9 +359,9 @@ func setupAndStartServices( go func() { var serverErr error if useTLS { - serverErr = services.HealthServer.StartTLS(tlsCert, tlsKey) + serverErr = runningServices.HealthServer.StartTLS(tlsCert, tlsKey) } else { - serverErr = services.HealthServer.Start() + serverErr = runningServices.HealthServer.Start() } if serverErr != nil && serverErr != http.ErrServerClosed { logger.ErrorCF("health", "Health server error", map[string]any{"error": serverErr.Error()}) @@ -380,55 +380,49 @@ func setupAndStartServices( ) } - // Setup state manager and device service stateManager := state.NewManager(cfg.WorkspacePath()) - services.DeviceService = devices.NewService(devices.Config{ + runningServices.DeviceService = devices.NewService(devices.Config{ Enabled: cfg.Devices.Enabled, MonitorUSB: cfg.Devices.MonitorUSB, }, stateManager) - services.DeviceService.SetBus(msgBus) - if err := services.DeviceService.Start(context.Background()); err != nil { + runningServices.DeviceService.SetBus(msgBus) + if err = runningServices.DeviceService.Start(context.Background()); err != nil { logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()}) } else if cfg.Devices.Enabled { fmt.Println("✓ Device event service started") } - return services, nil + + return runningServices, nil } -// stopAndCleanupServices stops all services and cleans up resources -func stopAndCleanupServices( - services *gatewayServices, - shutdownTimeout time.Duration, -) { +func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration) { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) defer shutdownCancel() - if services.ChannelManager != nil { - services.ChannelManager.StopAll(shutdownCtx) + if runningServices.ChannelManager != nil { + runningServices.ChannelManager.StopAll(shutdownCtx) } - if services.HealthServer != nil { - services.HealthServer.Stop(shutdownCtx) + if runningServices.HealthServer != nil { + runningServices.HealthServer.Stop(shutdownCtx) } - if services.DeviceService != nil { - services.DeviceService.Stop() + if runningServices.DeviceService != nil { + runningServices.DeviceService.Stop() } - if services.HeartbeatService != nil { - services.HeartbeatService.Stop() + if runningServices.HeartbeatService != nil { + runningServices.HeartbeatService.Stop() } - if services.CronService != nil { - services.CronService.Stop() + if runningServices.CronService != nil { + runningServices.CronService.Stop() } - if services.MediaStore != nil { - // Stop the media store if it's a FileMediaStore with cleanup - if fms, ok := services.MediaStore.(*media.FileMediaStore); ok { + if runningServices.MediaStore != nil { + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Stop() } } } -// shutdownGateway performs a complete gateway shutdown func shutdownGateway( - services *gatewayServices, + runningServices *services, agentLoop *agent.AgentLoop, provider providers.LLMProvider, fullShutdown bool, @@ -437,7 +431,7 @@ func shutdownGateway( cp.Close() } - stopAndCleanupServices(services, gracefulShutdownTimeout) + stopAndCleanupServices(runningServices, gracefulShutdownTimeout) agentLoop.Stop() agentLoop.Close() @@ -445,15 +439,14 @@ func shutdownGateway( logger.Info("✓ Gateway stopped") } -// handleConfigReload handles config file reload by stopping all services, -// reloading the provider and config, and restarting services with the new config. func handleConfigReload( ctx context.Context, al *agent.AgentLoop, newCfg *config.Config, providerRef *providers.LLMProvider, - services *gatewayServices, + runningServices *services, msgBus *bus.MessageBus, + allowEmptyStartup bool, ) error { logger.Info("🔄 Config file changed, reloading...") @@ -464,18 +457,14 @@ func handleConfigReload( logger.Infof(" New model is '%s', recreating provider...", newModel) - // Stop all services before reloading logger.Info(" Stopping all services...") - stopAndCleanupServices(services, serviceShutdownTimeout) + stopAndCleanupServices(runningServices, serviceShutdownTimeout) - // Create new provider from updated config first to ensure validity - // This will use the correct API key and settings from newCfg.ModelList - newProvider, newModelID, err := providers.CreateProvider(newCfg) + newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup) if err != nil { logger.Errorf(" ⚠ Error creating new provider: %v", err) logger.Warn(" Attempting to restart services with old provider and config...") - // Try to restart services with old configuration - if restartErr := restartServices(al, services, msgBus); restartErr != nil { + if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) } return fmt.Errorf("error creating new provider: %w", err) @@ -485,31 +474,25 @@ func handleConfigReload( newCfg.Agents.Defaults.ModelName = newModelID } - // Use the atomic reload method on AgentLoop to safely swap provider and config. - // This handles locking internally to prevent races with in-flight LLM calls - // and concurrent reads of registry/config while the swap occurs. reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout) defer reloadCancel() if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil { logger.Errorf(" ⚠ Error reloading agent loop: %v", err) - // Close the newly created provider since it wasn't adopted if cp, ok := newProvider.(providers.StatefulProvider); ok { cp.Close() } logger.Warn(" Attempting to restart services with old provider and config...") - if restartErr := restartServices(al, services, msgBus); restartErr != nil { + if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) } return fmt.Errorf("error reloading agent loop: %w", err) } - // Update local provider reference only after successful atomic reload *providerRef = newProvider - // Restart all services with new config logger.Info(" Restarting all services with new configuration...") - if err := restartServices(al, services, msgBus); err != nil { + if err := restartServices(al, runningServices, msgBus); err != nil { logger.Errorf(" ⚠ Error restarting services: %v", err) return fmt.Errorf("error restarting services: %w", err) } @@ -518,23 +501,16 @@ func handleConfigReload( return nil } -// restartServices restarts all services after a config reload func restartServices( al *agent.AgentLoop, - services *gatewayServices, + runningServices *services, msgBus *bus.MessageBus, ) error { - // Create an independent context with timeout for service restart - // This prevents cancellation from the main loop context during reload - ctx, cancel := context.WithTimeout(context.Background(), serviceRestartTimeout) - defer cancel() - - // Get current config from agent loop (which has been updated if this is a reload) cfg := al.GetConfig() - // Re-create and start cron service with new config execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - services.CronService = setupCronTool( + var err error + runningServices.CronService, err = setupCronTool( al, msgBus, cfg.WorkspacePath(), @@ -542,83 +518,57 @@ func restartServices( execTimeout, cfg, ) - if err := services.CronService.Start(); err != nil { + if err != nil { + return fmt.Errorf("error restarting cron service: %w", err) + } + if err = runningServices.CronService.Start(); err != nil { return fmt.Errorf("error restarting cron service: %w", err) } fmt.Println(" ✓ Cron service restarted") - // Re-create and start heartbeat service with new config - services.HeartbeatService = heartbeat.NewHeartbeatService( + runningServices.HeartbeatService = heartbeat.NewHeartbeatService( cfg.WorkspacePath(), cfg.Heartbeat.Interval, cfg.Heartbeat.Enabled, ) - services.HeartbeatService.SetBus(msgBus) - if services.ResearchStore != nil { - services.HeartbeatService.SetResearchStore(services.ResearchStore) + runningServices.HeartbeatService.SetBus(msgBus) + if runningServices.ResearchStore != nil { + runningServices.HeartbeatService.SetResearchStore(runningServices.ResearchStore) } - services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - if channel == "" || chatID == "" { - channel, chatID = "cli", "direct" - } - var response string - var err error - response, err = al.ProcessHeartbeat(context.Background(), prompt, channel, chatID) - if err != nil { - return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) - } - if response == "HEARTBEAT_OK" { - return tools.SilentResult("Heartbeat OK") - } - return tools.SilentResult(response) - }) - if err := services.HeartbeatService.Start(); err != nil { + runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al)) + if err = runningServices.HeartbeatService.Start(); err != nil { return fmt.Errorf("error restarting heartbeat service: %w", err) } fmt.Println(" ✓ Heartbeat service restarted") - // Stop the old media store before creating a new one - if fms, ok := services.MediaStore.(*media.FileMediaStore); ok { - fms.Stop() - } - - // Re-create media store with new config - services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ + runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ Enabled: cfg.Tools.MediaCleanup.Enabled, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, }) - // Start the media store if it's a FileMediaStore with cleanup - if fms, ok := services.MediaStore.(*media.FileMediaStore); ok { + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Start() } - al.SetMediaStore(services.MediaStore) + al.SetMediaStore(runningServices.MediaStore) - // Re-create channel manager with new config - var err error - services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore) + runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { - // Stop the media store if it's a FileMediaStore with cleanup - if fms, ok := services.MediaStore.(*media.FileMediaStore); ok { - fms.Stop() - } return fmt.Errorf("error recreating channel manager: %w", err) } - al.SetChannelManager(services.ChannelManager) + al.SetChannelManager(runningServices.ChannelManager) - enabledChannels := services.ChannelManager.GetEnabledChannels() + enabledChannels := runningServices.ChannelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels) } else { fmt.Println(" ⚠ Warning: No channels enabled") } - // Setup HTTP server with new config addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) - services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - services.ChannelManager.SetupHTTPServer(addr, services.HealthServer) + runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) - if err := services.ChannelManager.StartAll(ctx); err != nil { + if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { return fmt.Errorf("error restarting channels: %w", err) } fmt.Printf( @@ -627,22 +577,20 @@ func restartServices( cfg.Gateway.Port, ) - // Re-create device service with new config stateManager := state.NewManager(cfg.WorkspacePath()) - services.DeviceService = devices.NewService(devices.Config{ + runningServices.DeviceService = devices.NewService(devices.Config{ Enabled: cfg.Devices.Enabled, MonitorUSB: cfg.Devices.MonitorUSB, }, stateManager) - services.DeviceService.SetBus(msgBus) - if err := services.DeviceService.Start(ctx); err != nil { + runningServices.DeviceService.SetBus(msgBus) + if err := runningServices.DeviceService.Start(context.Background()); err != nil { logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()}) } else if cfg.Devices.Enabled { fmt.Println(" ✓ Device event service restarted") } - // Wire up voice transcription with new config transcriber := voice.DetectTranscriber(cfg) - al.SetTranscriber(transcriber) // This will set it to nil if disabled + al.SetTranscriber(transcriber) if transcriber != nil { logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) } else { @@ -652,8 +600,6 @@ func restartServices( return nil } -// setupConfigWatcherPolling sets up a simple polling-based config file watcher -// Returns a channel for config updates and a stop function func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) { configChan := make(chan *config.Config, 1) stop := make(chan struct{}) @@ -663,11 +609,10 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf go func() { defer wg.Done() - // Get initial file info lastModTime := getFileModTime(configPath) lastSize := getFileSize(configPath) - ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds + ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { @@ -676,16 +621,16 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf currentModTime := getFileModTime(configPath) currentSize := getFileSize(configPath) - // Check if file changed (modification time or size changed) if currentModTime.After(lastModTime) || currentSize != lastSize { if debug { logger.Debugf("🔍 Config file change detected") } - // Debounce - wait a bit to ensure file write is complete time.Sleep(500 * time.Millisecond) - // Validate and load new config + lastModTime = currentModTime + lastSize = currentSize + newCfg, err := config.LoadConfig(configPath) if err != nil { logger.Errorf("⚠ Error loading new config: %v", err) @@ -693,7 +638,6 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf continue } - // Validate the new config if err := newCfg.ValidateModelList(); err != nil { logger.Errorf(" ⚠ New config validation failed: %v", err) logger.Warn(" Using previous valid config") @@ -702,19 +646,12 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf logger.Info("✓ Config file validated and loaded") - // Update last known state - lastModTime = currentModTime - lastSize = currentSize - - // Send new config to main loop (non-blocking) select { case configChan <- newCfg: default: - // Channel full, skip this update logger.Warn("⚠ Previous config reload still in progress, skipping") } } - case <-stop: return } @@ -729,7 +666,6 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf return configChan, stopFunc } -// getFileModTime returns the modification time of a file, or zero time if file doesn't exist func getFileModTime(path string) time.Time { info, err := os.Stat(path) if err != nil { @@ -738,7 +674,6 @@ func getFileModTime(path string) time.Time { return info.ModTime() } -// getFileSize returns the size of a file, or 0 if file doesn't exist func getFileSize(path string) int64 { info, err := os.Stat(path) if err != nil { @@ -754,25 +689,22 @@ func setupCronTool( restrict bool, execTimeout time.Duration, cfg *config.Config, -) *cron.CronService { +) (*cron.CronService, error) { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") - // Create cron service cronService := cron.NewCronService(cronStorePath, nil) - // Create and register CronTool if enabled var cronTool *tools.CronTool if cfg.Tools.IsToolEnabled("cron") { var err error cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) if err != nil { - logger.Fatalf("Critical error during CronTool initialization: %v", err) + return nil, fmt.Errorf("critical error during CronTool initialization: %w", err) } agentLoop.RegisterTool(cronTool) } - // Set onJob handler if cronTool != nil { cronService.SetOnJob(func(job *cron.CronJob) (string, error) { result := cronTool.ExecuteJob(context.Background(), job) @@ -780,7 +712,24 @@ func setupCronTool( }) } - return cronService + return cronService, nil +} + +func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { + return func(prompt, channel, chatID string) *tools.ToolResult { + if channel == "" || chatID == "" { + channel, chatID = "cli", "direct" + } + + response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + if err != nil { + return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) + } + if response == "HEARTBEAT_OK" { + return tools.SilentResult("Heartbeat OK") + } + return tools.SilentResult(response) + } } // agentLoopDataProvider adapts AgentLoop to the miniapp.DataProvider interface. diff --git a/pkg/health/server.go b/pkg/health/server.go index 5609ebdf6..b9ee9f496 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" "net/http" + "os" "sync" "time" ) @@ -29,6 +30,7 @@ type StatusResponse struct { Status string `json:"status"` Uptime string `json:"uptime"` Checks map[string]Check `json:"checks,omitempty"` + Pid int `json:"pid"` } func NewServer(host string, port int) *Server { @@ -112,6 +114,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { resp := StatusResponse{ Status: "ok", Uptime: uptime.String(), + Pid: os.Getpid(), } json.NewEncoder(w).Encode(resp) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index e8f408037..7550c4cfc 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -154,7 +154,7 @@ func init() { TimeFormat: "15:04:05", // TODO: make it configurable??? } - logger = zerolog.New(consoleWriter).With().Timestamp().Logger() + logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() fileLogger = zerolog.Logger{} ringBuf = newLogRingBuffer(ringBufSize) }) @@ -167,6 +167,12 @@ func SetLevel(level LogLevel) { zerolog.SetGlobalLevel(level) } +func SetConsoleLevel(level LogLevel) { + mu.Lock() + defer mu.Unlock() + logger = logger.Level(level) +} + func GetLevel() LogLevel { mu.RLock() defer mu.RUnlock() @@ -207,9 +213,9 @@ func DisableFileLogging() { fileLogger = zerolog.Logger{} } -func getCallerInfo() (string, int, string) { +func getCallerSkip() int { for i := 2; i < 15; i++ { - pc, file, line, ok := runtime.Caller(i) + pc, file, _, ok := runtime.Caller(i) if !ok { continue } @@ -231,10 +237,10 @@ func getCallerInfo() (string, int, string) { continue } - return filepath.Base(file), line, filepath.Base(funcName) + return i - 1 } - return "???", 0, "???" + return 3 } //nolint:zerologlint @@ -273,23 +279,16 @@ func logMessage(level LogLevel, component string, message string, fields map[str ringBuf.push(entry) broadcastToSubscribers(entry) - // Upstream zerolog console output - callerFile, callerLine, callerFunc := getCallerInfo() + skip := getCallerSkip() event := getEvent(logger, level) - // Build combined field with component and caller if component != "" { - event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc)) - } else { - event.Str("caller", fmt.Sprintf(" %s:%d (%s)", callerFile, callerLine, callerFunc)) + event.Str("component", component) } - for k, v := range fields { - event.Interface(k, v) - } - - event.Msg(message) + appendFields(event, fields) + event.CallerSkipFrame(skip).Msg(message) // Also log to file if enabled if fileLogger.GetLevel() != zerolog.NoLevel { @@ -298,10 +297,9 @@ func logMessage(level LogLevel, component string, message string, fields map[str if component != "" { fileEvent.Str("component", component) } - for k, v := range fields { - fileEvent.Interface(k, v) - } - fileEvent.Msg(message) + + appendFields(fileEvent, fields) + fileEvent.CallerSkipFrame(skip).Msg(message) } if level == FATAL { @@ -309,6 +307,26 @@ func logMessage(level LogLevel, component string, message string, fields map[str } } +func appendFields(event *zerolog.Event, fields map[string]any) { + for k, v := range fields { + // Type switch to avoid double JSON serialization of strings + switch val := v.(type) { + case string: + event.Str(k, val) + case int: + event.Int(k, val) + case int64: + event.Int64(k, val) + case float64: + event.Float64(k, val) + case bool: + event.Bool(k, val) + default: + event.Interface(k, v) // Fallback for struct, slice and maps + } + } +} + func Debug(message string) { logMessage(DEBUG, "", message, nil) } diff --git a/pkg/logger/logger_3rd_party.go b/pkg/logger/logger_3rd_party.go index da50d686a..d0cb178c5 100644 --- a/pkg/logger/logger_3rd_party.go +++ b/pkg/logger/logger_3rd_party.go @@ -2,7 +2,20 @@ package logger -import "fmt" +import ( + "fmt" + "regexp" +) + +// botTokenRe matches the bot ID prefix and the secret part of a Telegram bot token. +// Groups: 1 = "bot:", 2 = first 4 chars of secret, 3 = middle, 4 = last 4 chars. +var botTokenRe = regexp.MustCompile(`(bot\d+:)([A-Za-z0-9_-]{4})[A-Za-z0-9_-]{12,}([A-Za-z0-9_-]{4})`) + +// maskSecrets replaces any embedded bot tokens in s with a redacted placeholder +// that keeps the first and last 4 characters of the secret for identification. +func maskSecrets(s string) string { + return botTokenRe.ReplaceAllString(s, "${1}${2}****${3}") +} // Logger implements common Logger interface type Logger struct { @@ -12,52 +25,52 @@ type Logger struct { // Debug logs debug messages func (b *Logger) Debug(v ...any) { - logMessage(DEBUG, b.component, fmt.Sprint(v...), nil) + logMessage(DEBUG, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Info logs info messages func (b *Logger) Info(v ...any) { - logMessage(INFO, b.component, fmt.Sprint(v...), nil) + logMessage(INFO, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Warn logs warning messages func (b *Logger) Warn(v ...any) { - logMessage(WARN, b.component, fmt.Sprint(v...), nil) + logMessage(WARN, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Error logs error messages func (b *Logger) Error(v ...any) { - logMessage(ERROR, b.component, fmt.Sprint(v...), nil) + logMessage(ERROR, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Debugf logs formatted debug messages func (b *Logger) Debugf(format string, v ...any) { - logMessage(DEBUG, b.component, fmt.Sprintf(format, v...), nil) + logMessage(DEBUG, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Infof logs formatted info messages func (b *Logger) Infof(format string, v ...any) { - logMessage(INFO, b.component, fmt.Sprintf(format, v...), nil) + logMessage(INFO, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Warnf logs formatted warning messages func (b *Logger) Warnf(format string, v ...any) { - logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil) + logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Warningf logs formatted warning messages func (b *Logger) Warningf(format string, v ...any) { - logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil) + logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Errorf logs formatted error messages func (b *Logger) Errorf(format string, v ...any) { - logMessage(ERROR, b.component, fmt.Sprintf(format, v...), nil) + logMessage(ERROR, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Fatalf logs formatted fatal messages and exits func (b *Logger) Fatalf(format string, v ...any) { - logMessage(FATAL, b.component, fmt.Sprintf(format, v...), nil) + logMessage(FATAL, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Log logs a message at a given level with caller information @@ -75,7 +88,7 @@ func (b *Logger) Log(msgL, caller int, format string, a ...any) { level = lvl } } - logMessage(level, b.component, fmt.Sprintf(format, a...), nil) + logMessage(level, b.component, maskSecrets(fmt.Sprintf(format, a...)), nil) } // Sync flushes log buffer (no-op for this implementation) diff --git a/pkg/media/tempdir.go b/pkg/media/tempdir.go new file mode 100644 index 000000000..45942b34f --- /dev/null +++ b/pkg/media/tempdir.go @@ -0,0 +1,13 @@ +package media + +import ( + "os" + "path/filepath" +) + +const TempDirName = "picoclaw_media" + +// TempDir returns the shared temporary directory used for downloaded media. +func TempDir() string { + return filepath.Join(os.TempDir(), TempDirName) +} diff --git a/pkg/migrate/internal/common.go b/pkg/migrate/internal/common.go index c77ab9f26..75aef5dc2 100644 --- a/pkg/migrate/internal/common.go +++ b/pkg/migrate/internal/common.go @@ -5,13 +5,15 @@ import ( "io" "os" "path/filepath" + + "github.com/sipeed/picoclaw/pkg/config" ) func ResolveTargetHome(override string) (string, error) { if override != "" { return ExpandHome(override), nil } - if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" { + if envHome := os.Getenv(config.EnvHome); envHome != "" { return ExpandHome(envHome), nil } home, err := os.UserHomeDir() diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index e95c2f3ec..317bd3e84 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -132,11 +132,12 @@ type OpenClawChannels struct { } type OpenClawTelegramConfig struct { - BotToken *string `json:"botToken"` - AllowFrom []string `json:"allowFrom"` - GroupPolicy *string `json:"groupPolicy"` - DmPolicy *string `json:"dmPolicy"` - Enabled *bool `json:"enabled"` + BotToken *string `json:"botToken"` + AllowFrom []string `json:"allowFrom"` + GroupPolicy *string `json:"groupPolicy"` + DmPolicy *string `json:"dmPolicy"` + Enabled *bool `json:"enabled"` + UseMarkdownV2 *bool `json:"useMarkdownV2"` } type OpenClawDiscordConfig struct { @@ -645,10 +646,11 @@ type WhatsAppConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled"` - Token string `json:"token"` - Proxy string `json:"proxy"` - AllowFrom []string `json:"allow_from"` + Enabled bool `json:"enabled"` + Token string `json:"token"` + Proxy string `json:"proxy"` + AllowFrom []string `json:"allow_from"` + UseMarkdownV2 bool `json:"use_markdown_v2"` } type FeishuConfig struct { @@ -777,9 +779,11 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig { if c.Channels.Telegram != nil { enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled + useMarkdownV2 := c.Channels.Telegram.UseMarkdownV2 != nil && *c.Channels.Telegram.UseMarkdownV2 channels.Telegram = TelegramConfig{ - Enabled: enabled, - AllowFrom: c.Channels.Telegram.AllowFrom, + Enabled: enabled, + AllowFrom: c.Channels.Telegram.AllowFrom, + UseMarkdownV2: useMarkdownV2, } if c.Channels.Telegram.BotToken != nil { channels.Telegram.Token = *c.Channels.Telegram.BotToken diff --git a/pkg/migrate/sources/openclaw/openclaw_handler.go b/pkg/migrate/sources/openclaw/openclaw_handler.go index aaff119f1..5e5241268 100644 --- a/pkg/migrate/sources/openclaw/openclaw_handler.go +++ b/pkg/migrate/sources/openclaw/openclaw_handler.go @@ -10,6 +10,11 @@ import ( "github.com/sipeed/picoclaw/pkg/migrate/internal" ) +// OpenclawHomeEnvVar is the environment variable that overrides the source +// openclaw home directory when migrating from openclaw to picoclaw. +// Default: ~/.openclaw +const OpenclawHomeEnvVar = "OPENCLAW_HOME" + var providerMapping = map[string]string{ "anthropic": "anthropic", "claude": "anthropic", @@ -112,7 +117,7 @@ func resolveSourceHome(override string) (string, error) { if override != "" { return internal.ExpandHome(override), nil } - if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { + if envHome := os.Getenv(OpenclawHomeEnvVar); envHome != "" { return internal.ExpandHome(envHome), nil } home, err := os.UserHomeDir() diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 17d70314a..85bceca0b 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -180,6 +180,10 @@ func buildParams( blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) } for _, tc := range msg.ToolCalls { + // Skip tool calls with empty names to avoid API errors + if tc.Name == "" { + continue + } args := tc.Arguments if args == nil && tc.Function != nil && len(tc.Function.Arguments) > 0 { args = tc.Function.Arguments diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index e2b75516c..f87cfa4e1 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -221,11 +221,17 @@ func buildRequestBody( // Add tool_use blocks for _, tc := range msg.ToolCalls { + // Handle nil Arguments (GLM-4 may return null input) + input := tc.Arguments + if input == nil { + input = map[string]any{} + } + toolUse := map[string]any{ "type": "tool_use", "id": tc.ID, "name": tc.Name, - "input": tc.Arguments, + "input": input, } content = append(content, toolUse) } diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 6c4f6a767..40b581490 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -50,10 +50,18 @@ func (p *ClaudeCliProvider) Chat( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - if stderrStr := stderr.String(); stderrStr != "" { + stderrStr := strings.TrimSpace(stderr.String()) + stdoutStr := strings.TrimSpace(stdout.String()) + switch { + case stderrStr != "" && stdoutStr != "": + return nil, fmt.Errorf("claude cli error: %w\nstderr: %s\nstdout: %s", err, stderrStr, stdoutStr) + case stderrStr != "": return nil, fmt.Errorf("claude cli error: %s", stderrStr) + case stdoutStr != "": + return nil, fmt.Errorf("claude cli error: %w\noutput: %s", err, stdoutStr) + default: + return nil, fmt.Errorf("claude cli error: %w", err) } - return nil, fmt.Errorf("claude cli error: %w", err) } return p.parseClaudeCliResponse(stdout.String()) diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go index 40f3ee2a1..c5b25f040 100644 --- a/pkg/providers/codex_cli_credentials.go +++ b/pkg/providers/codex_cli_credentials.go @@ -8,6 +8,11 @@ import ( "time" ) +// CodexHomeEnvVar is the environment variable that overrides the Codex CLI +// home directory when resolving the codex auth.json credentials file. +// Default: ~/.codex +const CodexHomeEnvVar = "CODEX_HOME" + // CodexCliAuth represents the ~/.codex/auth.json file structure. type CodexCliAuth struct { Tokens struct { @@ -69,7 +74,7 @@ func CreateCodexCliTokenSource() func() (string, string, error) { } func resolveCodexAuthPath() (string, error) { - codexHome := os.Getenv("CODEX_HOME") + codexHome := os.Getenv(CodexHomeEnvVar) if codexHome == "" { home, err := os.UserHomeDir() if err != nil { diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 1270813d6..c18cc73de 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -95,7 +95,10 @@ func (p *CodexProvider) Chat( ) } - params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch) + // Respect tools.web.prefer_native: only inject native search when the agent + // loop requested it (options["native_search"]), so prefer_native: false + useNativeSearch := p.enableWebSearch && (options["native_search"] == true) + params := buildCodexParams(messages, tools, resolvedModel, options, useNativeSearch) stream := p.client.Responses.NewStreaming(ctx, params, opts...) defer stream.Close() @@ -157,6 +160,10 @@ func (p *CodexProvider) GetDefaultModel() string { return codexDefaultModel } +func (p *CodexProvider) SupportsNativeSearch() bool { + return p.enableWebSearch +} + func resolveCodexModel(model string) (string, string) { m := strings.ToLower(strings.TrimSpace(model)) if m == "" { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 33d5c8322..2d6d7f356 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -346,7 +346,9 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"max_tokens": 1024}) + // Pass native_search so Codex injects built-in web search (mirrors agent loop when prefer_native is true). + opts := map[string]any{"max_tokens": 1024, "native_search": true} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", opts) if err != nil { t.Fatalf("Chat() error: %v", err) } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index b375f921a..1e2b199ae 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -54,8 +54,8 @@ func ExtractProtocol(model string) (protocol, modelID string) { // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. -// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity, -// claude-cli, codex-cli, github-copilot +// Supported protocols: openai, litellm, novita, anthropic, anthropic-messages, +// antigravity, claude-cli, codex-cli, github-copilot // Returns the provider, the model ID (without protocol prefix), and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { @@ -97,7 +97,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", - "minimax", "longcat", "modelscope": + "minimax", "longcat", "modelscope", "novita": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -287,6 +287,8 @@ func getDefaultAPIBase(protocol string) string { 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": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 00676ebf9..50d30e96a 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -106,6 +106,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { }{ {"openai", "openai"}, {"groq", "groq"}, + {"novita", "novita"}, {"openrouter", "openrouter"}, {"cerebras", "cerebras"}, {"vivgrid", "vivgrid"}, @@ -216,6 +217,34 @@ func TestGetDefaultAPIBase_ModelScope(t *testing.T) { } } +func TestCreateProviderFromConfig_Novita(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-novita", + Model: "novita/deepseek/deepseek-v3.2", + APIKey: "test-key", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "deepseek/deepseek-v3.2" { + t.Errorf("modelID = %q, want %q", modelID, "deepseek/deepseek-v3.2") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestGetDefaultAPIBase_Novita(t *testing.T) { + if got := getDefaultAPIBase("novita"); got != "https://api.novita.ai/openai" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "novita", got, "https://api.novita.ai/openai") + } +} + func TestCreateProviderFromConfig_Anthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-anthropic", diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 7ba563b66..549ec7837 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -117,17 +117,19 @@ func (fc *FallbackChain) Execute( return nil, context.Canceled } - // Check cooldown. - if !fc.cooldown.IsAvailable(candidate.Provider) { - remaining := fc.cooldown.CooldownRemaining(candidate.Provider) + // Check cooldown (per provider/model, not just provider). + // This allows multi-key failover where different keys use different model names. + cooldownKey := ModelKey(candidate.Provider, candidate.Model) + if !fc.cooldown.IsAvailable(cooldownKey) { + remaining := fc.cooldown.CooldownRemaining(cooldownKey) result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Skipped: true, Reason: FailoverRateLimit, Error: fmt.Errorf( - "provider %s in cooldown (%s remaining)", - candidate.Provider, + "%s in cooldown (%s remaining)", + cooldownKey, remaining.Round(time.Second), ), }) @@ -141,7 +143,7 @@ func (fc *FallbackChain) Execute( if err == nil { // Success. - fc.cooldown.MarkSuccess(candidate.Provider) + fc.cooldown.MarkSuccess(cooldownKey) result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model @@ -187,7 +189,7 @@ func (fc *FallbackChain) Execute( } // Retriable error: mark failure and continue to next candidate. - fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason) + fc.cooldown.MarkFailure(cooldownKey, failErr.Reason) result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, diff --git a/pkg/providers/fallback_multikey_test.go b/pkg/providers/fallback_multikey_test.go new file mode 100644 index 000000000..9ed8fa73c --- /dev/null +++ b/pkg/providers/fallback_multikey_test.go @@ -0,0 +1,384 @@ +package providers + +import ( + "context" + "errors" + "testing" +) + +// TestMultiKeyFailover tests the complete failover flow with multiple API keys. +// This simulates the config expansion scenario where api_keys: ["key1", "key2", "key3"] +// is expanded into primary + fallbacks. +func TestMultiKeyFailover(t *testing.T) { + // Simulate expanded config: primary with 2 fallbacks + // This is what ExpandMultiKeyModels would produce for api_keys: ["key1", "key2", "key3"] + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + if len(candidates) != 3 { + t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates) + } + + // Create fallback chain + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown) + + // Mock run function: first call fails with 429, second succeeds + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + if callCount == 1 { + // First call: simulate rate limit + return nil, errors.New("http error: status 429 - rate limit exceeded") + } + // Second call: success + return &LLMResponse{ + Content: "Hello from key2!", + }, nil + } + + // Execute fallback chain + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success after failover, got error: %v", err) + } + + if result == nil { + t.Fatal("expected result, got nil") + } + + if result.Response.Content != "Hello from key2!" { + t.Errorf("expected response from key2, got: %s", result.Response.Content) + } + + if callCount != 2 { + t.Errorf("expected 2 calls (1 fail + 1 success), got %d", callCount) + } + + // Verify first attempt was recorded + if len(result.Attempts) != 1 { + t.Errorf("expected 1 failed attempt recorded, got %d", len(result.Attempts)) + } + + if result.Attempts[0].Reason != FailoverRateLimit { + t.Errorf( + "expected first attempt reason to be rate_limit, got: %s", + result.Attempts[0].Reason, + ) + } +} + +// TestMultiKeyFailoverAllFail tests when all keys hit rate limit +func TestMultiKeyFailoverAllFail(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown) + + // Mock run function: all calls fail with rate limit + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + return nil, errors.New("status: 429 - too many requests") + } + + // Execute fallback chain + result, err := chain.Execute(context.Background(), candidates, mockRun) + + if err == nil { + t.Fatal("expected error when all keys fail, got nil") + } + + if result != nil { + t.Errorf("expected nil result on failure, got: %v", result) + } + + if callCount != 3 { + t.Errorf("expected 3 calls (all fail), got %d", callCount) + } + + // Verify error type + var exhausted *FallbackExhaustedError + if !errors.As(err, &exhausted) { + t.Errorf("expected FallbackExhaustedError, got: %T - %v", err, err) + } + + if len(exhausted.Attempts) != 3 { + t.Errorf("expected 3 attempts in exhausted error, got %d", len(exhausted.Attempts)) + } +} + +// TestMultiKeyFailoverCooldown tests that a key in cooldown is skipped +func TestMultiKeyFailoverCooldown(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown) + + // Put the first model in cooldown (using ModelKey now, not just provider) + cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model) + cooldown.MarkFailure(cooldownKey, FailoverRateLimit) + + // Verify it's not available + if cooldown.IsAvailable(cooldownKey) { + t.Fatal("expected first model to be in cooldown") + } + + // Mock run function: only second should be called + callCount := 0 + calledProviders := []string{} + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + calledProviders = append(calledProviders, provider+"/"+model) + return &LLMResponse{Content: "success"}, nil + } + + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + // First provider should have been skipped + if callCount != 1 { + t.Errorf("expected 1 call (first skipped due to cooldown), got %d", callCount) + } + + // Should have called the second provider/model + if len(calledProviders) != 1 || + calledProviders[0] != candidates[1].Provider+"/"+candidates[1].Model { + t.Errorf("expected second model to be called, got: %v", calledProviders) + } + + // Verify first attempt was recorded as skipped + if len(result.Attempts) != 1 { + t.Fatalf("expected 1 attempt (skipped), got %d", len(result.Attempts)) + } + + if !result.Attempts[0].Skipped { + t.Error("expected first attempt to be marked as skipped") + } +} + +// TestMultiKeyFailoverWithFormatError tests that format errors are non-retriable +func TestMultiKeyFailoverWithFormatError(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown) + + // Mock run function: first call fails with format error (bad request) + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + return nil, errors.New("invalid request format: tool_use.id missing") + } + + // Execute fallback chain + result, err := chain.Execute(context.Background(), candidates, mockRun) + + if err == nil { + t.Fatal("expected error for format failure, got nil") + } + + // Format errors should NOT trigger failover (non-retriable) + // So we should only have 1 call + if callCount != 1 { + t.Errorf("expected 1 call (format error is non-retriable), got %d", callCount) + } + + // Verify the error is a FailoverError with format reason + var failoverErr *FailoverError + if !errors.As(err, &failoverErr) { + t.Errorf("expected FailoverError, got: %T - %v", err, err) + } + + if failoverErr.Reason != FailoverFormat { + t.Errorf("expected FailoverFormat reason, got: %s", failoverErr.Reason) + } + + _ = result // result should be nil +} + +// TestMultiKeyWithModelFallback tests multi-key failover combined with model fallback. +// This simulates the scenario: api_keys: ["k1", "k2"] + fallbacks: ["minimax"] +// Expected failover order: glm-4.7 (k1) → glm-4.7__key_1 (k2) → minimax +func TestMultiKeyWithModelFallback(t *testing.T) { + // Simulate expanded config from: + // { "model_name": "glm-4.7", "api_keys": ["k1", "k2"], "fallbacks": ["minimax"] } + // After ExpandMultiKeyModels, primaryEntry.Fallbacks = ["glm-4.7__key_1", "minimax"] + // Note: In production, "minimax" would be resolved via model lookup to "minimax/minimax" + // In this test, we use the full format to avoid needing a lookup function. + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "minimax/minimax"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + // Should have 3 candidates: glm-4.7 (zhipu), glm-4.7__key_1 (zhipu), minimax (minimax) + if len(candidates) != 3 { + t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates) + } + + // Verify candidate order + if candidates[0].Model != "glm-4.7" || candidates[0].Provider != "zhipu" { + t.Errorf( + "expected first candidate to be zhipu/glm-4.7, got: %s/%s", + candidates[0].Provider, + candidates[0].Model, + ) + } + if candidates[1].Model != "glm-4.7__key_1" || candidates[1].Provider != "zhipu" { + t.Errorf( + "expected second candidate to be zhipu/glm-4.7__key_1, got: %s/%s", + candidates[1].Provider, + candidates[1].Model, + ) + } + if candidates[2].Model != "minimax" || candidates[2].Provider != "minimax" { + t.Errorf( + "expected third candidate to be minimax/minimax, got: %s/%s", + candidates[2].Provider, + candidates[2].Model, + ) + } + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown) + + // Mock run function: first two fail, third succeeds (model fallback) + callCount := 0 + calledModels := []string{} + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + calledModels = append(calledModels, provider+"/"+model) + + switch callCount { + case 1: + // k1: rate limit + return nil, errors.New("status: 429 - rate limit") + case 2: + // k2: also rate limit (all zhipu keys exhausted) + return nil, errors.New("status: 429 - rate limit") + case 3: + // minimax: success + return &LLMResponse{Content: "success from minimax"}, nil + default: + return nil, errors.New("unexpected call") + } + } + + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success after failover to model fallback, got error: %v", err) + } + + if callCount != 3 { + t.Errorf("expected 3 calls (k1 fail + k2 fail + minimax success), got %d", callCount) + } + + if result.Response.Content != "success from minimax" { + t.Errorf("expected response from minimax, got: %s", result.Response.Content) + } + + // Verify call order + if len(calledModels) != 3 { + t.Fatalf("expected 3 called models, got %d", len(calledModels)) + } + if calledModels[0] != "zhipu/glm-4.7" { + t.Errorf("expected first call to zhipu/glm-4.7, got: %s", calledModels[0]) + } + if calledModels[1] != "zhipu/glm-4.7__key_1" { + t.Errorf("expected second call to zhipu/glm-4.7__key_1, got: %s", calledModels[1]) + } + if calledModels[2] != "minimax/minimax" { + t.Errorf("expected third call to minimax/minimax, got: %s", calledModels[2]) + } + + // Verify 2 failed attempts recorded + if len(result.Attempts) != 2 { + t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts)) + } + + // Both should be rate limit + for i, attempt := range result.Attempts { + if attempt.Reason != FailoverRateLimit { + t.Errorf("expected attempt %d to be rate_limit, got: %s", i, attempt.Reason) + } + } +} + +// TestMultiKeyFailoverMixedErrors tests failover with different error types +func TestMultiKeyFailoverMixedErrors(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown) + + // Mock run function: different errors for each key + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + switch callCount { + case 1: + // First: rate limit (retriable) + return nil, errors.New("status: 429 - rate limit") + case 2: + // Second: timeout (retriable) + return nil, errors.New("context deadline exceeded") + case 3: + // Third: success + return &LLMResponse{Content: "success from key3"}, nil + default: + return nil, errors.New("unexpected call") + } + } + + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success after 2 failovers, got error: %v", err) + } + + if callCount != 3 { + t.Errorf("expected 3 calls, got %d", callCount) + } + + // Verify both failed attempts were recorded + if len(result.Attempts) != 2 { + t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts)) + } + + // First should be rate limit + if result.Attempts[0].Reason != FailoverRateLimit { + t.Errorf("expected first attempt to be rate_limit, got: %s", result.Attempts[0].Reason) + } + + // Second should be timeout + if result.Attempts[1].Reason != FailoverTimeout { + t.Errorf("expected second attempt to be timeout, got: %s", result.Attempts[1].Reason) + } +} diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 1783ebcb5..1a1118e33 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -157,8 +157,8 @@ func TestFallback_CooldownSkip(t *testing.T) { ct, _ := newTestTracker(now) fc := NewFallbackChain(ct) - // Put openai in cooldown - ct.MarkFailure("openai", FailoverRateLimit) + // Put openai/gpt-4 in cooldown (using ModelKey now) + ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -195,9 +195,9 @@ func TestFallback_AllInCooldown(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) - // Put all providers in cooldown - ct.MarkFailure("openai", FailoverRateLimit) - ct.MarkFailure("anthropic", FailoverBilling) + // Put all models in cooldown (using ModelKey now) + ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) + ct.MarkFailure(ModelKey("anthropic", "claude"), FailoverBilling) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -273,12 +273,13 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) { fc := NewFallbackChain(ct) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} + modelKey := ModelKey("openai", "gpt-4") attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ if attempt == 1 { - ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere + ct.MarkFailure(modelKey, FailoverRateLimit) // simulate failure tracked elsewhere } return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil } @@ -287,7 +288,7 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !ct.IsAvailable("openai") { + if !ct.IsAvailable(modelKey) { t.Error("success should reset cooldown") } } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 5c328f418..4d823630e 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -55,3 +55,7 @@ func (p *HTTPProvider) Chat( func (p *HTTPProvider) GetDefaultModel() string { return "" } + +func (p *HTTPProvider) SupportsNativeSearch() bool { + return p.delegate.SupportsNativeSearch() +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 315c2139e..a37fafd78 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -165,8 +165,11 @@ func (p *Provider) buildHTTPRequest( "messages": serializeMessages(messages), } - if len(tools) > 0 { - requestBody["tools"] = tools + // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. + nativeSearch, _ := options["native_search"].(bool) + nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase) + if len(tools) > 0 || nativeSearch { + requestBody["tools"] = buildToolsList(tools, nativeSearch) requestBody["tool_choice"] = "auto" } @@ -760,7 +763,7 @@ func normalizeModel(model, apiBase string) string { prefix := strings.ToLower(before) switch prefix { case "openai", "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", - "google", "openrouter", "zhipu", "minimax", "mistral", "vivgrid": + "google", "openrouter", "zhipu", "minimax", "mistral", "vivgrid", "novita": return after default: return model @@ -797,6 +800,33 @@ func asFloat(v any) (float64, bool) { } } +func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any { + result := make([]any, 0, len(tools)+1) + for _, t := range tools { + if nativeSearch && strings.EqualFold(t.Function.Name, "web_search") { + continue + } + result = append(result, t) + } + if nativeSearch { + result = append(result, map[string]any{"type": "web_search_preview"}) + } + return result +} + +func (p *Provider) SupportsNativeSearch() bool { + return isNativeSearchHost(p.apiBase) +} + +func isNativeSearchHost(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") +} + // --- SSE streaming support --- type streamChunk struct { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index f21318f0f..31186f473 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -431,7 +431,28 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin } } -func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) { +func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") tests := []struct { name string input string @@ -462,31 +483,25 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) { input: "vivgrid/auto", wantModel: "auto", }, + { + name: "strips novita prefix deepseek model", + input: "novita/deepseek/deepseek-v3.2", + wantModel: "deepseek/deepseek-v3.2", + }, + { + name: "strips novita prefix zai model", + input: "novita/zai-org/glm-5", + wantModel: "zai-org/glm-5", + }, + { + name: "strips novita prefix minimax model", + input: "novita/minimax/minimax-m2.5", + wantModel: "minimax/minimax-m2.5", + }, } for _, tt := range tests { //nolint:dupl t.Run(tt.name, func(t *testing.T) { - var requestBody map[string]any - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - resp := map[string]any{ - "choices": []map[string]any{ - { - "message": map[string]any{"content": "ok"}, - "finish_reason": "stop", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -572,6 +587,12 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" { t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto") } + if got := normalizeModel( + "novita/deepseek/deepseek-v3.2", + "https://api.novita.ai/openai", + ); got != "deepseek/deepseek-v3.2" { + t.Fatalf("normalizeModel(novita) = %q, want %q", got, "deepseek/deepseek-v3.2") + } } func TestProvider_RequestTimeoutDefault(t *testing.T) { @@ -823,6 +844,232 @@ func TestSupportsPromptCacheKey(t *testing.T) { } } +func TestBuildToolsList_NativeSearchAddsWebSearchPreview(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, true) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } + wsEntry, ok := result[1].(map[string]any) + if !ok { + t.Fatalf("web search entry is %T, want map[string]any", result[1]) + } + if wsEntry["type"] != "web_search_preview" { + t.Fatalf("type = %v, want web_search_preview", wsEntry["type"]) + } +} + +func TestBuildToolsList_NativeSearchFiltersClientWebSearch(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, true) + for _, entry := range result { + if td, ok := entry.(ToolDefinition); ok && strings.EqualFold(td.Function.Name, "web_search") { + t.Fatal("client-side web_search should be filtered out when native search is enabled") + } + } + if len(result) != 2 { // read_file + web_search_preview + t.Fatalf("len(result) = %d, want 2 (read_file + web_search_preview)", len(result)) + } +} + +func TestBuildToolsList_NoNativeSearchPassesThrough(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, false) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } +} + +func TestIsNativeSearchHost(t *testing.T) { + tests := []struct { + apiBase string + want bool + }{ + {"https://api.openai.com/v1", true}, + {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, + {"https://api.mistral.ai/v1", false}, + {"https://api.deepseek.com/v1", false}, + {"https://api.groq.com/openai/v1", false}, + {"http://localhost:11434/v1", false}, + {"", false}, + } + for _, tt := range tests { + if got := isNativeSearchHost(tt.apiBase); got != tt.want { + t.Errorf("isNativeSearchHost(%q) = %v, want %v", tt.apiBase, got, tt.want) + } + } +} + +func TestSupportsNativeSearch_OpenAI(t *testing.T) { + p := NewProvider("key", "https://api.openai.com/v1", "") + if !p.SupportsNativeSearch() { + t.Fatal("OpenAI provider should support native search") + } +} + +func TestSupportsNativeSearch_NonOpenAI(t *testing.T) { + p := NewProvider("key", "https://api.deepseek.com/v1", "") + if p.SupportsNativeSearch() { + t.Fatal("DeepSeek provider should not support native search") + } +} + +func TestProviderChat_NativeSearchToolInjected(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.apiBase = "https://api.openai.com/v1" + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + tools, + "gpt-5.4", + map[string]any{"native_search": true}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsRaw, ok := requestBody["tools"].([]any) + if !ok { + t.Fatalf("tools is %T, want []any", requestBody["tools"]) + } + if len(toolsRaw) != 2 { + t.Fatalf("len(tools) = %d, want 2 (read_file + web_search_preview)", len(toolsRaw)) + } + + lastTool, ok := toolsRaw[1].(map[string]any) + if !ok { + t.Fatalf("last tool is %T, want map[string]any", toolsRaw[1]) + } + if lastTool["type"] != "web_search_preview" { + t.Fatalf("last tool type = %v, want web_search_preview", lastTool["type"]) + } +} + +func TestProviderChat_NativeSearchNotInjectedWithoutOption(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + } + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + tools, + "gpt-5.4", + map[string]any{}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsRaw, ok := requestBody["tools"].([]any) + if !ok { + t.Fatalf("tools is %T, want []any", requestBody["tools"]) + } + if len(toolsRaw) != 1 { + t.Fatalf("len(tools) = %d, want 1 (web_search only)", len(toolsRaw)) + } +} + +// TestProviderChat_NativeSearchIgnoredOnNonOpenAI verifies that when native_search +// is true in options but the provider's apiBase is not OpenAI (e.g. fallback to DeepSeek), +// we do not inject web_search_preview to avoid API errors. +func TestProviderChat_NativeSearchIgnoredOnNonOpenAI(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Use server.URL so host is not api.openai.com — simulates DeepSeek/other provider + p := NewProvider("key", server.URL, "") + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "deepseek-chat", + map[string]any{"native_search": true}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Should not have tools at all (no tools passed, and we must not add web_search_preview) + if toolsRaw, ok := requestBody["tools"]; ok { + t.Fatalf("tools should be omitted for non-OpenAI when only native_search was requested, got %v", toolsRaw) + } +} + func TestSerializeMessages_StripsSystemParts(t *testing.T) { messages := []protocoltypes.Message{ { diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 7f289421b..9abe45c2f 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -45,6 +45,15 @@ type ThinkingCapable interface { SupportsThinking() bool } +// NativeSearchCapable is an optional interface for providers that support +// built-in web search during LLM inference (e.g. OpenAI web_search_preview, +// xAI Grok search). When the active provider implements this interface and +// returns true, the agent loop can hide the client-side web_search tool to +// avoid duplicate search surfaces and use the provider's native search instead. +type NativeSearchCapable interface { + SupportsNativeSearch() bool +} + // FailoverReason classifies why an LLM request failed for fallback decisions. type FailoverReason string diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 648cc3c6c..154ec75f0 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -20,10 +20,12 @@ type JobExecutor interface { // CronTool provides scheduling capabilities for the agent type CronTool struct { - cronService *cron.CronService - executor JobExecutor - msgBus *bus.MessageBus - execTool *ExecTool + cronService *cron.CronService + executor JobExecutor + msgBus *bus.MessageBus + execTool *ExecTool + allowCommand bool + execEnabled bool } // NewCronTool creates a new CronTool @@ -32,17 +34,32 @@ func NewCronTool( cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config, ) (*CronTool, error) { - execTool, err := NewExecToolWithConfig(workspace, restrict, config) - if err != nil { - return nil, fmt.Errorf("unable to configure exec tool: %w", err) + allowCommand := true + execEnabled := true + if config != nil { + allowCommand = config.Tools.Cron.AllowCommand + execEnabled = config.Tools.Exec.Enabled } - execTool.SetTimeout(execTimeout) + var execTool *ExecTool + if execEnabled { + var err error + execTool, err = NewExecToolWithConfig(workspace, restrict, config) + if err != nil { + return nil, fmt.Errorf("unable to configure exec tool: %w", err) + } + } + + if execTool != nil { + execTool.SetTimeout(execTimeout) + } return &CronTool{ - cronService: cronService, - executor: executor, - msgBus: msgBus, - execTool: execTool, + cronService: cronService, + executor: executor, + msgBus: msgBus, + execTool: execTool, + allowCommand: allowCommand, + execEnabled: execEnabled, }, nil } @@ -76,7 +93,7 @@ func (t *CronTool) Parameters() map[string]any { }, "command_confirm": map[string]any{ "type": "boolean", - "description": "Required when using command=true. Must be true to explicitly confirm scheduling a shell command.", + "description": "Optional explicit confirmation flag for scheduling a shell command. Command execution must also be enabled via tools.cron.allow_command.", }, "at_seconds": map[string]any{ "type": "integer", @@ -96,7 +113,7 @@ func (t *CronTool) Parameters() map[string]any { }, "deliver": map[string]any{ "type": "boolean", - "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true", + "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false", }, }, "required": []string{"action"}, @@ -174,22 +191,26 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required") } - // Read deliver parameter, default to true - deliver := true + // Read deliver parameter, default to false so scheduled tasks execute through the agent + deliver := false if d, ok := args["deliver"].(bool); ok { deliver = d } - // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel + explicit confirm. - // Non-command reminders (plain messages) remain open to all channels. + // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When + // allow_command is disabled, explicit confirmation is required as an override. + // Non-command reminders remain open to all channels. command, _ := args["command"].(string) commandConfirm, _ := args["command_confirm"].(bool) if command != "" { + if !t.execEnabled { + return ErrorResult("command execution is disabled") + } if !constants.IsInternalChannel(channel) { return ErrorResult("scheduling command execution is restricted to internal channels") } - if !commandConfirm { - return ErrorResult("command_confirm=true is required to schedule command execution") + if !t.allowCommand && !commandConfirm { + return ErrorResult("command_confirm=true is required when allow_command is disabled") } deliver = false } @@ -290,6 +311,18 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // Execute command if present if job.Payload.Command != "" { + if !t.execEnabled || t.execTool == nil { + output := "Error executing scheduled command: command execution is disabled" + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: output, + }) + return "ok" + } + args := map[string]any{ "command": job.Payload.Command, "__channel": channel, diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index 1776abc65..cd7d39860 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -5,18 +5,18 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" ) -func newTestCronTool(t *testing.T) *CronTool { +func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool { t.Helper() storePath := filepath.Join(t.TempDir(), "cron.json") cronService := cron.NewCronService(storePath, nil) msgBus := bus.NewMessageBus() - cfg := config.DefaultConfig() tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg) if err != nil { t.Fatalf("NewCronTool() error: %v", err) @@ -24,6 +24,11 @@ func newTestCronTool(t *testing.T) *CronTool { return tool } +func newTestCronTool(t *testing.T) *CronTool { + t.Helper() + return newTestCronToolWithConfig(t, config.DefaultConfig()) +} + // TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) { tool := newTestCronTool(t) @@ -44,8 +49,7 @@ func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) { } } -// TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required -func TestCronTool_CommandRequiresConfirm(t *testing.T) { +func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ @@ -55,11 +59,79 @@ func TestCronTool_CommandRequiresConfirm(t *testing.T) { "at_seconds": float64(60), }) + if result.IsError { + t.Fatalf("expected command scheduling without confirm to succeed by default, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Cron job added") { + t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) + } +} + +func TestCronTool_CommandRequiresConfirmWhenAllowCommandDisabled(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Cron.AllowCommand = false + + tool := newTestCronToolWithConfig(t, cfg) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "at_seconds": float64(60), + }) + if !result.IsError { - t.Fatal("expected error when command_confirm is missing") + t.Fatal("expected command scheduling to require confirm when allow_command is disabled") } if !strings.Contains(result.ForLLM, "command_confirm=true") { - t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM) + t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM) + } +} + +func TestCronTool_CommandAllowedWithConfirmWhenAllowCommandDisabled(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Cron.AllowCommand = false + + tool := newTestCronToolWithConfig(t, cfg) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if result.IsError { + t.Fatalf( + "expected command scheduling with confirm to succeed when allow_command is disabled, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "Cron job added") { + t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) + } +} + +func TestCronTool_CommandBlockedWhenExecDisabled(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Exec.Enabled = false + + tool := newTestCronToolWithConfig(t, cfg) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected command scheduling to be blocked when exec is disabled") + } + if !strings.Contains(result.ForLLM, "command execution is disabled") { + t.Errorf("expected exec disabled message, got: %s", result.ForLLM) } } @@ -114,3 +186,54 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM) } } + +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 + + tool := newTestCronToolWithConfig(t, cfg) + job := &cron.CronJob{} + job.Payload.Channel = "cli" + job.Payload.To = "direct" + job.Payload.Command = "df -h" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + var msg bus.OutboundMessage + select { + case msg = <-tool.msgBus.OutboundChan(): + // got message + case <-ctx.Done(): + t.Fatal("timeout waiting for outbound message") + } + if !strings.Contains(msg.Content, "command execution is disabled") { + t.Fatalf("expected exec disabled message, got: %s", msg.Content) + } +} diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 7a165e123..d4d08fed8 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -20,11 +20,10 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow -// validatePath ensures the given path is within the workspace if restrict is true. - -// Used by shell.go for working directory validation. - -func validatePath(path, workspace string, restrict bool) (string, error) { +// validatePathWithAllowPaths ensures the given path is within the workspace if +// restrict is true. patterns optionally allow access to paths outside the +// workspace (used by shell.go for working directory validation). +func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) { if workspace == "" { return path, fmt.Errorf("workspace is not defined") } @@ -45,6 +44,10 @@ func validatePath(path, workspace string, restrict bool) (string, error) { } if restrict { + if isAllowedPath(absPath, patterns) { + return absPath, nil + } + if !isWithinWorkspace(absPath, absWorkspace) { return "", fmt.Errorf("access denied: path outside workspace %s", absWorkspace) } @@ -76,6 +79,143 @@ func validatePath(path, workspace string, restrict bool) (string, error) { return absPath, nil } +// validatePath is a convenience wrapper around validatePathWithAllowPaths with +// no extra allow-path patterns. Used by shell.go and tests. +func validatePath(path, workspace string, restrict bool) (string, error) { + return validatePathWithAllowPaths(path, workspace, restrict, nil) +} + +func isAllowedPath(path string, patterns []*regexp.Regexp) bool { + if len(patterns) == 0 { + return false + } + + cleaned := filepath.Clean(path) + if !filepath.IsAbs(cleaned) { + return false + } + if !matchesAllowedPath(cleaned, patterns) { + return false + } + + resolved, err := resolvePathAgainstExistingAncestor(cleaned) + if err != nil { + return false + } + + return matchesAllowedPath(resolved, patterns) +} + +func matchesAllowedPath(path string, patterns []*regexp.Regexp) bool { + cleaned := filepath.Clean(path) + for _, pattern := range patterns { + if pattern.MatchString(cleaned) { + return true + } + if root, ok := extractAllowedPathRoot(pattern); ok && isWithinAllowedRoot(cleaned, root) { + return true + } + } + return false +} + +func extractAllowedPathRoot(pattern *regexp.Regexp) (string, bool) { + raw := pattern.String() + if !strings.HasPrefix(raw, "^") { + return "", false + } + + literal := strings.TrimPrefix(raw, "^") + + // Recognize the common "directory prefix" form: ^(?:/|$) + literal = strings.TrimSuffix(literal, "(?:/|$)") + literal = strings.TrimSuffix(literal, `(?:\\|$)`) + + // Reject patterns that still contain regex operators after removing the + // optional anchored-directory suffix. That keeps arbitrary regex behavior + // unchanged and only enables normalized prefix matching for literal paths. + if containsUnescapedRegexMeta(literal) { + return "", false + } + + unescaped, ok := unescapeRegexLiteral(literal) + if !ok || unescaped == "" { + return "", false + } + + return filepath.Clean(unescaped), filepath.IsAbs(unescaped) +} + +func appendUniquePath(paths []string, path string) []string { + for _, existing := range paths { + if existing == path { + return paths + } + } + return append(paths, path) +} + +func containsUnescapedRegexMeta(s string) bool { + escaped := false + for _, r := range s { + if escaped { + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + switch r { + case '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '|': + return true + } + } + return escaped +} + +func unescapeRegexLiteral(s string) (string, bool) { + var b strings.Builder + b.Grow(len(s)) + + escaped := false + for _, r := range s { + if escaped { + b.WriteRune(r) + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + b.WriteRune(r) + } + + if escaped { + return "", false + } + + return b.String(), true +} + +func isWithinAllowedRoot(path, root string) bool { + candidate := filepath.Clean(path) + allowedVariants := []string{filepath.Clean(root)} + + if resolvedRoot, err := resolvePathAgainstExistingAncestor(root); err == nil { + allowedVariants = appendUniquePath(allowedVariants, filepath.Clean(resolvedRoot)) + } + + for _, allowedRoot := range allowedVariants { + if isWithinWorkspace(candidate, allowedRoot) { + return true + } + } + + return false +} + func resolveExistingAncestor(path string) (string, error) { for current := filepath.Clean(path); ; current = filepath.Dir(current) { if resolved, err := filepath.EvalSymlinks(current); err == nil { @@ -89,9 +229,32 @@ func resolveExistingAncestor(path string) (string, error) { } } +func resolvePathAgainstExistingAncestor(path string) (string, error) { + cleaned := filepath.Clean(path) + for current := cleaned; ; current = filepath.Dir(current) { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + suffix, relErr := filepath.Rel(current, cleaned) + if relErr != nil { + return "", relErr + } + if suffix == "." { + return filepath.Clean(resolved), nil + } + return filepath.Clean(filepath.Join(resolved, suffix)), nil + } + if !os.IsNotExist(err) { + return "", err + } + if filepath.Dir(current) == current { + return "", os.ErrNotExist + } + } +} + func isWithinWorkspace(candidate, workspace string) bool { rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) - return err == nil && filepath.IsLocal(rel) + return err == nil && (rel == "." || filepath.IsLocal(rel)) } type ReadFileTool struct { @@ -697,12 +860,7 @@ type whitelistFs struct { } func (w *whitelistFs) matches(path string) bool { - for _, p := range w.patterns { - if p.MatchString(path) { - return true - } - } - return false + return isAllowedPath(path, w.patterns) } func (w *whitelistFs) ReadFile(path string) ([]byte, error) { diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 98cad2561..e5b104477 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -521,6 +521,90 @@ func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) { } } +func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { + workspace := t.TempDir() + allowedDir := t.TempDir() + secretDir := t.TempDir() + secretFile := filepath.Join(secretDir, "secret.txt") + if err := os.WriteFile(secretFile, []byte("top secret"), 0o644); err != nil { + t.Fatalf("WriteFile(secretFile) error = %v", err) + } + + linkPath := filepath.Join(allowedDir, "link_out") + if err := os.Symlink(secretDir, linkPath); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} + tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) + + result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")}) + if !result.IsError { + t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) + } +} + +func TestWhitelistFs_WriteAllowsNewFileUnderAllowedDir(t *testing.T) { + workspace := t.TempDir() + rootDir := t.TempDir() + allowedDir := filepath.Join(rootDir, "allowed") + targetFile := filepath.Join(allowedDir, "nested", "file.txt") + + patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} + tool := NewWriteFileTool(workspace, true, patterns) + + result := tool.Execute(context.Background(), map[string]any{ + "path": targetFile, + "content": "outside write", + }) + if result.IsError { + t.Fatalf("expected whitelisted write to succeed, got: %s", result.ForLLM) + } + + data, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("ReadFile(targetFile) error = %v", err) + } + if string(data) != "outside write" { + t.Fatalf("target file content = %q, want %q", string(data), "outside write") + } +} + +func TestWhitelistFs_AllowsResolvedAllowedRootAlias(t *testing.T) { + workspace := t.TempDir() + realDir := t.TempDir() + linkParent := t.TempDir() + allowedAlias := filepath.Join(linkParent, "allowed-link") + + if err := os.Symlink(realDir, allowedAlias); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + targetFile := filepath.Join(allowedAlias, "nested", "alias.txt") + if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil { + t.Fatalf("MkdirAll(targetFile dir) error = %v", err) + } + if err := os.WriteFile(targetFile, []byte("through alias"), 0o644); err != nil { + t.Fatalf("WriteFile(targetFile) error = %v", err) + } + + patterns := []*regexp.Regexp{ + regexp.MustCompile( + "^" + regexp.QuoteMeta(filepath.Clean(allowedAlias)) + + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", + ), + } + tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) + + result := tool.Execute(context.Background(), map[string]any{"path": targetFile}) + if result.IsError { + t.Fatalf("expected symlink-backed allowed root to be readable, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "through alias") { + t.Fatalf("expected file content, got: %s", result.ForLLM) + } +} + // TestReadFileTool_ChunkedReading verifies the pagination logic of the tool // by reading a file in multiple chunks using 'offset' and 'length'. func TestReadFileTool_ChunkedReading(t *testing.T) { diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 9b5577b7c..8211e147d 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -202,15 +202,48 @@ func (r *ToolRegistry) ExecuteWithContext( // The callback is a call parameter, not mutable state on the tool instance. var result *ToolResult start := time.Now() - if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { - logger.DebugCF("tool", "Executing async tool via ExecuteAsync", - map[string]any{ - "tool": name, - }) - result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) - } else { - result = tool.Execute(ctx, args) + + // Use recover to catch any panics during tool execution + // This prevents tool crashes from killing the entire agent + func() { + defer func() { + if re := recover(); re != nil { + errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re) + logger.ErrorCF("tool", "Tool execution panic recovered", + map[string]any{ + "tool": name, + "panic": fmt.Sprintf("%v", re), + }) + result = &ToolResult{ + ForLLM: errMsg, + ForUser: errMsg, + IsError: true, + Err: fmt.Errorf("panic: %v", re), + } + } + }() + + if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { + logger.DebugCF("tool", "Executing async tool via ExecuteAsync", + map[string]any{ + "tool": name, + }) + result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) + } else { + result = tool.Execute(ctx, args) + } + }() + + // Handle nil result (should not happen, but defensive) + if result == nil { + result = &ToolResult{ + ForLLM: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name), + ForUser: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name), + IsError: true, + Err: fmt.Errorf("nil result from tool"), + } } + duration := time.Since(start) // Log based on result type @@ -353,6 +386,28 @@ func (r *ToolRegistry) List() []string { return r.sortedToolNames() } +// Clone creates an independent copy of the registry containing the same tool +// entries (shallow copy of each ToolEntry). This is used to give subagents a +// snapshot of the parent agent's tools without sharing the same registry — +// tools registered on the parent after cloning (e.g. spawn, spawn_status) +// will NOT be visible to the clone, preventing recursive subagent spawning. +// The version counter is reset to 0 in the clone as it's a new independent registry. +func (r *ToolRegistry) Clone() *ToolRegistry { + r.mu.RLock() + defer r.mu.RUnlock() + clone := &ToolRegistry{ + tools: make(map[string]*ToolEntry, len(r.tools)), + } + for name, entry := range r.tools { + clone.tools[name] = &ToolEntry{ + Tool: entry.Tool, + IsCore: entry.IsCore, + TTL: entry.TTL, + } + } + return clone +} + // Count returns the number of registered tools. func (r *ToolRegistry) Count() int { r.mu.RLock() diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 6bde1d39c..b6f362c50 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -3,6 +3,7 @@ package tools import ( "context" "encoding/json" + "errors" "fmt" "strings" "sync" @@ -338,6 +339,96 @@ func TestToolToSchema(t *testing.T) { } } +func TestToolRegistry_Clone(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("read_file", "reads files")) + r.Register(newMockTool("exec", "runs commands")) + r.Register(newMockTool("web_search", "searches the web")) + + clone := r.Clone() + + // Clone should have the same tools + if clone.Count() != 3 { + t.Errorf("expected clone to have 3 tools, got %d", clone.Count()) + } + for _, name := range []string{"read_file", "exec", "web_search"} { + if _, ok := clone.Get(name); !ok { + t.Errorf("expected clone to have tool %q", name) + } + } + + // Registering on parent should NOT affect clone + r.Register(newMockTool("spawn", "spawns subagent")) + if r.Count() != 4 { + t.Errorf("expected parent to have 4 tools, got %d", r.Count()) + } + if clone.Count() != 3 { + t.Errorf("expected clone to still have 3 tools after parent mutation, got %d", clone.Count()) + } + if _, ok := clone.Get("spawn"); ok { + t.Error("expected clone NOT to have 'spawn' tool registered on parent after cloning") + } + + // Registering on clone should NOT affect parent + clone.Register(newMockTool("custom", "custom tool")) + if clone.Count() != 4 { + t.Errorf("expected clone to have 4 tools, got %d", clone.Count()) + } + if _, ok := r.Get("custom"); ok { + t.Error("expected parent NOT to have 'custom' tool registered on clone") + } +} + +func TestToolRegistry_Clone_Empty(t *testing.T) { + r := NewToolRegistry() + clone := r.Clone() + if clone.Count() != 0 { + t.Errorf("expected empty clone, got count %d", clone.Count()) + } +} + +func TestToolRegistry_Clone_PreservesHiddenToolState(t *testing.T) { + r := NewToolRegistry() + r.RegisterHidden(newMockTool("mcp_tool", "dynamic MCP tool")) + + clone := r.Clone() + + // Hidden tools with TTL=0 should not be gettable (same behavior as parent) + if _, ok := clone.Get("mcp_tool"); ok { + t.Error("expected hidden tool with TTL=0 to be invisible in clone") + } + + // But the entry should exist (count includes hidden tools) + if clone.Count() != 1 { + t.Errorf("expected clone count 1 (hidden entry exists), got %d", clone.Count()) + } +} + +func TestToolRegistry_Clone_PreservesTTLValue(t *testing.T) { + r := NewToolRegistry() + r.RegisterHidden(newMockTool("ttl_tool", "tool with TTL")) + + // Manually set a non-zero TTL on the entry + r.mu.RLock() + if entry, ok := r.tools["ttl_tool"]; ok { + entry.TTL = 5 + } + r.mu.RUnlock() + + clone := r.Clone() + + // Verify TTL value is preserved in the clone + clone.mu.RLock() + defer clone.mu.RUnlock() + entry, ok := clone.tools["ttl_tool"] + if !ok { + t.Fatal("expected ttl_tool to exist in clone") + } + if entry.TTL != 5 { + t.Errorf("expected TTL=5 in clone, got %d", entry.TTL) + } +} + func TestToolRegistry_ConcurrentAccess(t *testing.T) { r := NewToolRegistry() var wg sync.WaitGroup @@ -479,3 +570,175 @@ func BenchmarkToProviderDefs_NoCache(b *testing.B) { r.ToProviderDefs() } } + +// --- Panic and abnormal exit tests --- + +// mockPanicTool is a tool that panics during execution +type mockPanicTool struct { + name string + panicValue any +} + +func (m *mockPanicTool) Name() string { return m.name } +func (m *mockPanicTool) Description() string { return "a tool that panics" } +func (m *mockPanicTool) Parameters() map[string]any { return map[string]any{"type": "object"} } +func (m *mockPanicTool) Execute(_ context.Context, _ map[string]any) *ToolResult { + panic(m.panicValue) +} + +// mockNilResultTool is a tool that returns nil +type mockNilResultTool struct { + name string +} + +func (m *mockNilResultTool) Name() string { return m.name } +func (m *mockNilResultTool) Description() string { return "a tool that returns nil" } +func (m *mockNilResultTool) Parameters() map[string]any { return map[string]any{"type": "object"} } +func (m *mockNilResultTool) Execute(_ context.Context, _ map[string]any) *ToolResult { + return nil +} + +func TestToolRegistry_Execute_PanicRecovery(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockPanicTool{ + name: "panic_tool", + panicValue: "something went terribly wrong", + }) + + // Should not panic, should return error result + result := r.Execute(context.Background(), "panic_tool", nil) + + if result == nil { + t.Fatal("expected non-nil result after panic recovery") + } + if !result.IsError { + t.Error("expected IsError=true after panic") + } + if !strings.Contains(result.ForLLM, "panic") { + t.Errorf("expected 'panic' in error message, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "panic_tool") { + t.Errorf("expected tool name in error message, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "something went terribly wrong") { + t.Errorf("expected panic value in error message, got %q", result.ForLLM) + } + if result.Err == nil { + t.Error("expected Err to be set") + } +} + +func TestToolRegistry_Execute_PanicRecovery_ErrorType(t *testing.T) { + r := NewToolRegistry() + + // Test with error type panic + r.Register(&mockPanicTool{ + name: "error_panic_tool", + panicValue: errors.New("custom error panic"), + }) + + result := r.Execute(context.Background(), "error_panic_tool", nil) + + if !result.IsError { + t.Error("expected IsError=true") + } + if !strings.Contains(result.ForLLM, "custom error panic") { + t.Errorf("expected error message in ForLLM, got %q", result.ForLLM) + } +} + +func TestToolRegistry_Execute_PanicRecovery_IntType(t *testing.T) { + r := NewToolRegistry() + + // Test with int type panic + r.Register(&mockPanicTool{ + name: "int_panic_tool", + panicValue: 42, + }) + + result := r.Execute(context.Background(), "int_panic_tool", nil) + + if !result.IsError { + t.Error("expected IsError=true") + } + if !strings.Contains(result.ForLLM, "42") { + t.Errorf("expected panic value '42' in ForLLM, got %q", result.ForLLM) + } +} + +func TestToolRegistry_Execute_NilResultHandling(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockNilResultTool{name: "nil_tool"}) + + result := r.Execute(context.Background(), "nil_tool", nil) + + if result == nil { + t.Fatal("expected non-nil result when tool returns nil") + } + if !result.IsError { + t.Error("expected IsError=true for nil result") + } + if !strings.Contains(result.ForLLM, "nil_tool") { + t.Errorf("expected tool name in error message, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "nil result") { + t.Errorf("expected 'nil result' in error message, got %q", result.ForLLM) + } + if result.Err == nil { + t.Error("expected Err to be set") + } +} + +func TestToolRegistry_ExecuteWithContext_PanicRecovery(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockPanicTool{ + name: "ctx_panic_tool", + panicValue: "context panic test", + }) + + // Should not panic even with context + result := r.ExecuteWithContext( + context.Background(), + "ctx_panic_tool", + map[string]any{"key": "value"}, + "telegram", + "chat-123", + nil, + ) + + if result == nil { + t.Fatal("expected non-nil result") + } + if !result.IsError { + t.Error("expected IsError=true") + } + if !strings.Contains(result.ForLLM, "context panic test") { + t.Errorf("expected panic message, got %q", result.ForLLM) + } +} + +func TestToolRegistry_Execute_PanicDoesNotAffectOtherTools(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockPanicTool{name: "bad_tool", panicValue: "boom"}) + r.Register(&mockRegistryTool{ + name: "good_tool", + desc: "works fine", + params: map[string]any{}, + result: SilentResult("success"), + }) + + // First, trigger the panic + result1 := r.Execute(context.Background(), "bad_tool", nil) + if !result1.IsError { + t.Error("expected error from panic tool") + } + + // Then, verify the good tool still works + result2 := r.Execute(context.Background(), "good_tool", nil) + if result2.IsError { + t.Errorf("expected success from good tool, got error: %s", result2.ForLLM) + } + if result2.ForLLM != "success" { + t.Errorf("expected 'success', got %q", result2.ForLLM) + } +} diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go index 1a03e58ed..a67bd4210 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/send_file.go @@ -6,6 +6,7 @@ import ( "mime" "os" "path/filepath" + "regexp" "strings" "github.com/h2non/filetype" @@ -21,20 +22,32 @@ type SendFileTool struct { restrict bool maxFileSize int mediaStore media.MediaStore + allowPaths []*regexp.Regexp defaultChannel string defaultChatID string } -func NewSendFileTool(workspace string, restrict bool, maxFileSize int, store media.MediaStore) *SendFileTool { +func NewSendFileTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *SendFileTool { if maxFileSize <= 0 { maxFileSize = config.DefaultMaxMediaSize } + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } return &SendFileTool{ workspace: workspace, restrict: restrict, maxFileSize: maxFileSize, mediaStore: store, + allowPaths: patterns, } } @@ -92,7 +105,7 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("media store not configured") } - resolved, err := validatePath(path, t.workspace, t.restrict) + resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) if err != nil { return ErrorResult(fmt.Sprintf("invalid path: %v", err)) } diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go index 08d129674..6daaab31c 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/send_file_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "regexp" "strings" "testing" @@ -128,6 +129,44 @@ func TestSendFileTool_CustomFilename(t *testing.T) { } } +func TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) { + workspace := t.TempDir() + mediaDir := media.TempDir() + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + t.Fatalf("MkdirAll(mediaDir) error = %v", err) + } + + testFile, err := os.CreateTemp(mediaDir, "send-file-*.txt") + if err != nil { + t.Fatalf("CreateTemp(mediaDir) error = %v", err) + } + testPath := testFile.Name() + if _, err := testFile.WriteString("forward me"); err != nil { + testFile.Close() + t.Fatalf("WriteString(testFile) error = %v", err) + } + if err := testFile.Close(); err != nil { + t.Fatalf("Close(testFile) error = %v", err) + } + t.Cleanup(func() { _ = os.Remove(testPath) }) + + pattern := regexp.MustCompile( + "^" + regexp.QuoteMeta(filepath.Clean(mediaDir)) + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", + ) + + store := media.NewFileMediaStore() + tool := NewSendFileTool(workspace, true, 0, store, []*regexp.Regexp{pattern}) + tool.SetContext("feishu", "chat123") + + result := tool.Execute(context.Background(), map[string]any{"path": testPath}) + if result.IsError { + t.Fatalf("expected whitelisted temp media file to be sendable, got: %s", result.ForLLM) + } + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } +} + func TestDetectMediaType_MagicBytes(t *testing.T) { dir := t.TempDir() diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a04602a32..e152eb2d1 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -28,6 +28,8 @@ type ExecTool struct { customAllowPatterns []*regexp.Regexp + allowedPathPatterns []*regexp.Regexp + restrictToWorkspace bool allowRemote bool @@ -143,14 +145,23 @@ var ( } ) -func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { - return NewExecToolWithConfig(workingDir, restrict, nil) +func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) { + return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...) } -func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) { +func NewExecToolWithConfig( + workingDir string, + restrict bool, + config *config.Config, + allowPaths ...[]*regexp.Regexp, +) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) customAllowPatterns := make([]*regexp.Regexp, 0) + var allowedPathPatterns []*regexp.Regexp allowRemote := true + if len(allowPaths) > 0 { + allowedPathPatterns = allowPaths[0] + } if config != nil { execConfig := config.Tools.Exec @@ -206,6 +217,8 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf customAllowPatterns: customAllowPatterns, + allowedPathPatterns: allowedPathPatterns, + restrictToWorkspace: restrict, allowRemote: allowRemote, @@ -299,8 +312,8 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } if wd, ok := args["working_dir"].(string); ok && wd != "" { - if t.restrictToWorkspace && cwd != "" { - resolvedWD, err := validatePath(wd, cwd, true) + if t.restrictToWorkspace && t.workingDir != "" { + resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns) if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } @@ -328,16 +341,20 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult if err != nil { return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err)) } - absWorkspace, _ := filepath.Abs(t.workingDir) - wsResolved, _ := filepath.EvalSymlinks(absWorkspace) - if wsResolved == "" { - wsResolved = absWorkspace + if isAllowedPath(resolved, t.allowedPathPatterns) { + cwd = resolved + } else { + absWorkspace, _ := filepath.Abs(t.workingDir) + wsResolved, _ := filepath.EvalSymlinks(absWorkspace) + if wsResolved == "" { + wsResolved = absWorkspace + } + rel, err := filepath.Rel(wsResolved, resolved) + if err != nil || !filepath.IsLocal(rel) { + return ErrorResult("Command blocked by safety guard (working directory escaped workspace)") + } + cwd = resolved } - rel, err := filepath.Rel(wsResolved, resolved) - if err != nil || !filepath.IsLocal(rel) { - return ErrorResult("Command blocked by safety guard (working directory escaped workspace)") - } - cwd = resolved } if bg { @@ -413,15 +430,31 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe if err != nil { if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { msg := fmt.Sprintf("Command timed out after %v", t.timeout) + if ob.Len() > 0 { + msg += "\n\nPartial output before timeout:\n" + ob.String() + } return &ToolResult{ ForLLM: msg, ForUser: msg, IsError: true, + Err: fmt.Errorf("command timeout: %w", err), } } - fmt.Fprintf(&ob, "\nExit code: %v", err) + // Extract detailed exit information + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode := exitErr.ExitCode() + fmt.Fprintf(&ob, "\n\n[Command exited with code %d]", exitCode) + + // Add signal information if killed by signal (Unix) + if exitCode == -1 { + ob.WriteString(" (killed by signal)") + } + } else { + fmt.Fprintf(&ob, "\n\n[Command failed: %v]", err) + } } output := ob.String() @@ -581,6 +614,9 @@ func (t *ExecTool) guardCommand(command, cwd string) string { if safePaths[p] { continue } + if isAllowedPath(p, t.allowedPathPatterns) { + continue + } rel, err := filepath.Rel(cwdPath, p) if err != nil { diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index c4553020f..f8f83ea74 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -489,6 +489,69 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) { } } +// TestShellTool_ExitCodeDetails verifies that exit codes are captured with details +func TestShellTool_ExitCodeDetails(t *testing.T) { + tool, err := NewExecTool("", false) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + ctx := context.Background() + args := map[string]any{ + "command": "sh -c 'exit 42'", + } + + result := tool.Execute(ctx, args) + + if !result.IsError { + t.Error("expected error for non-zero exit code") + } + + // Should contain the exit code in the message (new format: "exited with code 42") + if !strings.Contains(result.ForLLM, "42") { + t.Errorf("expected exit code 42 in error message, got: %s", result.ForLLM) + } + + // Verify the new detailed message format + if !strings.Contains(result.ForLLM, "exited with code") { + t.Errorf("expected 'exited with code' in message, got: %s", result.ForLLM) + } + + // Err field is set by the exec system (may or may not be set depending on implementation) + // The important thing is that IsError=true + t.Logf("Exit code result: %s", result.ForLLM) +} + +// TestShellTool_TimeoutWithPartialOutput verifies timeout includes partial output +func TestShellTool_TimeoutWithPartialOutput(t *testing.T) { + tool, err := NewExecTool("", false) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + tool.SetTimeout(1 * time.Second) // Give more time for echo to complete + + ctx := context.Background() + // Use a command that outputs immediately then sleeps + args := map[string]any{ + "command": "echo 'partial output before timeout' && sleep 30", + } + + result := tool.Execute(ctx, args) + + if !result.IsError { + t.Error("expected error for timeout") + } + + // Should mention timeout + if !strings.Contains(result.ForLLM, "timed out") { + t.Errorf("expected 'timed out' in message, got: %s", result.ForLLM) + } + + // Log the result for debugging (partial output depends on shell behavior) + t.Logf("Timeout result: %s", result.ForLLM) +} + // TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt // commands from deny pattern checks. func TestShellTool_CustomAllowPatterns(t *testing.T) { diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go new file mode 100644 index 000000000..416fd2226 --- /dev/null +++ b/pkg/tools/spawn_status.go @@ -0,0 +1,178 @@ +package tools + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// SpawnStatusTool reports the status of subagents that were spawned via the +// spawn tool. It can query a specific task by ID, or list every known task with +// a summary count broken-down by status. +type SpawnStatusTool struct { + manager *SubagentManager +} + +// NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager. +func NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool { + return &SpawnStatusTool{manager: manager} +} + +func (t *SpawnStatusTool) Name() string { + return "spawn_status" +} + +func (t *SpawnStatusTool) Description() string { + return "Get the status of spawned subagents. " + + "Returns a list of all subagents and their current state " + + "(running, completed, failed, or canceled), or retrieves details " + + "for a specific subagent task when task_id is provided. " + + "Results are scoped to the current conversation's channel and chat ID; " + + "all tasks are listed only when no channel/chat context is injected " + + "(e.g. direct programmatic calls via Execute)." +} + +func (t *SpawnStatusTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "task_id": map[string]any{ + "type": "string", + "description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " + + "subagent. When omitted, all visible subagents are listed.", + }, + }, + "required": []string{}, + } +} + +func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.manager == nil { + return ErrorResult("Subagent manager not configured") + } + + // Derive the calling conversation's identity so we can scope results to the + // current chat only — preventing cross-conversation task leakage in + // multi-user deployments. + callerChannel := ToolChannel(ctx) + callerChatID := ToolChatID(ctx) + + var taskID string + if rawTaskID, ok := args["task_id"]; ok && rawTaskID != nil { + taskIDStr, ok := rawTaskID.(string) + if !ok { + return ErrorResult("task_id must be a string") + } + taskID = strings.TrimSpace(taskIDStr) + } + + if taskID != "" { + // GetTaskCopy returns a consistent snapshot under the manager lock, + // eliminating any data race with the concurrent subagent goroutine. + taskCopy, ok := t.manager.GetTaskCopy(taskID) + if !ok { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + + // Restrict lookup to tasks that belong to this conversation. + if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + + return NewToolResult(spawnStatusFormatTask(&taskCopy)) + } + + // ListTaskCopies returns consistent snapshots under the manager lock. + origTasks := t.manager.ListTaskCopies() + if len(origTasks) == 0 { + return NewToolResult("No subagents have been spawned yet.") + } + + tasks := make([]*SubagentTask, 0, len(origTasks)) + for i := range origTasks { + cpy := &origTasks[i] + + // Filter to tasks that originate from the current conversation only. + if callerChannel != "" && cpy.OriginChannel != "" && cpy.OriginChannel != callerChannel { + continue + } + if callerChatID != "" && cpy.OriginChatID != "" && cpy.OriginChatID != callerChatID { + continue + } + + tasks = append(tasks, cpy) + } + + if len(tasks) == 0 { + return NewToolResult("No subagents found for this conversation.") + } + + // Order by creation time (ascending) so spawning order is preserved. + // Fall back to ID string for tasks created in the same millisecond. + sort.Slice(tasks, func(i, j int) bool { + if tasks[i].Created != tasks[j].Created { + return tasks[i].Created < tasks[j].Created + } + return tasks[i].ID < tasks[j].ID + }) + + counts := map[string]int{} + for _, task := range tasks { + counts[task.Status]++ + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Subagent status report (%d total):\n", len(tasks))) + for _, status := range []string{"running", "completed", "failed", "canceled"} { + if n := counts[status]; n > 0 { + label := strings.ToUpper(status[:1]) + status[1:] + ":" + sb.WriteString(fmt.Sprintf(" %-10s %d\n", label, n)) + } + } + sb.WriteString("\n") + + for _, task := range tasks { + sb.WriteString(spawnStatusFormatTask(task)) + sb.WriteString("\n\n") + } + + return NewToolResult(strings.TrimRight(sb.String(), "\n")) +} + +// spawnStatusFormatTask renders a single SubagentTask as a human-readable block. +func spawnStatusFormatTask(task *SubagentTask) string { + var sb strings.Builder + + header := fmt.Sprintf("[%s] status=%s", task.ID, task.Status) + if task.Label != "" { + header += fmt.Sprintf(" label=%q", task.Label) + } + if task.AgentID != "" { + header += fmt.Sprintf(" agent=%s", task.AgentID) + } + if task.Created > 0 { + created := time.UnixMilli(task.Created).UTC().Format("2006-01-02 15:04:05 UTC") + header += fmt.Sprintf(" created=%s", created) + } + sb.WriteString(header) + + if task.Task != "" { + sb.WriteString(fmt.Sprintf("\n task: %s", task.Task)) + } + if task.Result != "" { + result := task.Result + const maxResultLen = 300 + runes := []rune(result) + if len(runes) > maxResultLen { + result = string(runes[:maxResultLen]) + "…" + } + sb.WriteString(fmt.Sprintf("\n result: %s", result)) + } + + return sb.String() +} diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go new file mode 100644 index 000000000..c934f56b2 --- /dev/null +++ b/pkg/tools/spawn_status_test.go @@ -0,0 +1,408 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/orch" +) + +func TestSpawnStatusTool_Name(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace, nil, orch.Noop, WebSearchToolOptions{}) + tool := NewSpawnStatusTool(manager) + + if tool.Name() != "spawn_status" { + t.Errorf("Expected name 'spawn_status', got '%s'", tool.Name()) + } +} + +func TestSpawnStatusTool_Description(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace, nil, orch.Noop, WebSearchToolOptions{}) + tool := NewSpawnStatusTool(manager) + + desc := tool.Description() + if desc == "" { + t.Error("Description should not be empty") + } + if !strings.Contains(strings.ToLower(desc), "subagent") { + t.Errorf("Description should mention 'subagent', got: %s", desc) + } +} + +func TestSpawnStatusTool_Parameters(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace, nil, orch.Noop, WebSearchToolOptions{}) + tool := NewSpawnStatusTool(manager) + + params := tool.Parameters() + if params["type"] != "object" { + t.Errorf("Expected type 'object', got: %v", params["type"]) + } + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("Expected 'properties' to be a map") + } + if _, hasTaskID := props["task_id"]; !hasTaskID { + t.Error("Expected 'task_id' parameter in properties") + } +} + +func TestSpawnStatusTool_NilManager(t *testing.T) { + tool := &SpawnStatusTool{manager: nil} + result := tool.Execute(context.Background(), map[string]any{}) + if !result.IsError { + t.Error("Expected error result when manager is nil") + } +} + +func TestSpawnStatusTool_Empty(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace, nil, orch.Noop, WebSearchToolOptions{}) + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{}) + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No subagents") { + t.Errorf("Expected 'No subagents' message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ListAll(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace, nil, orch.Noop, WebSearchToolOptions{}) + + now := time.Now().UnixMilli() + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Do task A", + Label: "task-a", + Status: "running", + Created: now, + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", + Task: "Do task B", + Label: "task-b", + Status: "completed", + Result: "Done successfully", + Created: now, + } + manager.tasks["subagent-3"] = &SubagentTask{ + ID: "subagent-3", + Task: "Do task C", + Status: "failed", + Result: "Error: something went wrong", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + + // Summary header + if !strings.Contains(result.ForLLM, "3 total") { + t.Errorf("Expected total count in header, got: %s", result.ForLLM) + } + + // Individual task IDs + for _, id := range []string{"subagent-1", "subagent-2", "subagent-3"} { + if !strings.Contains(result.ForLLM, id) { + t.Errorf("Expected task %s in output, got:\n%s", id, result.ForLLM) + } + } + + // Status values + for _, status := range []string{"running", "completed", "failed"} { + if !strings.Contains(result.ForLLM, status) { + t.Errorf("Expected status '%s' in output, got:\n%s", status, result.ForLLM) + } + } + + // Result content + if !strings.Contains(result.ForLLM, "Done successfully") { + t.Errorf("Expected result text in output, got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + manager.mu.Lock() + manager.tasks["subagent-42"] = &SubagentTask{ + ID: "subagent-42", + Task: "Specific task", + Label: "my-task", + Status: "failed", + Result: "Something went wrong", + Created: time.Now().UnixMilli(), + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-42"}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-42") { + t.Errorf("Expected task ID in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "failed") { + t.Errorf("Expected status 'failed' in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Something went wrong") { + t.Errorf("Expected result text in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "my-task") { + t.Errorf("Expected label in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"}) + if !result.IsError { + t.Errorf("Expected error for nonexistent task, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "nonexistent-999") { + t.Errorf("Expected task ID in error message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_TaskID_NonString(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + tool := NewSpawnStatusTool(manager) + + for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} { + result := tool.Execute(context.Background(), map[string]any{"task_id": badVal}) + if !result.IsError { + t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM) + } + if !strings.Contains(result.ForLLM, "task_id must be a string") { + t.Errorf("Expected type-error message, got: %s", result.ForLLM) + } + } +} + +func TestSpawnStatusTool_ResultTruncation(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + longResult := strings.Repeat("X", 500) + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Long task", + Status: "completed", + Result: longResult, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // Output should be shorter than the raw result due to truncation + if len(result.ForLLM) >= len(longResult) { + t.Errorf("Expected result to be truncated, but ForLLM is %d chars", len(result.ForLLM)) + } + if !strings.Contains(result.ForLLM, "…") { + t.Errorf("Expected truncation indicator '…' in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + // Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit. + cjkChar := string(rune(0x5b57)) + longResult := strings.Repeat(cjkChar, 400) + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Unicode task", + Status: "completed", + Result: longResult, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "…") { + t.Errorf("Expected truncation indicator in output") + } + // The truncated result must be valid UTF-8 (no split rune boundaries). + if !strings.Contains(result.ForLLM, cjkChar) { + t.Errorf("Expected CJK runes to appear intact in output") + } +} + +func TestSpawnStatusTool_StatusCounts(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + manager.mu.Lock() + for i, status := range []string{"running", "running", "completed", "failed", "canceled"} { + id := fmt.Sprintf("subagent-%d", i+1) + manager.tasks[id] = &SubagentTask{ID: id, Task: "t", Status: status} + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // The summary line should mention all statuses that have counts + for _, want := range []string{"Running:", "Completed:", "Failed:", "Canceled:"} { + if !strings.Contains(result.ForLLM, want) { + t.Errorf("Expected %q in summary, got:\n%s", want, result.ForLLM) + } + } +} + +func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + now := time.Now().UnixMilli() + manager.mu.Lock() + // Intentionally insert with out-of-order IDs and timestamps that reflect + // true spawn order: subagent-2 was spawned first, subagent-10 second. + manager.tasks["subagent-10"] = &SubagentTask{ + ID: "subagent-10", Task: "second", Status: "running", + Created: now + 1, + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", Task: "first", Status: "running", + Created: now, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + + pos2 := strings.Index(result.ForLLM, "subagent-2") + pos10 := strings.Index(result.ForLLM, "subagent-10") + if pos2 < 0 || pos10 < 0 { + t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM) + } + if pos2 > pos10 { + t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", Task: "mine", Status: "running", + OriginChannel: "telegram", OriginChatID: "chat-A", + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", Task: "other user", Status: "running", + OriginChannel: "telegram", OriginChatID: "chat-B", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // Caller is chat-A — should only see subagent-1. + ctx := WithToolContext(context.Background(), "telegram", "chat-A") + result := tool.Execute(ctx, map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-1") { + t.Errorf("Expected own task in output, got:\n%s", result.ForLLM) + } + if strings.Contains(result.ForLLM, "subagent-2") { + t.Errorf("Should NOT see other chat's task, got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + manager.mu.Lock() + manager.tasks["subagent-99"] = &SubagentTask{ + ID: "subagent-99", Task: "secret", Status: "completed", Result: "private data", + OriginChannel: "slack", OriginChatID: "room-Z", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // Different chat trying to look up subagent-99 by ID. + ctx := WithToolContext(context.Background(), "slack", "room-OTHER") + result := tool.Execute(ctx, map[string]any{"task_id": "subagent-99"}) + + if !result.IsError { + t.Errorf("Expected error (cross-chat lookup blocked), got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", Task: "t", Status: "completed", + OriginChannel: "telegram", OriginChatID: "chat-A", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // No ToolContext injected (e.g. a direct programmatic call that bypasses + // WithToolContext entirely) — callerChannel and callerChatID are both "". + // Note: the normal CLI path uses ProcessDirectWithChannel("cli", "direct"), + // which *does* inject a non-empty context; this test covers the case where + // no context injection happens at all. + // The filter conditions require a non-empty caller value, so all tasks pass through. + result := tool.Execute(context.Background(), map[string]any{}) + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-1") { + t.Errorf("Expected task visible from no-context caller, got:\n%s", result.ForLLM) + } +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 8ce9d961a..04bccbd7a 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -466,6 +466,18 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { return task, ok } +// GetTaskCopy returns a copy of the task with the given ID, taken under the +// read lock, so the caller receives a consistent snapshot with no data race. +func (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + task, ok := sm.tasks[taskID] + if !ok { + return SubagentTask{}, false + } + return *task, true +} + func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.mu.RLock() defer sm.mu.RUnlock() @@ -477,6 +489,19 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask { return tasks } +// ListTaskCopies returns value copies of all tasks, taken under the read lock, +// so callers receive consistent snapshots with no data race. +func (sm *SubagentManager) ListTaskCopies() []SubagentTask { + sm.mu.RLock() + defer sm.mu.RUnlock() + + copies := make([]SubagentTask, 0, len(sm.tasks)) + for _, task := range sm.tasks { + copies = append(copies, *task) + } + return copies +} + // SubagentTool executes a subagent task synchronously and returns the result. // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion // and returns the result directly in the ToolResult. diff --git a/pkg/tools/subagent_preset.go b/pkg/tools/subagent_preset.go index e9354865a..c20685cd1 100644 --- a/pkg/tools/subagent_preset.go +++ b/pkg/tools/subagent_preset.go @@ -221,7 +221,7 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string, } if config.AllowedTools["web_fetch"] { - if fetchTool, err := NewWebFetchTool(50000); err == nil { + if fetchTool, err := NewWebFetchTool(50000, "", 0); err == nil { registry.Register(fetchTool) } } diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 25ec6050f..d2bbc8042 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "mime" "net" "net/http" "net/url" @@ -15,6 +16,7 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -868,33 +870,52 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR } type WebFetchTool struct { - maxChars int - - proxy string - - client *http.Client - + maxChars int + proxy string + client *http.Client + format string fetchLimitBytes int64 + whitelist *privateHostWhitelist } -// NewWebFetchTool creates a WebFetchTool. The optional fetchLimitBytes parameter -// sets the maximum response body size (defaults to 10MB if not provided or <= 0). -func NewWebFetchTool(maxChars int, fetchLimitBytes ...int64) (*WebFetchTool, error) { - var limit int64 - if len(fetchLimitBytes) > 0 { - limit = fetchLimitBytes[0] - } - return NewWebFetchToolWithProxy(maxChars, "", limit) +type privateHostWhitelist struct { + exact map[string]struct{} + cidrs []*net.IPNet +} + +func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { + // createHTTPClient cannot fail with an empty proxy string. + return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil) } // allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed. // This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily. var allowPrivateWebFetchHosts atomic.Bool -func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) { +func NewWebFetchToolWithProxy( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} + +func NewWebFetchToolWithConfig( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { if maxChars <= 0 { maxChars = defaultMaxChars } + whitelist, err := newPrivateHostWhitelist(privateHostWhitelist) + if err != nil { + return nil, fmt.Errorf("failed to parse web fetch private host whitelist: %w", err) + } client, err := utils.CreateHTTPClient(proxy, fetchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) @@ -904,13 +925,13 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) Timeout: 15 * time.Second, KeepAlive: 30 * time.Second, } - transport.DialContext = newSafeDialContext(dialer) + transport.DialContext = newSafeDialContext(dialer, whitelist) } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } - if isObviousPrivateHost(req.URL.Hostname()) { + if isObviousPrivateHost(req.URL.Hostname(), whitelist) { return fmt.Errorf("redirect target is private or local network host") } return nil @@ -920,13 +941,12 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) } return &WebFetchTool{ - maxChars: maxChars, - - proxy: proxy, - - client: client, - + maxChars: maxChars, + proxy: proxy, + client: client, + format: format, fetchLimitBytes: fetchLimitBytes, + whitelist: whitelist, }, nil } @@ -981,7 +1001,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe // Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution. // The real SSRF guard is newSafeDialContext at connect time. hostname := parsedURL.Hostname() - if isObviousPrivateHost(hostname) { + if isObviousPrivateHost(hostname, t.whitelist) { return ErrorResult("fetching private or local network hosts is not allowed") } @@ -1016,32 +1036,68 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult(fmt.Sprintf("failed to read response: %v", err)) } + bodyStr := string(body) contentType := resp.Header.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + // The most common error here is "mime: no media type" if the header is empty. + logger.WarnCF("tool", "Failed to parse Content-Type", map[string]any{ + "raw_header": contentType, + "error": err.Error(), + }) + + // security fallback + mediaType = "application/octet-stream" + } + + charset, hasCharset := params["charset"] + if hasCharset { + // If the charset is not utf-8, we might have to convert the bodyStr + // before passing it to the HTML/Markdown parser + if strings.ToLower(charset) != "utf-8" { + logger.WarnCF("tool", "Note: the content is not in UTF-8", map[string]any{"charset": charset}) + } + } + var text, extractor string - bodyStr := string(body) - - if strings.Contains(contentType, "application/json") { + switch { + case mediaType == "application/json": var jsonData any - if err := json.Unmarshal(body, &jsonData); err == nil { - formatted, _ := json.MarshalIndent(jsonData, "", " ") - text = string(formatted) - extractor = "json" - } else { + if err := json.Unmarshal(body, &jsonData); err != nil { text = bodyStr - extractor = "raw" + break } - } else if strings.Contains(contentType, "text/html") || len(body) > 0 && - (strings.HasPrefix(bodyStr, "]*>\)\]\(<[^>]*>\)`) + reEmptyHeader = regexp.MustCompile(`(?m)^#{1,6}\s*$`) + reLeadingLineSpace = regexp.MustCompile(`(?m)^([ \t])([^ \t\n])`) +) + +var skipTags = map[string]bool{ + "script": true, "style": true, "head": true, + "noscript": true, "template": true, + "nav": true, "footer": true, "aside": true, "header": true, "form": true, "dialog": true, +} + +func isSafeHref(href string) bool { + lower := strings.ToLower(strings.TrimSpace(href)) + if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "vbscript:") || + strings.HasPrefix(lower, "data:") { + return false + } + u, err := url.Parse(strings.TrimSpace(href)) + if err != nil { + return false + } + scheme := strings.ToLower(u.Scheme) + return scheme == "" || scheme == "http" || scheme == "https" || scheme == "mailto" +} + +func isSafeImageSrc(src string) bool { + lower := strings.ToLower(strings.TrimSpace(src)) + if strings.HasPrefix(lower, "data:image/") { + return true + } + return isSafeHref(src) +} + +func escapeMdAlt(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `[`, `\[`) + s = strings.ReplaceAll(s, `]`, `\]`) + return s +} + +func getAttr(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +func normalizeAttr(val string) string { + val = strings.ReplaceAll(val, "\n", "") + val = strings.ReplaceAll(val, "\r", "") + val = strings.ReplaceAll(val, "\t", "") + return strings.TrimSpace(val) +} + +func isUnlikelyNode(n *html.Node) bool { + if n.Type != html.ElementNode { + return false + } + classId := strings.ToLower(getAttr(n, "class") + " " + getAttr(n, "id")) + if classId == " " { + return false + } + if strings.Contains(classId, "article") || strings.Contains(classId, "main") || + strings.Contains(classId, "content") { + return false + } + unlikelyKeywords := []string{ + "menu", + "nav", + "footer", + "sidebar", + "cookie", + "banner", + "sponsor", + "advert", + "popup", + "modal", + "newsletter", + "share", + "social", + } + for _, keyword := range unlikelyKeywords { + if strings.Contains(classId, keyword) { + return true + } + } + return false +} + +type converter struct { + stack []*bytes.Buffer + linkHrefs []string + linkStates []bool + emphStack []string // Tracks "**", "*", "~~" for buffered emphasis + olCounters []int + inPre bool + listDepth int +} + +func newConverter() *converter { + return &converter{ + stack: []*bytes.Buffer{{}}, + } +} + +func (c *converter) write(s string) { + c.stack[len(c.stack)-1].WriteString(s) +} + +func (c *converter) pushBuf() { + c.stack = append(c.stack, &bytes.Buffer{}) +} + +func (c *converter) popBuf() string { + top := c.stack[len(c.stack)-1] + c.stack = c.stack[:len(c.stack)-1] + return top.String() +} + +func (c *converter) walk(n *html.Node) { + if n.Type == html.ElementNode { + if skipTags[n.Data] { + return + } + if isUnlikelyNode(n) { + return + } + } + + if n.Type == html.TextNode { + text := n.Data + if !c.inPre { + text = strings.ReplaceAll(text, "\n", " ") + text = reSpaces.ReplaceAllString(text, " ") + } + if text != "" { + c.write(text) + } + return + } + + if n.Type != html.ElementNode { + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + c.walk(ch) + } + return + } + + // Opening Tags + switch n.Data { + // Buffer emphasis content so we can TrimSpace the inner text, + // avoiding the regex-across-boundaries bug. + case "b", "strong": + c.emphStack = append(c.emphStack, "**") + c.pushBuf() + case "i", "em": + c.emphStack = append(c.emphStack, "*") + c.pushBuf() + case "del", "s": + c.emphStack = append(c.emphStack, "~~") + c.pushBuf() + + case "a": + href := normalizeAttr(getAttr(n, "href")) + if href != "" && !isSafeHref(href) { + href = "#" + } + hasHref := href != "" + c.linkStates = append(c.linkStates, hasHref) + if hasHref { + c.linkHrefs = append(c.linkHrefs, href) + c.pushBuf() + } + + case "h1": + c.write("\n\n# ") + case "h2": + c.write("\n\n## ") + case "h3": + c.write("\n\n### ") + case "h4": + c.write("\n\n#### ") + case "h5": + c.write("\n\n##### ") + case "h6": + c.write("\n\n###### ") + + case "p": + c.write("\n\n") + case "br": + c.write("\n") + case "hr": + c.write("\n\n---\n\n") + + case "ol": + c.olCounters = append(c.olCounters, 1) + // Only write leading newline for top-level list. + if c.listDepth == 0 { + c.write("\n") + } + c.listDepth++ + case "ul": + if c.listDepth == 0 { + c.write("\n") + } + c.listDepth++ + case "li": + c.write("\n") + if c.listDepth > 1 { + c.write(strings.Repeat(" ", c.listDepth-1)) + } + if n.Parent != nil && n.Parent.Data == "ol" && len(c.olCounters) > 0 { + idx := c.olCounters[len(c.olCounters)-1] + c.write(strconv.Itoa(idx) + ". ") + c.olCounters[len(c.olCounters)-1]++ + } else { + c.write("- ") + } + + case "pre": + c.inPre = true + c.write("\n\n```\n") + case "code": + if !c.inPre { + c.write("`") + } + + case "blockquote": + c.pushBuf() + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + c.walk(ch) + } + inner := strings.TrimSpace(c.popBuf()) + lines := strings.Split(inner, "\n") + var quoted []string + for _, l := range lines { + if strings.TrimSpace(l) == "" { + quoted = append(quoted, ">") + } else { + quoted = append(quoted, "> "+l) + } + } + var deduped []string + for i, line := range quoted { + if line == ">" && i > 0 && deduped[len(deduped)-1] == ">" { + continue + } + deduped = append(deduped, line) + } + c.write("\n\n" + strings.Join(deduped, "\n") + "\n\n") + return + + case "img": + src := normalizeAttr(getAttr(n, "src")) + if src == "" { + src = normalizeAttr(getAttr(n, "data-src")) + } + if src == "" { + return + } + alt := escapeMdAlt(normalizeAttr(getAttr(n, "alt"))) + if isSafeImageSrc(src) { + c.write("![" + alt + "](" + src + ")") + } + return + } + + // Traverse Children + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + c.walk(ch) + } + + // Closing Tags + switch n.Data { + // Pop buffer, trim, wrap with the correct marker. + case "b", "strong", "i", "em", "del", "s": + if len(c.emphStack) == 0 { + break + } + marker := c.emphStack[len(c.emphStack)-1] + c.emphStack = c.emphStack[:len(c.emphStack)-1] + inner := strings.TrimSpace(c.popBuf()) + if inner != "" { + c.write(marker + inner + marker) + } + + case "a": + if len(c.linkStates) == 0 { + break + } + hasHref := c.linkStates[len(c.linkStates)-1] + c.linkStates = c.linkStates[:len(c.linkStates)-1] + if !hasHref { + break + } + href := c.linkHrefs[len(c.linkHrefs)-1] + c.linkHrefs = c.linkHrefs[:len(c.linkHrefs)-1] + inner := strings.TrimSpace(c.popBuf()) + if strings.Contains(inner, "\n") { + lines := strings.Split(inner, "\n") + linked := false + for i, l := range lines { + cleanLine := strings.TrimSpace(l) + if cleanLine != "" && !strings.HasPrefix(cleanLine, "![") && !linked { + lines[i] = "[" + cleanLine + "](" + href + ")" + linked = true + } + } + c.write(strings.Join(lines, "\n")) + } else { + c.write("[" + inner + "](" + href + ")") + } + + case "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "div", + "section", + "article", + "header", + "footer", + "aside", + "nav", + "figure": + c.write("\n") + + case "ol": + c.listDepth-- + if len(c.olCounters) > 0 { + c.olCounters = c.olCounters[:len(c.olCounters)-1] + } + if c.listDepth == 0 { + c.write("\n") + } + case "ul": + c.listDepth-- + if c.listDepth == 0 { + c.write("\n") + } + + case "pre": + c.inPre = false + c.write("\n```\n\n") + case "code": + if !c.inPre { + c.write("`") + } + } +} + +func HtmlToMarkdown(htmlStr string) (string, error) { + doc, err := html.Parse(strings.NewReader(htmlStr)) + if err != nil { + return "", err + } + + c := newConverter() + c.walk(doc) + + res := c.stack[0].String() + + // Post-processing + res = reImageOnlyLink.ReplaceAllString(res, "") + res = reEmptyListItem.ReplaceAllString(res, "") + res = reEmptyHeader.ReplaceAllString(res, "") + + lines := strings.Split(res, "\n") + var cleanLines []string + for _, line := range lines { + line = strings.TrimRight(line, " \t") + cleanTest := strings.TrimSpace(line) + if cleanTest == "[]()" || cleanTest == "[](#)" || cleanTest == "-" { + cleanLines = append(cleanLines, "") + continue + } + cleanLines = append(cleanLines, line) + } + res = strings.Join(cleanLines, "\n") + + res = strings.TrimSpace(res) + res = reNewlines.ReplaceAllString(res, "\n\n") + + // Strip a single leading space from lines that are NOT list indentation. + // "(?m)^([ \t])([^ \t\n])" matches exactly one space/tab at line start followed + // by a non-whitespace char, so " - nested" (4 spaces) is left untouched. + res = reLeadingLineSpace.ReplaceAllString(res, "$2") + + return res, nil +} diff --git a/pkg/utils/markdown_test.go b/pkg/utils/markdown_test.go new file mode 100644 index 000000000..72277fb91 --- /dev/null +++ b/pkg/utils/markdown_test.go @@ -0,0 +1,245 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +func TestHtmlToMarkdown(t *testing.T) { + // Define our test cases + tests := []struct { + name string + input string + expected string + }{ + { + name: "Removes scripts and styles", + input: `

Clean text

`, + expected: "Clean text", + }, + { + name: "Extracts links correctly", + input: `Visit my website for info.`, + expected: "Visit my [website](https://example.com) for info.", + }, + { + name: "Converts headers (H1, H2, H3)", + input: `

Main Title

Subtitle

Section

`, + expected: "# Main Title\n\n## Subtitle\n\n### Section", + }, + { + name: "Handles bold and italics", + input: `Text bold and strong, then italic and em.`, + expected: "Text **bold** and **strong**, then *italic* and *em*.", + }, + { + name: "Converts lists", + input: `
  • First element
  • Second element
`, + expected: "- First element\n- Second element", + }, + { + name: "Handles paragraphs and line breaks (
)", + input: `

First paragraph

Second paragraph with
a line break.

`, + expected: "First paragraph\n\nSecond paragraph with\na line break.", + }, + { + name: "Decodes HTML entities", + input: `Math: 5 > 3 & 2 < 4. A "quote".`, + expected: "Math: 5 > 3 & 2 < 4. A \"quote\".", + }, + { + name: "Cleans up residual HTML tags", + input: `
Text inside div and span
`, + expected: "Text inside div and span", + }, + { + name: "Removes multiple spaces and excessive empty lines", + input: `This text has too many spaces.



And too many newlines.`, + expected: "This text has too many spaces.\n\nAnd too many newlines.", + }, + { + name: "Nested lists with indentation", + input: "
  • One
    • Two
", + // Expect the sub-element to have 4 spaces of indentation + expected: "- One\n - Two", + }, + { + name: "Image support", + input: `alternative text`, + // Correct Markdown syntax for images + expected: "![alternative text](image.jpg)", + }, + { + name: "Image support without alt-text", + input: ``, + // If alt is missing, square brackets remain empty + expected: "![](image.jpg)", + }, + { + name: "XSS Bypass on Links (Obfuscated HTML entities)", + // The Go HTML parser resolves entities, so this becomes "javascript:alert(1)" + input: `Click here`, + // Our isSafeHref (if updated with net/url) should neutralize it to "#" + expected: "[Click here](#)", + }, + { + name: "Empty link or used as anchor", + input: ``, + // With no text or href, it shouldn't print anything (not even empty brackets) + expected: "", + }, + { + name: "Link without href but with text (Textual anchor)", + input: `Back to top`, + // Should extract only plain text, without generating a broken Markdown link like [Back to top](#) or [Back to top]() + expected: "Back to top", + }, + { + name: "Badly spaced bold and italics (Edge Case)", + input: ` Text `, + // In Markdown `** Text **` is often not formatted correctly. The ideal is `**Text**` + expected: "**Text**", + }, + { + name: "Complex Test - Real Article", + input: ` +

Article Title

+

This is an introductory text with a link.

+

Subtitle

+
    +
  • Point one
  • +
  • Point two
  • +
+ + `, + // Note: The indentation of the real HTML test will generate spaces that + // regex will clean up. + expected: "# Article Title\n\nThis is an **introductory text** with a [link](http://link.com).\n\n## Subtitle\n\n- Point one\n- Point two", + }, + { + name: "Ordered list (OL)", + input: `
  1. First
  2. Second
  3. Third
`, + expected: "1. First\n2. Second\n3. Third", + }, + { + name: "Ordered list nested in unordered list", + input: `
  • Fruits
    1. Apples
    2. Pears
  • Vegetables
`, + expected: "- Fruits\n 1. Apples\n 2. Pears\n- Vegetables", + }, + { + name: "Code block (pre/code)", + input: "
func main() {\n    fmt.Println(\"hello\")\n}
", + expected: "```\nfunc main() {\n fmt.Println(\"hello\")\n}\n```", + }, + { + name: "Inline code", + input: `

Use the command go test ./... to run the tests.

`, + expected: "Use the command `go test ./...` to run the tests.", + }, + { + name: "Simple blockquote", + input: `

An important quote.

`, + expected: "> An important quote.", + }, + { + name: "Multiline blockquote", + input: `

First line of the quote.

Second line of the quote.

`, + expected: "> First line of the quote.\n>\n> Second line of the quote.", + }, + { + name: "Strikethrough text (del/s)", + input: `This text is deleted and this is crossed out.`, + expected: "This text is ~~deleted~~ and this is ~~crossed out~~.", + }, + { + name: "Horizontal separator (HR)", + input: `

Above the line


Below the line

`, + expected: "Above the line\n\n---\n\nBelow the line", + }, + { + name: "Bold nested in link", + input: `Linked bold text`, + expected: "[**Linked bold text**](https://example.com)", + }, + { + name: "data-src Image (lazy loading)", + input: `Lazy image`, + expected: "![Lazy image](lazy.jpg)", + }, + { + name: "Image with javascript: src blocked", + input: `XSS`, + // src is not safe, so the image is not emitted + expected: "", + }, + { + name: "Link with data: href blocked", + input: `Click`, + expected: "[Click](#)", + }, + { + name: "Deeply nested divs", + input: `

Deeply nested text

`, + expected: "Deeply nested text", + }, + { + name: "Non-consecutive headers (H1, H3, H5)", + input: `

Title

Subsection

Sub-subsection
`, + expected: "# Title\n\n### Subsection\n\n##### Sub-subsection", + }, + { + name: "Paragraph with mixed multiple emphasis", + input: `

Important: read the critical instructions carefully.

`, + expected: "**Important:** read the ***critical instructions*** *carefully*.", + }, + { + name: "Article with nav and aside sections (noise to filter)", + input: ` + +
+

Article title

+

This is the body of the article.

+
+ + `, + expected: "## Article title\n\nThis is the body of the article.", + }, + { + name: "Text with mixed special HTML entities", + input: `Copyright © 2024 — All rights reserved ®`, + expected: "Copyright © 2024 — All rights reserved ®", + }, + { + name: "Mailto link", + input: `Write to us at info@example.com`, + expected: "Write to us at [info@example.com](mailto:info@example.com)", + }, + { + name: "Image inside a link (clickable figure)", + input: `Photo`, + // The image-link without text must not generate broken markup + expected: "[![Photo](photo.jpg)](https://example.com)", + }, + { + name: "Empty content or only whitespace", + input: `

`, + expected: "", + }, + } + + // Iterate over all test cases + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := HtmlToMarkdown(tt.input) + if err != nil { + logger.ErrorCF("tool", "Failed to parse html to markdown: %s", map[string]any{"error": err.Error()}) + } + + if got != tt.expected { + t.Errorf("\nTest case failed: %s\nInput: %q\nGot: %q\nExpected: %q", + tt.name, tt.input, got, tt.expected) + } + }) + } +} diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 3e1c5d88e..82e9f5f45 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" ) // IsAudioFile checks if a file is an audio file based on its filename extension and content type. @@ -67,7 +68,7 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string { opts.LoggerPrefix = "utils" } - mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{ "error": err.Error(), diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 000000000..76cc72938 --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Build macOS .app bundle for PicoClaw Launcher + +set -e + +EXECUTABLE=$1 + +if [ -z "$EXECUTABLE" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "executable: $EXECUTABLE" + +APP_NAME="PicoClaw Launcher" +APP_PATH="./build/${APP_NAME}.app" +APP_CONTENTS="${APP_PATH}/Contents" +APP_MACOS="${APP_CONTENTS}/MacOS" +APP_RESOURCES="${APP_CONTENTS}/Resources" +APP_EXECUTABLE="picoclaw-launcher" +ICON_SOURCE="./scripts/icon.icns" + +# Clean up existing .app +if [ -d "$APP_PATH" ]; then + echo "Removing existing ${APP_PATH}" + rm -rf "$APP_PATH" +fi + +# Create directory structure +echo "Creating .app bundle structure..." +mkdir -p "$APP_MACOS" +mkdir -p "$APP_RESOURCES" + +# Copy executable +echo "Copying executable..." +if [ -f "./web/build/${APP_EXECUTABLE}" ]; then + cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/" +else + echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first." + echo "Run: make build in web dir" + exit 1 +fi +if [ -f "./build/picoclaw" ]; then + cp "./build/picoclaw" "${APP_MACOS}/" +else + echo "Error: ./build/picoclaw not found. Please build the main file first." + echo "Run: make build" + exit 1 +fi +chmod +x "${APP_MACOS}/"* + +# Create Info.plist +echo "Creating Info.plist..." +cat > "${APP_CONTENTS}/Info.plist" << 'EOF' + + + + + CFBundleExecutable + picoclaw-launcher + CFBundleIdentifier + com.picoclaw.launcher + CFBundleName + PicoClaw Launcher + CFBundleDisplayName + PicoClaw Launcher + CFBundleIconFile + icon.icns + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + LSRequiresCarbon + + LSUIElement + 1 + + +EOF + +#sips -z 128 128 "$ICON_SOURCE" --out "${ICONSET_PATH}/icon_128x128.png" > /dev/null 2>&1 +# +## Create icns file +#iconutil -c icns "$ICONSET_PATH" -o "$ICON_OUTPUT" 2>/dev/null || { +# echo "Warning: iconutil failed" +#} + +cp $ICON_SOURCE "${APP_RESOURCES}/icon.icns" + +echo "" +echo "==========================================" +echo "Successfully created: ${APP_PATH}" +echo "==========================================" +echo "" +echo "To launch PicoClaw:" +echo " 1. Double-click ${APP_NAME}.app in Finder" +echo " 2. Or use: open ${APP_PATH}" +echo "" +echo "Note: The app will run in the menu bar (systray) without a terminal window." +echo "" diff --git a/scripts/icon.icns b/scripts/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..bcf9adcd737e21ddd54b4b9c1a23aaaa95afb26f GIT binary patch literal 16192 zcmbumWmH^E&@MUygWKQ)cXtxp-QC>@?rwu?&;<7&!6hNMySuw5xZ5B%@BPj?_x?Vo z*Q$Q1yKC3l)jxVw?_H0rg_An~AR=mO!OjB!2tCKBD$AfD6Cwiu05mySN%emS`A-nx z|Mh*sW@G;V=&mj!4yc(V{`+qrX{9S?t)v8C{HG%VfKj#p(0?ZXgz%pL0I&r>0PH^m z{x??u^S@Ui1+f30{-2?20?7N{8yLz-ifQ@)&x{ZPv=(09H?B6vHq8=6MZsSg{G*Gs zMW?%BwUhL8Dm|y{?)WOI8yoE$PXOO`dX+YqPAQ^c!PFsjSi_`j&Kq~{Z^jG!eAB zQT84bP04qjJe)<}Vy?+4rdPmeCscGER6(EJ>}aF`hxSs zr>DKAD3oELe>j#>?nL_`qoFu2#X$*xtEgsJeW?mug?Hz9AUf$#o4?PEO?kl(P;=25625&U=8{rw(Uub%ZPt$CG%J<+oLkarkN#Zu%(QD_jjzpYcnvWS}60^|B zm})Y(svs93WcXXbTRV^qi|39dah4Z2(06@57v=LY4CeUx(uY%lpQu7!jm7kiA@f_O zx*l6x$;T@Xeu~~(^a8|**{z)hY2VaDSeuWc=i@h~l1UG!951^xyP|5(WL^I7QfK;+e*j1i^-c?hb3Ixv6qN@`lKoCuQ7K>hW=zI;$07Q zu0t@^HmWybk?6|3c*QxeQ39mU&ss`s#0$#dkv}!oRCpS zIp_Ll^5HoLX<=UIMbAF>y$T6n-riLXCp%lRfCgk%XodCiLsS7y;AdeUU6hFXAgxWW->H0Bu^#IK8)=T)J45_J6hWz?G}XmhLVJ2 zL0yfL!IL0Bd$LRlZYJ?p9Hj0xd?CZnS);Jl)3qa5Xk_@2mNjf`?Fu{GZxB{#8*ldK zwLne#zf$B08^!b+nsYzOgzZkclU0o+yZCDmlGco(5MuT(l#D7xp~A!&4N!+_i;Xyg z6F54LF04RUvK+^VmMIx^EFd1c)+j`h+{3L6lE8yY&7E%ij(2ch!* zK_dj$cKpxHD#;`ljwhtd7aen%6+}HuJEzrxUW%Q& zLrkjGE4^Qn9kv1OdwGgk&Uul4(%_(HB4!|7TMB({N#bezEge^LL~_`MhYUzbQ>x?cJ!agO|*GgasXc4i8)X#PQ$ESo3w9jIUMX=vdaU|AkD8@ z-aw}-ld(8zTz4ySw(kdDva0yd;VD&6s+}4JT>~!Nn}7)@^j4{Ym+%=dSP3P4L0oUd z7?k|@EeM3WKoe306Bb7z8c3RWMW&z2n*$SR-r7t$VWo9^@ejJbz2&5@(?Mf15A)Y) zYA~PqsdLg5K*wzMMzu|ST9s|=&`ke2*qY3o-N z&UO@OEJZ`42pFdg{5_Pc~E9g^rSxVx8Nja%r@g zz+j+xqVq>T8K#YBEKvf}ROuJS35uDHQm4s9MW)O>59I6n(9#iXVJ_lWKj+myEjKLr z&!SI*HcTg3S=y9SZ=$&KPiKyiMl;$P6PcwPHbur39<6p0CX3rs*DSLa4W8mn60W%) z_E3+jw3ptL)6m^mE@rp!5dw}Q9vXXN-9e0ovJHQD=J&WHkvfxui-!l`5ti@zr_{ofVwQ1GooVmk}_wDPi zsHO3>4Y%D90|r6kjl`2|yL%Q~PR*wdSzeo;kC(Yg`pu>{d)568!nAJZnevl|j@Z(u zPjY@b=RCfaKKy3YA?Bd^0(r15&Jfz%9;o(Pp+6|s+VTqZ%1c>z2pCJbxR|HSG=7`& z{J6dEz^`g3kFseXeG$-^*C1e}?q*KxX}c9Ejs8cBjCD(L!PsQ0*df?>tq}hr4d)e^ z9^%0AYmh#}e1G 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⁢khI2_ z8@WN`$2xvd)8UgDAVj9PP0`tYh_Rs2$O19A0-LMZ6tGEcZs7Jnz=lrlBsO}nt?UeM z(*RI_?AwnDtUz`@MKB_E2!N{w4#+;u;UCqHp7KNt>PBKE)LrxFt_)FvY_W*O!z4>B zqs3YV4eVWIf5cJ~mJ#hn35SKTEq}z{!96b)Gm{H-zF4RLf$3VLB{1^)bIxnK74^v3 z8*fU2ADQ-#xGo3S;B-X~0eAtPhcD8@M^OOExM0ZVrjl$G^G=tY5S}15-WI>f@bRoS z4QT-DceQG=`1r}s$*78LFrU?SqV#2DZ)M#}iI-LIqD9|mWuGJa3vAq_}#8anV zx49U@V_2{}1IQG&u;0RkX~thT^qKqIc$XayQwCnwB6Qeo$@y_pe#{V@$?qb@(7=Fa(&Vb}Y9nV({qz-EbQiCW!Png_RGb+#_aC1S5r5jC)^7x1CJ0#6cq;ylP;O>G~DaqyWVa_<1q^t&b@ zSAlc%PG#*3q=cI8UCbF$-9t=X@S|L8T*>V%s=VV?f*@kpPpp0qyujRS1rx{T zG(%ZwE3KkknHT^++3H9owLID#m=M|B6JQ5msE8U|?Eip`vShOyh0m?p!<=pK1yJ=X zJoRccubq+(@nMh^6|CAq)Ubu3@J{g}6g~5a5Wzjuj3AiFNLYnD`2Ccj3pD$7s=BXS zg_14R`r3dfBMl>?6K2lQegd(EeR0g%<- ztF6y_QWfxjCEvYZEOQ|%5{<->6-^ zGES~ueh0&iNX4ukVbSrhVZp@%3%WrKU{?r^ndY~|hlxDFwKj9L!gJN-xTmBt^OibZPty`t zJ89aID;UgixjI}7Ds_SoMF6P@gxh&jJQBf6rMmyeC-U%pv%ge`px3K(cYnHPxjlA{ zMIzeX1OO^I%$*k`EQDMi&b1qDr2jeIaN_Pej|zv8lK_IL)|kEz0$ zXdCN_UUb!me+>8Zzdx6N^}oXk@4@OeP*cute|Kh#m^tYc$3p61(nf+u<(4t$1Sc2` zWtgh!0FY!ln|zm)pUqD2%wRMANbEpc^NgT@ZOu;84d)*+m#-3MQ)u7=L2?}Sqiw2Y zW#W*JjE4E??KfokQTQ2I!xF#mi8TYD`VoNV&D#JwrJlDBl^giN$(~nXOQQX35xfdu zjcOl0m1Y+#8;Q|h2qQ#{Kt`e`!Vey06~mZ_rq%ff(pqlj?SMR^2i=5tkjO1-n})7S z><;A8C6NY=S2;%Qo5)%Ar;^e+1(dhH#Qp;gbx-LoR;$;&Xd-MfeNB&b#v$p!-(x;s z4B<4v4(?2I*r>X>xpmZ=BME?FQeooR30%#Ah=gwbWZKM73Jwou9dA<#R4VwI1JO6( zJe;tNpU(3#m3=%Z-L<)TXFlziD4h{|aLjO^b(X7^$9>`AAxvp`WoWl>i9~yJYBoBD1|?4rMAh+3Rq@8@PUx z!%(iCNI_)^+plJBPFbZ#;426pZ#0Oy1xdDG_^BkY4%T%2?9sgMXJuuTfyRObq~IoP z32gHsp--^EC`2Fgx+vbA*mA4;thyS$MN2~$aCMA#j7=-PgE^7s#<0yPfQX)`JI!W| zP9==R!@?JpSS=0!I(teh+F3goN(*>D|Iu@OT5ReXYk+TPct*I@iQf(i^6&438RIv7 zl&G}bw)Os1$?-|Yw5fW0KDz75Qpf%(8S@Ngx3WG6!k5J2P22GC6cGo>MEO}v6_#qqWCo%XR8NxkU$qRAbB!0Z4XfM^<%7(937isPj8X<i!BE>ev@1Cz;44xTxt^%wrVK%NCJX$H1q3)bUi$m1i)W)FUZid1 zsZl?aBRO?2)QmCUQ%*dYuvZi;eVF;8Ad5N|blN|nF|^$;(e-Cw%EuqiVMbc6t}`1a z_)DAPwK6lU7QtWcg5@X?Rc)zq^3bp0ynpOdcQs2e+~S<5o1mS`3_S1UrKT6nXbM{% zwn#DarT%QA$BU`Fa|57jk7n;6@~^-SfnYkV({(X)<$#EfrbHnFogjg*BZhiIg1;7Z zNbCm9&Qr>ac?ugjZmie~x|z+dG^;nmRO!0v-vF}MDVrwW_ZW&fMI=cO2_jOS#iAs z>G)t+wSNglF~SExK`SrBH%O*)a3xiIzWOZV0yyH+qPRYj7NibxwberVCMtA^+ZIc3 z@d;XLviuif|L7*e{#dT&kpu(1PwHxoy_`#BU*M1d?B67N1^{ZvJy2S@W0aocdU^%vyOTM3%36H8Aj7_s8S}bX_~`A_;F-)aXY$R47eowr?Wl z4OC=^Z>YVOLb@D#K;66wM|RcUFlUq`byKI`RpCTcrB~~H;UL(+j7Qkn?V89rDr|k4 zcxBcPBS_3}t3Ko1CBGh1x~4OACUrj9V|h`g+}iDFxf%F6irWQC$!#m>Q!D)$6{_nF7WfcKX~TJhy7&BM#H}tnv(I_RsI*46yapeZwHDOH;%m+`^RsJp@GI2P?pj5k8iecc3&a@xu^QQnP?rxwke%>*od z@2I4{elT8n9mo`>dl#=^8?)c*G_uoF_ga%$Q zM!0RK0sD;;?7F9eD3L@Hf}JT8<68e3`!ltH@vtK!TlhW}w?b}%MFsa;^;6Dh7Zh2H zl$`fx2RXorh!a(;D*9#qD@j4h`bF{*Smg|gE+y^p3AMv>zZ=gBi~ENdC;E3U{~tjJIWwaB@>AfL8jG&>z0L_Dj0*@h|^#nAdU*&>nh!o(8P zI+urZ_7E=**0$mjs>`U|C%k)eX>KC6yD4(thilHb5k#5{c;5PrC{oF4fJ(3w~j!lG)y-)U%7Y3#xr&z%>DQVw88 ztg&+O0rZf-rf>|#$#KNUSc(ud>4Tqq}r=pXvFncOy@vO>! zohmNskk_`vEyH-Mubfe=#RScIuXm_Xw}B5ulw zr{yv9MX-}0d(nWr&0re&-1)Uv=qg@^f%zWwkF7S6j5uhWthbYqoe#cD5pWm#H8qT_ z(D9|o<8mASTf%$@69~7w+e}xth`Qt^Dbrtyl?)8PLBxnYg0nmoJbb@)OnHsI?1^af z!Q(*SCde2LX@+ewBUk~OZD1^K>9oH1CZmN1%9ADx5RM*7iga}mZ;^4noaXs_-CZMh z9cQUSa>k?BpFV4SKS3*bkArH8SMF)<95~pKFVx9g?5$1U(ATQI z&=P;b-+Aq>-|>ORhMmrExmv}hX1~EpIYn?_j)xH2+gZw#xwX{ZB9aQR151J+?X^PO zemi=c<(R^*RcQMkxEm^G+_BvJLj$OdN-s_`XOmnonJ2Hg9(rOl%X?%_qRFpFG>dbd zPqvGHRCF3F?KS%|B454K)Y*;wrY;ebAH>)wkR-?j+r7@DK{<}lQnS%1TF!Sz&6ZY^{&+oYr{~T(`=fB!uK41>mg3VDF}vKLrXi!Q!gt_$g_&W}h8ziMqx)2sc}=7d}l^2nYL`L{cq+b|ok zKLTvcl$KoV;NIsZ8LS{n(~QAQ$SIL#?oF&|Iteyy^XLyogl7jI_tvBVk)gXxL?8Pm zDm8?AFt1){i%b-#&w-GV>lP{c)zSG&u-1Lb6nG7dY0rAdQui8>_by%BQ~8%>%TJ)e zaQNoIy!(e6QdP6hF0$iMjp(_%f%I9ny6InQG9D5<7ffXe7tQ42i}J`~pdX$b{&HQ1 zC2{WLObOLbVq`5gCB?eS1hn6uOe;{nv!S)RUzrB`>M7SXz@RrO6&8*%Zx$rh5ze+! zJ>2;p{nP<)d@`xLLNzzXG)(NN^MJTMm>CYSj)^iqK*!__J!|{iNg}}r{LA!M{^vKW zAI57Y@F45IXw$1Q9y*QADv`d^b_*CF*Oz@<%7Pw}w9Scv+RvanTUK3#SIHZ6==lv7 zU%Z_V;)MZ8TMI%QCjxAG>Qg(1(Iodmy!dT7hOv2r-AdB!gzHTG!E;pBGtODhdo*K* zF?U$H_klu^@7OslIGeGU6UGq;g(r;7rKeWg+D1q*0g#Dbc;wT5N7ERcb~uuim4^P5 zxdC^n!8~^DOR>e{b!8O$ME<73MrjWf{&xie#(Aj9KEkSco3yI%rB#7HRnc01uO_GY z#&NNp;|fk8-)RYOl84O|`QT%;p#_HzGIXX5-vsBozunheO6rTa?km*C za)zbyWHGIyAPAc_Y6x0C5_VLy3}WV84f~XTl9>}ljlRbY;@s4aHF?%u6>d8w8exf- zYAgB5cRu>Yj=M_O`&rYfhj$z|o&|Ks zO7syDlXQ^J*Tk7^KnQ1f_xBY*;mz>$gHDIlLvZu=d8=8f@`T6kuDTu=PLpL18+MfX zi=Kv(o13)eE&`XM-EviDwnqhp-Ns|Ls~iFAB?QCPB~${NlAQUQNl2MrLS*%dqvQ={ zR~VgP?`24xD?#@?D;gvufp1JL7GJX4#9A-Hg$5zL$|vmzUh>JSw@w+TXLO!a>@=|g z>lG+WOQG7|L%Dc!*F$Dpd2pnNniJDk0Yk|T>C+)NY193CB?cY1a6-Faa9qBn=A%9I zSclTA=E4q+ZXb!0T=P(V1=0#sQro;m&i=(tc~KsUTHdrOzI+NnQT}sDVZAMthFOca zi}ynE;^dUzH|$vQDM^K`|2VJdIry3cuy#!;J#Bleq$`npO%Cr(()$(hTJ(r$b)%(r z6MEUbhEJhm>^Ej$__Y{izkw!^5}w?4*j+@Vaf-pOZuF+Gs|ciRsw+nF1CMk0{a|Jh zZ}Q47y_lu}g_CM|6|$NPHA_YC70ky^&VZWQoyeu~XC&l&?l^V9z#sKY42aDe5(3wd9ok)AxZ;RSLk$goX(@3hA*Q&BA z3!E@KIBfnqay3yOe{J>nK*MCg5fqbtc`doYIiX!4ft<2%c?W#aOHL6@ji?oSa~*Q_ zT^*J&KI0q2YJB0iA5VlAgb0baRKyGeyvrz0GDmxa1k8Vv)2SL~!P%3^R!IpTU!#+N z?^H^1`2ONiT|{|?jK+q*2WrPEt(SudegfmbxzcKJlNO!prj#RSr+b0-F_JZZ_Z*knhOxA)ZHYQ29m;M68w?1p3 zcXQo)SJT%yJtbyNg;;6WGFSI>vZI|r7qwLg7;Pt1LW@+8$S(++^`ALVJ8j?0kJdfYOR6=P#&2+LZTAqY=RHZzU4dd%W+J0WbHox+dr3ik9M_p$JiQl?l-r z<-I||+h>0)=E7|y9Mkapu=-7Qtw5_XU$S)AOBRyr8H^%07L0n2uu<3xzbB9I;Id~0 zQ4h1>rpg{;pMt5CREm&W17Gv+YCTaz?Z!1~W)KNVc6c+{9annp@ds)KB3o6&XT5i8 z%DvvF5Ybt*58rDA<<@_^CIWT$+&4c=kC*9zcoas=(4I7OtFE1a-?UA6TnV%dGr$Qp zW+VNq#BEjE`{?z$)Y;o^9ZWKhzBI>(LHx+CtG9y7tn187(Z_r_HrdhEVQ}Cc zKytg7LtJG?eh3u3hpxOkD*~ny5fi$D=zH0!m1OGn9W}8yqLWBW+cDN`tmEgN3HiJV zAHIu3|3w~1fhTb=?|N;-mRa9U`Wm(KWHzGv%CQ86AmUfS5UAyl5buef!)U@HBrl)+ z#H7zEG}aJ``aB3vHwDvR0DlPF6?!Yc$9vftt7IhOqdFo2ob)qzx&wD4D#|Y{|9;xM zPfo&^>C&rU4le6mIx%!vAITY9uXBM!+n`fXysH}7MirkN;9VC>bX&M$T*}XPj9U5B zlkDn0P8vzY@}66C(hjiy%KRMsUMP&7i}r3hflWmBW!YNyddmW*1))t%iQ+`Hays*i z<)ONAsi{eJ@>CBsEohQZ-PE;LJwgcvsRudA^$R|`++`i#Z;D-_N3W{bQ2YTkNn>v7 zfE^Bxhvvkk+Ru-h*gO11$d~E(?uYQ=7ws^3TFcM)WEguYDR_rLkD7qtt_)1Ptj$IS zvV4$3?21I5O0?=S6<9Rv!p^LO4uY|@C5_9QOwToAKWI>TtA#jPsJ!e1*e3e<7$33^vLrwV6CuZeR#H}e-i?V3h*Xk9+Fly%^7>QXBwn5e2fhadvA2a- zYP0B8{N3ak+(96$gngEMp``m+E$T;7;7YRcxJO8iesBAv@>@7Ecn+zGrAErX6gF;m zH^+`M{)b~IbO5#!119Mpm3f3Xs^)Mmi|?a0W|U_=BGavnZv*V}X56vn~&r-ggTmSA0&=#SIq8t)mXXTKG-zr9s(jjO_lek_(d8K#LY ze%Fh)mt_rEee}P&_U)SB4LmQKKC_7_Nc6$PK-dIwbM|Y3trPzk++q)zQ`{0%40GPp znY!1#G7%RdOuTp9x0?y!YP^23GlEaACs{f?Fky+IjC>gXZc!XpqQ$H=%m^ucM6J4N zP?H{$snNq8x%gKx8!jNi2(w-L$L|VO$8>D*D2Y(2;wLu@YJoW|*g#Om?cIlu*bQ+9 zhFr#t7b^n893{LO{L|JG5q=B>iQGVfMxv(og&$kg9M(4U@OpUGYO3CYx2OT{261}@ zi;k1T48>k@%MCVern+&O1E#~^bPntDPs`Rc zXW)mZP2_D#1NA%81XyE|`=D~GAb-U=SWO46IoKr`ML?g3VH3vL+hlO|BuQQbK3e~C zHgI@L80oK|ZUBX=ZdO@?o*>BdQrlcGg5AKIk=9X6)$1oUf>&Oo)VCiuB5E1UiLItH zN1q;zqJrhIw7Z(?90^rk>I~=)QM*;ZPp5?S2{LwAWEk?X!pI{@a6p?`J5Uh(JlAc; z2J?;Rw9B3Aj}QmMwIn*o@84gy`4AmwKM^tRmwYGn!A9dT73sX~m!uK7H9Qj4z}9-V zaUyF3fx(E!wfg9-n96Q!o15V@0C)bNbyeaOyF<$2iU>!2mcNljY{sKu=UpGLg<%Q* zRUiS*P0UKBE_uWQ4gp1#$4Qs*|8jE}mUYwu!w56`|5b2pA zy;g$Mot93AEA_9sc~Mc`Rtee92xGFmNOuC_V4pDHS@!YW&7fu7Y65fEeGTE{?1{UfjnMdIZImz z^;fdo3U0UeBa|ZIpmAMWJ%phMfc|~Oa3km7_y>@M3p`>WQ>cha+P+{uc8m3`)Bg=c z`Tx&wRv^E8PxW$ceknew{Eu%fC#5V|BW@aM>tyZxkDe`T?&4tU?!nGJ4}^gQ!NDUS z>r#7Jxw+dqJLyrIxmkR+{WsF2_HcI5rFJ&Av$F7T#{vK&3rxTm${Gq*zE+lE|I26w z5lPd{+1UdEEJH!XC-^`}szvSO>EJMjhK>Qo#?w*OV70gMb=Uug|I3j!wS|M3yL%BP z76b+YTZDrP0YTs(@S09$j#egMynjnp9u{UEW)99aEN)iTR&G{K7FL?J7S2xp`6MF7 zD=2`#&ytZ-Q0o7Wr~e+*{U6oJ-B4LW($>P`Um#{~zC{#Ny2=_V|8D?@n1s{}oIG-h wY8nRr?!5k?5m7Pm2}vpGb&c&Eo!tY|D~JCv-2XM;e|+};8ukCc|L(8/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}') +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) + +# Platform-specific settings +ifeq ($(UNAME_S),Linux) + PLATFORM=linux + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),aarch64) + ARCH=arm64 + else ifeq ($(UNAME_M),armv81) + ARCH=arm64 + else ifeq ($(UNAME_M),loongarch64) + ARCH=loong64 + else ifeq ($(UNAME_M),riscv64) + ARCH=riscv64 + else ifeq ($(UNAME_M),mipsel) + ARCH=mipsle + else + ARCH=$(UNAME_M) + endif +else ifeq ($(UNAME_S),Darwin) + PLATFORM=darwin + WEB_GO=CGO_ENABLED=1 go + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH=arm64 + else + ARCH=$(UNAME_M) + endif +else ifeq ($(UNAME_S),Windows) + PLATFORM=windows + ARCH=$(UNAME_M) + LDFLAGS=-H=windowsgui $(LDFLAGS) +else + PLATFORM=$(UNAME_S) + ARCH=$(UNAME_M) +endif + # Run both frontend and backend dev servers dev: - @if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \ + @if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \ echo "Build artifacts not found, building..."; \ $(MAKE) build; \ fi @@ -15,24 +73,24 @@ dev-frontend: # Start backend dev server dev-backend: - cd backend && go run . + cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" . # Build frontend and embed into Go binary build: cd frontend && pnpm build:backend - cd backend && go build -o picoclaw-web . + ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/ # Run all tests test: - cd backend && go test ./... + cd backend && ${WEB_GO} test ./... cd frontend && pnpm lint # Lint and format lint: - cd backend && go vet ./... + cd backend && ${WEB_GO} vet ./... cd frontend && pnpm check # Clean build artifacts clean: - rm -rf frontend/dist backend/dist backend/picoclaw-web + rm -rf frontend/dist backend/dist $(BUILD_DIR) mkdir -p backend/dist && touch backend/dist/.gitkeep diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 091e3fbae..a7d5b3c5d 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "regexp" "github.com/sipeed/picoclaw/pkg/config" ) @@ -188,6 +189,27 @@ func validateConfig(cfg *config.Config) []string { errs = append(errs, "channels.discord.token is required when discord channel is enabled") } + if cfg.Tools.Exec.Enabled { + if cfg.Tools.Exec.EnableDenyPatterns { + errs = append( + errs, + validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...) + } + errs = append( + errs, + validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...) + } + + return errs +} + +func validateRegexPatterns(field string, patterns []string) []string { + var errs []string + for index, pattern := range patterns { + if _, err := regexp.Compile(pattern); err != nil { + errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err)) + } + } return errs } diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 29811e37e..54ec8e857 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -86,3 +86,82 @@ func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { t.Fatalf("model_list[0].api_base = %q, want empty string", got) } } + +func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "exec": { + "custom_deny_patterns": ["("] + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) { + t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String()) + } +} + +func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "exec": { + "enabled": false, + "custom_deny_patterns": ["("], + "custom_allow_patterns": ["("] + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "exec": { + "enabled": true, + "enable_deny_patterns": false, + "custom_deny_patterns": ["("] + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} diff --git a/web/backend/api/events.go b/web/backend/api/events.go deleted file mode 100644 index af44d1824..000000000 --- a/web/backend/api/events.go +++ /dev/null @@ -1,65 +0,0 @@ -package api - -import ( - "encoding/json" - "sync" -) - -// GatewayEvent represents a state change event for the gateway process. -type GatewayEvent struct { - Status string `json:"gateway_status"` // "running", "starting", "restarting", "stopped", "error" - PID int `json:"pid,omitempty"` - BootDefaultModel string `json:"boot_default_model,omitempty"` - ConfigDefaultModel string `json:"config_default_model,omitempty"` - RestartRequired bool `json:"gateway_restart_required,omitempty"` -} - -// EventBroadcaster manages SSE client subscriptions and broadcasts events. -type EventBroadcaster struct { - mu sync.RWMutex - clients map[chan string]struct{} -} - -// NewEventBroadcaster creates a new broadcaster. -func NewEventBroadcaster() *EventBroadcaster { - return &EventBroadcaster{ - clients: make(map[chan string]struct{}), - } -} - -// Subscribe adds a new listener channel and returns it. -// The caller must call Unsubscribe when done. -func (b *EventBroadcaster) Subscribe() chan string { - ch := make(chan string, 8) - b.mu.Lock() - b.clients[ch] = struct{}{} - b.mu.Unlock() - return ch -} - -// Unsubscribe removes a listener channel and closes it. -func (b *EventBroadcaster) Unsubscribe(ch chan string) { - b.mu.Lock() - delete(b.clients, ch) - b.mu.Unlock() - close(ch) -} - -// Broadcast sends a GatewayEvent to all connected SSE clients. -func (b *EventBroadcaster) Broadcast(event GatewayEvent) { - data, err := json.Marshal(event) - if err != nil { - return - } - - b.mu.RLock() - defer b.mu.RUnlock() - - for ch := range b.clients { - // Non-blocking send; drop event if client is slow - select { - case ch <- string(data): - default: - } - } -} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 4c341ec77..9336127fd 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -4,9 +4,9 @@ import ( "bufio" "crypto/tls" "encoding/json" + "errors" "fmt" "io" - "log" "net" "net/http" "os" @@ -19,6 +19,8 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -26,15 +28,14 @@ import ( var gateway = struct { mu sync.Mutex cmd *exec.Cmd + owned bool // true if we started the process, false if we attached to an existing one bootDefaultModel string runtimeStatus string startupDeadline time.Time logs *LogBuffer - events *EventBroadcaster }{ runtimeStatus: "stopped", logs: NewLogBuffer(200), - events: NewEventBroadcaster(), } var ( @@ -63,10 +64,38 @@ func gatewayHealthScheme(cfg *config.Config) string { return "http" } +// 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 + } + + probeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) + url := "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health" + + return getGatewayHealthByURL(url, timeout) +} + +func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusResponse, int, error) { + resp, err := gatewayHealthGet(url, timeout) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + var healthResponse health.StatusResponse + if decErr := json.NewDecoder(resp.Body).Decode(&healthResponse); decErr != nil { + return nil, resp.StatusCode, decErr + } + + return &healthResponse, resp.StatusCode, nil +} + // registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux. func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) - mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents) mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs) mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs) mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) @@ -77,32 +106,55 @@ 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)) + } + return + } + } + gateway.mu.Lock() defer gateway.mu.Unlock() - if isGatewayProcessAliveLocked() { - return - } if gateway.cmd != nil && gateway.cmd.Process != nil { gateway.cmd = nil } ready, reason, err := h.gatewayStartReady() if err != nil { - log.Printf("Skip auto-starting gateway: %v", err) + logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err)) return } if !ready { - log.Printf("Skip auto-starting gateway: %s", reason) + logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason)) return } - pid, err := h.startGatewayLocked("starting") + pid, err := h.startGatewayLocked("starting", 0) if err != nil { - log.Printf("Failed to auto-start gateway: %v", err) + logger.ErrorC("gateway", fmt.Sprintf("Failed to auto-start gateway: %v", err)) return } - log.Printf("Gateway auto-started (PID: %d)", pid) + logger.InfoC("gateway", fmt.Sprintf("Gateway auto-started (PID: %d)", pid)) } // gatewayStartReady validates whether current config can start the gateway. @@ -140,8 +192,14 @@ func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig return modelCfg } -func isGatewayProcessAliveLocked() bool { - return isCmdProcessAliveLocked(gateway.cmd) +func gatewayRestartRequired(configDefaultModel, bootDefaultModel, gatewayStatus string) bool { + if gatewayStatus != "running" { + return false + } + if strings.TrimSpace(configDefaultModel) == "" || strings.TrimSpace(bootDefaultModel) == "" { + return false + } + return configDefaultModel != bootDefaultModel } func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { @@ -172,7 +230,30 @@ func setGatewayRuntimeStatusLocked(status string) { gateway.startupDeadline = time.Time{} } -func gatewayStatusOnHealthFailureLocked() string { +// attachToGatewayProcess attaches to an existing gateway process by PID +// and updates the gateway state accordingly. +// Assumes gateway.mu is held by the caller. +func attachToGatewayProcessLocked(pid int, cfg *config.Config) error { + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process for PID %d: %w", pid, err) + } + + gateway.cmd = &exec.Cmd{Process: process} + gateway.owned = false // We didn't start this process + setGatewayRuntimeStatusLocked("running") + + // Update bootDefaultModel from config + if cfg != nil { + defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + gateway.bootDefaultModel = defaultModelName + } + + logger.InfoC("gateway", fmt.Sprintf("Attached to gateway process (PID: %d)", pid)) + return nil +} + +func gatewayStatusWithoutHealthLocked() string { if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" { if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) { return gateway.runtimeStatus @@ -185,23 +266,7 @@ func gatewayStatusOnHealthFailureLocked() string { if gateway.runtimeStatus == "error" { return "error" } - return "error" -} - -func currentGatewayStatusLocked(processAlive bool) string { - if !processAlive { - if gateway.runtimeStatus == "restarting" { - if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) { - return "restarting" - } - return "error" - } - if gateway.runtimeStatus == "error" { - return "error" - } - return "stopped" - } - return gatewayStatusOnHealthFailureLocked() + return "stopped" } func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool { @@ -221,6 +286,59 @@ func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool { } } +// StopGateway stops the gateway process if it was started by this handler. +// This method is called during application shutdown to ensure the gateway subprocess +// is properly terminated. It only stops processes that were started by this handler, +// not processes that were attached to from existing instances. +func (h *Handler) StopGateway() { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + // Only stop if we own the process (started it ourselves) + if !gateway.owned || gateway.cmd == nil || gateway.cmd.Process == nil { + return + } + + pid, err := stopGatewayLocked() + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err)) + return + } + + logger.InfoC("gateway", fmt.Sprintf("Gateway stopped (PID: %d)", pid)) +} + +// stopGatewayLocked sends a stop signal to the gateway process. +// Assumes gateway.mu is held by the caller. +// Returns the PID of the stopped process and any error encountered. +func stopGatewayLocked() (int, error) { + if gateway.cmd == nil || gateway.cmd.Process == nil { + return 0, nil + } + + pid := gateway.cmd.Process.Pid + + // Send SIGTERM for graceful shutdown (SIGKILL on Windows) + var sigErr error + if runtime.GOOS == "windows" { + sigErr = gateway.cmd.Process.Kill() + } else { + sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM) + } + + if sigErr != nil { + return pid, sigErr + } + + logger.InfoC("gateway", fmt.Sprintf("Sent stop signal to gateway (PID: %d)", pid)) + gateway.cmd = nil + gateway.owned = false + gateway.bootDefaultModel = "" + setGatewayRuntimeStatusLocked("stopped") + + return pid, nil +} + func stopGatewayProcessForRestart(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) { return nil @@ -253,33 +371,41 @@ func stopGatewayProcessForRestart(cmd *exec.Cmd) error { return fmt.Errorf("existing gateway did not exit before restart") } -func gatewayRestartRequired(status, bootDefaultModel, configDefaultModel string) bool { - return status == "running" && - bootDefaultModel != "" && - configDefaultModel != "" && - bootDefaultModel != configDefaultModel -} - -func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { +func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return 0, fmt.Errorf("failed to load config: %w", err) } defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + var cmd *exec.Cmd + var pid int + + if existingPid > 0 { + // Attach to existing process + pid = existingPid + gateway.cmd = nil // Clear first to ensure clean state + if err = attachToGatewayProcessLocked(pid, cfg); err != nil { + return 0, err + } + + return pid, nil + } + + // Start new process // Locate the picoclaw executable execPath := utils.FindPicoclawBinary() - cmd := exec.Command(execPath, "gateway") + cmd = exec.Command(execPath, "gateway", "-E") cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same // config file without requiring a --config flag on the gateway subcommand. if h.configPath != "" { - cmd.Env = append(cmd.Env, "PICOCLAW_CONFIG="+h.configPath) + cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath) } if host := h.gatewayHostOverride(); host != "" { - cmd.Env = append(cmd.Env, "PICOCLAW_GATEWAY_HOST="+host) + cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host) } stdoutPipe, err := cmd.StdoutPipe() @@ -296,8 +422,8 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - if _, err := h.ensurePicoChannel(); err != nil { - log.Printf("Warning: failed to ensure pico channel: %v", err) + if _, err := h.ensurePicoChannel(""); err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) // Non-fatal: gateway can still start without pico channel } @@ -306,19 +432,11 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { } gateway.cmd = cmd + gateway.owned = true // We started this process gateway.bootDefaultModel = defaultModelName setGatewayRuntimeStatusLocked(initialStatus) - pid := cmd.Process.Pid - log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath) - - // Broadcast the launch state immediately so clients can reflect it without polling. - gateway.events.Broadcast(GatewayEvent{ - Status: initialStatus, - PID: pid, - BootDefaultModel: defaultModelName, - ConfigDefaultModel: defaultModelName, - RestartRequired: false, - }) + pid = cmd.Process.Pid + logger.InfoC("gateway", fmt.Sprintf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)) // Capture stdout/stderr in background go scanPipe(stdoutPipe, gateway.logs) @@ -327,32 +445,23 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { // Wait for exit in background and clean up go func() { if err := cmd.Wait(); err != nil { - log.Printf("Gateway process exited: %v", err) + logger.ErrorC("gateway", fmt.Sprintf("Gateway process exited: %v", err)) } else { - log.Printf("Gateway process exited normally") + logger.InfoC("gateway", "Gateway process exited normally") } gateway.mu.Lock() - shouldBroadcastStopped := false if gateway.cmd == cmd { gateway.cmd = nil gateway.bootDefaultModel = "" if gateway.runtimeStatus != "restarting" { setGatewayRuntimeStatusLocked("stopped") - shouldBroadcastStopped = true } } gateway.mu.Unlock() - - if shouldBroadcastStopped { - gateway.events.Broadcast(GatewayEvent{ - Status: "stopped", - RestartRequired: false, - }) - } }() - // Start a goroutine to probe health and broadcast "running" once ready + // Start a goroutine to probe health and update the runtime state once ready. go func() { for i := 0; i < 30; i++ { // try for up to 15 seconds time.Sleep(500 * time.Millisecond) @@ -366,32 +475,15 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { if err != nil { continue } - healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) - healthPort := cfg.Gateway.Port - if healthPort == 0 { - healthPort = 18790 - } - scheme := gatewayHealthScheme(cfg) - hostPort := net.JoinHostPort(healthHost, strconv.Itoa(healthPort)) - healthURL := fmt.Sprintf("%s://%s/health", scheme, hostPort) - resp, err := gatewayHealthGet(healthURL, 1*time.Second) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - gateway.mu.Lock() - if gateway.cmd == cmd { - setGatewayRuntimeStatusLocked("running") - } - gateway.mu.Unlock() - gateway.events.Broadcast(GatewayEvent{ - Status: "running", - PID: pid, - BootDefaultModel: defaultModelName, - ConfigDefaultModel: defaultModelName, - RestartRequired: false, - }) - return + 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 + gateway.mu.Lock() + if gateway.cmd == cmd { + setGatewayRuntimeStatusLocked("running") } + gateway.mu.Unlock() + return } } }() @@ -403,19 +495,54 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { // // 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) + 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) + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) + return + } + } + gateway.mu.Lock() defer gateway.mu.Unlock() - // Prevent duplicate starts - if isGatewayProcessAliveLocked() { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]any{ - "status": "already_running", - "pid": gateway.cmd.Process.Pid, - }) - return - } if gateway.cmd != nil && gateway.cmd.Process != nil { gateway.cmd = nil setGatewayRuntimeStatusLocked("stopped") @@ -440,7 +567,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { return } - pid, err := h.startGatewayLocked("starting") + pid, err := h.startGatewayLocked("starting", 0) if err != nil { http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) return @@ -454,6 +581,8 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { } // handleGatewayStop stops the running gateway subprocess gracefully. +// Note: Unlike StopGateway (which only stops self-started processes), this API endpoint +// stops any gateway process, including attached ones. This is intentional for user control. // // POST /api/gateway/stop func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { @@ -468,23 +597,12 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { return } - pid := gateway.cmd.Process.Pid - - // Send SIGTERM for graceful shutdown (SIGKILL on Windows) - var sigErr error - if runtime.GOOS == "windows" { - sigErr = gateway.cmd.Process.Kill() - } else { - sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM) - } - - if sigErr != nil { - http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, sigErr), http.StatusInternalServerError) + pid, err := stopGatewayLocked() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err), http.StatusInternalServerError) return } - log.Printf("Sent stop signal to gateway (PID: %d)", pid) - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "ok", @@ -492,36 +610,21 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { }) } -// handleGatewayRestart stops the gateway (if running) and starts a new instance. -// -// POST /api/gateway/restart -func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { +// RestartGateway restarts the gateway process. This is a non-blocking operation +// that stops the current gateway (if running) and starts a new one. +// Returns the PID of the new gateway process or an error. +func (h *Handler) RestartGateway() (int, error) { ready, reason, err := h.gatewayStartReady() if err != nil { - http.Error( - w, - fmt.Sprintf("Failed to validate gateway start conditions: %v", err), - http.StatusInternalServerError, - ) - return + return 0, fmt.Errorf("failed to validate gateway start conditions: %w", err) } if !ready { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]any{ - "status": "precondition_failed", - "message": reason, - }) - return + return 0, &preconditionFailedError{reason: reason} } gateway.mu.Lock() previousCmd := gateway.cmd setGatewayRuntimeStatusLocked("restarting") - gateway.events.Broadcast(GatewayEvent{ - Status: "restarting", - RestartRequired: false, - }) gateway.mu.Unlock() if err = stopGatewayProcessForRestart(previousCmd); err != nil { @@ -536,8 +639,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { } } gateway.mu.Unlock() - http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError) - return + return 0, fmt.Errorf("failed to stop gateway: %w", err) } gateway.mu.Lock() @@ -545,7 +647,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { gateway.cmd = nil gateway.bootDefaultModel = "" } - pid, err := h.startGatewayLocked("restarting") + pid, err := h.startGatewayLocked("restarting", 0) if err != nil { gateway.cmd = nil gateway.bootDefaultModel = "" @@ -553,6 +655,43 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { } gateway.mu.Unlock() if err != nil { + return 0, fmt.Errorf("failed to start gateway: %w", err) + } + + return pid, nil +} + +// preconditionFailedError is returned when gateway restart preconditions are not met +type preconditionFailedError struct { + reason string +} + +func (e *preconditionFailedError) Error() string { + return e.reason +} + +// IsBadRequest returns true if the error should result in a 400 Bad Request status +func (e *preconditionFailedError) IsBadRequest() bool { + return true +} + +// handleGatewayRestart stops the gateway (if running) and starts a new instance. +// +// POST /api/gateway/restart +func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { + pid, err := h.RestartGateway() + if err != nil { + // Check if it's a precondition failed error + var precondErr *preconditionFailedError + if errors.As(err, &precondErr) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": precondErr.reason, + }) + return + } http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError) return } @@ -589,8 +728,8 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) gatewayStatusData() map[string]any { data := map[string]any{} - cfg, cfgErr := config.LoadConfig(h.configPath) configDefaultModel := "" + cfg, cfgErr := config.LoadConfig(h.configPath) if cfgErr == nil && cfg != nil { configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) if configDefaultModel != "" { @@ -598,74 +737,74 @@ func (h *Handler) gatewayStatusData() map[string]any { } } - // Check process state - gateway.mu.Lock() - processAlive := isGatewayProcessAliveLocked() - bootDefaultModel := "" - if processAlive { - data["pid"] = gateway.cmd.Process.Pid - if gateway.bootDefaultModel != "" { - data["boot_default_model"] = gateway.bootDefaultModel - bootDefaultModel = gateway.bootDefaultModel - } - } - gateway.mu.Unlock() - - if !processAlive { + // Probe health endpoint to get pid and status + healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) + if err != nil { gateway.mu.Lock() - data["gateway_status"] = currentGatewayStatusLocked(false) + data["gateway_status"] = gatewayStatusWithoutHealthLocked() gateway.mu.Unlock() + logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err)) } else { - // Process is alive — probe its health endpoint - host := "127.0.0.1" - port := 18790 - if cfgErr == nil && cfg != nil { - host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) - if cfg.Gateway.Port != 0 { - port = cfg.Gateway.Port - } - } - - url := fmt.Sprintf("%s://%s/health", gatewayHealthScheme(cfg), net.JoinHostPort(host, strconv.Itoa(port))) - resp, err := gatewayHealthGet(url, 2*time.Second) - - if err != nil { + logger.InfoC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode)) + if statusCode != http.StatusOK { gateway.mu.Lock() - data["gateway_status"] = currentGatewayStatusLocked(true) + setGatewayRuntimeStatusLocked("error") gateway.mu.Unlock() + data["gateway_status"] = "error" + data["status_code"] = statusCode } else { - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("error") - gateway.mu.Unlock() - data["gateway_status"] = "error" - data["status_code"] = resp.StatusCode - } else { - var healthData map[string]any - if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { - gateway.mu.Lock() + 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") - gateway.mu.Unlock() 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 { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("running") - gateway.mu.Unlock() - for k, v := range healthData { - data[k] = v + // 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() } } - status, _ := data["gateway_status"].(string) + bootDefaultModel, _ := data["boot_default_model"].(string) + gatewayStatus, _ := data["gateway_status"].(string) data["gateway_restart_required"] = gatewayRestartRequired( - status, - bootDefaultModel, configDefaultModel, + bootDefaultModel, + gatewayStatus, ) ready, reason, readyErr := h.gatewayStartReady() @@ -736,51 +875,6 @@ func gatewayLogsData(r *http.Request) map[string]any { return data } -// handleGatewayEvents serves an SSE stream of gateway state change events. -// -// GET /api/gateway/events -func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "SSE not supported", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Subscribe to gateway events - ch := gateway.events.Subscribe() - defer gateway.events.Unsubscribe(ch) - - // Send initial status so the client doesn't start blank - initial := h.currentGatewayStatus() - fmt.Fprintf(w, "data: %s\n\n", initial) - flusher.Flush() - - for { - select { - case <-r.Context().Done(): - return - case data, ok := <-ch: - if !ok { - return - } - fmt.Fprintf(w, "data: %s\n\n", data) - flusher.Flush() - } - } -} - -// currentGatewayStatus returns the current gateway status as a JSON string. -func (h *Handler) currentGatewayStatus() string { - data := h.gatewayStatusData() - encoded, _ := json.Marshal(data) - return string(encoded) -} - // scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF. func scanPipe(r io.Reader, buf *LogBuffer) { scanner := bufio.NewScanner(r) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index a499c1ea2..592571a28 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -3,6 +3,7 @@ package api import ( "net" "net/http" + "net/url" "strconv" "strings" @@ -46,6 +47,23 @@ func gatewayProbeHost(bindHost string) string { return bindHost } +func (h *Handler) gatewayProxyURL() *url.URL { + cfg, err := config.LoadConfig(h.configPath) + port := 18790 + bindHost := "" + if err == nil && cfg != nil { + if cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + bindHost = h.effectiveGatewayBindHost(cfg) + } + + return &url.URL{ + Scheme: "http", + Host: net.JoinHostPort(gatewayProbeHost(bindHost), strconv.Itoa(port)), + } +} + func requestHostName(r *http.Request) string { reqHost, _, err := net.SplitHostPort(r.Host) if err == nil { @@ -57,10 +75,34 @@ func requestHostName(r *http.Request) string { return "127.0.0.1" } +func requestWSScheme(r *http.Request) string { + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) + if proto == "https" || proto == "wss" { + return "wss" + } + if proto == "http" || proto == "ws" { + return "ws" + } + } + + if r.TLS != nil { + return "wss" + } + + return "ws" +} + func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { host := h.effectiveGatewayBindHost(cfg) if host == "" || host == "0.0.0.0" { host = requestHostName(r) } - return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws" + // Use web server port instead of gateway port to avoid exposing extra ports + // The WebSocket connection will be proxied by the backend to the gateway + wsPort := h.serverPort + if wsPort == 0 { + wsPort = 18800 // default web server port + } + return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index afd600359..ae3434862 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -1,9 +1,13 @@ package api import ( + "crypto/tls" + "errors" + "net/http" "net/http/httptest" "path/filepath" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/web/backend/launcherconfig" @@ -47,8 +51,8 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) req.Host = "192.168.1.9:18800" - if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18790/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws") + if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws") } } @@ -57,3 +61,128 @@ func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1") } } + +func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "192.168.1.10" + cfg.Gateway.Port = 18791 + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if got := h.gatewayProxyURL().String(); got != "http://192.168.1.10:18791" { + t.Fatalf("gatewayProxyURL() = %q, want %q", got, "http://192.168.1.10:18791") + } +} + +func TestGetGatewayHealthUsesConfiguredHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "192.168.1.10" + cfg.Gateway.Port = 18791 + + originalHealthGet := gatewayHealthGet + t.Cleanup(func() { + gatewayHealthGet = originalHealthGet + }) + + var requestedURL string + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + requestedURL = url + return nil, errors.New("probe failed") + } + + _, statusCode, err := h.getGatewayHealth(cfg, time.Second) + _ = statusCode + _ = err + + if requestedURL != "http://192.168.1.10:18791/health" { + t.Fatalf("health url = %q, want %q", requestedURL, "http://192.168.1.10:18791/health") + } +} + +func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetServerOptions(18800, true, true, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18791 + + originalHealthGet := gatewayHealthGet + t.Cleanup(func() { + gatewayHealthGet = originalHealthGet + }) + + var requestedURL string + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + requestedURL = url + return nil, errors.New("probe failed") + } + + _, statusCode, err := h.getGatewayHealth(cfg, time.Second) + _ = statusCode + _ = err + + if requestedURL != "http://127.0.0.1:18791/health" { + t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health") + } +} + +func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req.Host = "chat.example.com" + req.Header.Set("X-Forwarded-Proto", "https") + + if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") + } +} + +func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req.Host = "secure.example.com" + req.TLS = &tls.ConnectionState{} + + if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") + } +} + +func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req.Host = "chat.example.com" + req.TLS = &tls.ConnectionState{} + req.Header.Set("X-Forwarded-Proto", "http") + + if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") + } +} diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 06803722d..5c94f0b89 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "os" @@ -36,6 +37,15 @@ func startLongRunningProcess(t *testing.T) *exec.Cmd { return cmd } +func mockGatewayHealthResponse(statusCode, pid int) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader( + `{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(pid) + `}`, + )), + } +} + func startIgnoringTermProcess(t *testing.T) *exec.Cmd { t.Helper() @@ -419,6 +429,125 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) } } +func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("stopped") + gateway.mu.Unlock() + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), 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["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) + } +} + +func TestGatewayStatusRequiresRestartAfterDefaultModelChange(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].APIKey = "test-key" + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "second-model", + Model: "openai/gpt-4.1", + APIKey: "second-key", + }) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Agents.Defaults.ModelName = "second-model" + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["boot_default_model"]; got != cfg.ModelList[0].ModelName { + t.Fatalf("boot_default_model = %#v, want %q", got, cfg.ModelList[0].ModelName) + } + if got := body["config_default_model"]; got != "second-model" { + t.Fatalf("config_default_model = %#v, want %q", got, "second-model") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) { resetGatewayTestState(t) @@ -494,60 +623,6 @@ func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) { } } -func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(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].APIKey = "test-key" - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("SaveConfig() error = %v", err) - } - - h := NewHandler(configPath) - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - cmd := startLongRunningProcess(t) - t.Cleanup(func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - _ = cmd.Wait() - }) - - gateway.mu.Lock() - gateway.cmd = cmd - gateway.bootDefaultModel = "previous-model" - setGatewayRuntimeStatusLocked("running") - gateway.mu.Unlock() - - gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { - rec := httptest.NewRecorder() - rec.WriteHeader(http.StatusOK) - _, _ = rec.WriteString(`{"ok":true}`) - return rec.Result(), 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_restart_required"]; got != true { - t.Fatalf("gateway_restart_required = %#v, want true", got) - } -} - func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 919b47fbc..4edabb9ab 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -7,13 +7,13 @@ import ( "fmt" "html" "io" - "log" "net/http" "strings" "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -714,7 +714,7 @@ func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred * if cp.Email == "" { email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken) if err != nil { - log.Printf("oauth warning: could not fetch google email: %v", err) + logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch google email: %v", err)) } else { cp.Email = email } @@ -722,7 +722,7 @@ func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred * if cp.ProjectID == "" { projectID, err := oauthFetchAntigravityProject(cp.AccessToken) if err != nil { - log.Printf("oauth warning: could not fetch antigravity project id: %v", err) + logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch antigravity project id: %v", err)) } else { cp.ProjectID = projectID } diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index a4590dcde..a880f2f0c 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httputil" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -16,6 +17,30 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) + + // WebSocket proxy: forward /pico/ws to gateway + // This allows the frontend to connect via the same port as the web UI, + // avoiding the need to expose extra ports for WebSocket communication. + mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy()) +} + +// 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) + } + 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) + } } // handleGetPicoToken returns the current WS token and URL for the frontend. @@ -65,9 +90,14 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { }) } -// ensurePicoChannel checks if the Pico Channel is properly configured and -// enables it with sensible defaults if not. Returns true if config was changed. -func (h *Handler) ensurePicoChannel() (bool, error) { +// 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) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, fmt.Errorf("failed to load config: %w", err) @@ -85,14 +115,9 @@ func (h *Handler) ensurePicoChannel() (bool, error) { changed = true } - if !cfg.Channels.Pico.AllowTokenQuery { - cfg.Channels.Pico.AllowTokenQuery = true - changed = true - } - - // Make sure origins are allowed (frontend might be running on a different port like 5173 during dev) - if len(cfg.Channels.Pico.AllowOrigins) == 0 { - cfg.Channels.Pico.AllowOrigins = []string{"*"} + // 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 } @@ -109,7 +134,7 @@ func (h *Handler) ensurePicoChannel() (bool, error) { // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { - changed, err := h.ensurePicoChannel() + changed, err := h.ensurePicoChannel(r.Header.Get("Origin")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go new file mode 100644 index 000000000..075da4ddc --- /dev/null +++ b/web/backend/api/pico_test.go @@ -0,0 +1,314 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestEnsurePicoChannel_FreshConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + changed, err := h.ensurePicoChannel("") + if err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + if !changed { + t.Fatal("ensurePicoChannel() should report changed on a fresh config") + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if !cfg.Channels.Pico.Enabled { + t.Error("expected Pico to be enabled after setup") + } + if cfg.Channels.Pico.Token == "" { + t.Error("expected a non-empty token after setup") + } +} + +func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.ensurePicoChannel(""); err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg.Channels.Pico.AllowTokenQuery { + t.Error("setup must not enable allow_token_query by default") + } +} + +func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.ensurePicoChannel("http://localhost:18800"); err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + for _, origin := range cfg.Channels.Pico.AllowOrigins { + if origin == "*" { + t.Error("setup must not set wildcard origin '*'") + } + } +} + +func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.ensurePicoChannel(""); err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + // 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) + if err != nil { + t.Fatalf("LoadConfig() 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) + } +} + +func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + // Pre-configure with custom user settings + cfg := config.DefaultConfig() + cfg.Channels.Pico.Enabled = true + cfg.Channels.Pico.Token = "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 { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + + changed, err := h.ensurePicoChannel("") + if err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + if changed { + t.Error("ensurePicoChannel() should not change a fully configured config") + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg.Channels.Pico.Token != "user-custom-token" { + t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token") + } + if !cfg.Channels.Pico.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) + } +} + +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 { + t.Fatalf("first ensurePicoChannel() error = %v", err) + } + + cfg1, _ := config.LoadConfig(configPath) + token1 := cfg1.Channels.Pico.Token + + // Second call should be a no-op + changed, err := h.ensurePicoChannel(origin) + if err != nil { + t.Fatalf("second ensurePicoChannel() error = %v", err) + } + if changed { + t.Error("second ensurePicoChannel() should not report changed") + } + + cfg2, _ := config.LoadConfig(configPath) + if cfg2.Channels.Pico.Token != token1 { + t.Error("token should not change on subsequent calls") + } +} + +func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("POST", "/api/pico/setup", nil) + req.Header.Set("Origin", "http://10.0.0.5:3000") + rec := httptest.NewRecorder() + + h.handlePicoSetup(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + 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) + } +} + +func TestHandlePicoSetup_Response(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("POST", "/api/pico/setup", nil) + rec := httptest.NewRecorder() + + h.handlePicoSetup(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["token"] == nil || resp["token"] == "" { + t.Error("response should contain a non-empty token") + } + if resp["ws_url"] == nil || resp["ws_url"] == "" { + t.Error("response should contain ws_url") + } + if resp["enabled"] != true { + t.Error("response should have enabled=true") + } + if resp["changed"] != true { + t.Error("response should have changed=true on first setup") + } +} + +func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("server1 path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "server1") + })) + defer server1.Close() + + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("server2 path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "server2") + })) + defer server2.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server1.URL) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + rec1 := httptest.NewRecorder() + handler(rec1, req1) + + if rec1.Code != http.StatusOK { + t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK) + } + if body := rec1.Body.String(); body != "server1" { + t.Fatalf("first body = %q, want %q", body, "server1") + } + + cfg.Gateway.Port = mustGatewayTestPort(t, server2.URL) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + rec2 := httptest.NewRecorder() + handler(rec2, req2) + + if rec2.Code != http.StatusOK { + t.Fatalf("second status = %d, want %d", rec2.Code, http.StatusOK) + } + if body := rec2.Body.String(); body != "server2" { + t.Fatalf("second body = %q, want %q", body, "server2") + } +} + +func mustGatewayTestPort(t *testing.T, rawURL string) int { + t.Helper() + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("url.Parse() error = %v", err) + } + + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", parsed.Port(), err) + } + + return port +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 646794a72..d55269ed8 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -73,3 +73,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Research tasks (proxy to gateway) h.registerResearchRoutes(mux) } + +// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler. +func (h *Handler) Shutdown() { + h.StopGateway() +} diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go index 936074fee..3c2fb57dd 100644 --- a/web/backend/api/skills.go +++ b/web/backend/api/skills.go @@ -309,7 +309,7 @@ func loadSkillContent(path string) (string, error) { } func globalConfigDir() string { - if home := os.Getenv("PICOCLAW_HOME"); home != "" { + if home := os.Getenv(config.EnvHome); home != "" { return home } home, err := os.UserHomeDir() @@ -320,7 +320,7 @@ func globalConfigDir() string { } func builtinSkillsDir() string { - if path := os.Getenv("PICOCLAW_BUILTIN_SKILLS"); path != "" { + if path := os.Getenv(config.EnvBuiltinSkills); path != "" { return path } wd, err := os.Getwd() diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index 373a3be12..9df4a7091 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -118,6 +118,12 @@ var toolCatalog = []toolCatalogEntry{ Category: "agents", ConfigKey: "spawn", }, + { + Name: "spawn_status", + Description: "Query the status of spawned subagents.", + Category: "agents", + ConfigKey: "spawn_status", + }, { Name: "i2c", Description: "Interact with I2C hardware devices exposed on the host.", @@ -205,7 +211,7 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem { reasonCode = "requires_skills" } } - case "spawn": + case "spawn", "spawn_status": if cfg.Tools.IsToolEnabled(entry.ConfigKey) { if cfg.Tools.IsToolEnabled("subagent") { status = "enabled" @@ -300,6 +306,12 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error { if enabled { cfg.Tools.Subagent.Enabled = true } + case "spawn_status": + cfg.Tools.SpawnStatus.Enabled = enabled + if enabled { + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true + } case "i2c": cfg.Tools.I2C.Enabled = enabled case "spi": diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go new file mode 100644 index 000000000..e3a9ec64f --- /dev/null +++ b/web/backend/app_runtime.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +const ( + browserDelay = 500 * time.Millisecond + shutdownTimeout = 15 * time.Second +) + +// shutdownApp gracefully shuts down all server components and resources. +// It performs the following shutdown sequence: +// - Shuts down the API handler to close all active SSE (Server-Sent Events) connections +// - Disables HTTP keep-alive to prevent new connections during shutdown +// - Attempts graceful HTTP server shutdown with timeout +// - Logs shutdown status at appropriate log levels +// +// The function handles timeout errors gracefully by logging them at info level +// since context.DeadlineExceeded is expected when there are active long-running +// connections (such as SSE streams). +// +// This function should be called during application termination to ensure +// clean resource cleanup and proper connection closure. +func shutdownApp() { + // First, shutdown API handler to close all SSE connections + if apiHandler != nil { + apiHandler.Shutdown() + } + + if 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) + } + } else { + logger.Infof("Server shutdown completed successfully") + } + } +} + +func openBrowser() error { + if serverAddr == "" { + return fmt.Errorf("server address not set") + } + return utils.OpenBrowser(serverAddr) +} diff --git a/web/backend/embed.go b/web/backend/embed.go index 2b28f84b9..cf0c76bce 100644 --- a/web/backend/embed.go +++ b/web/backend/embed.go @@ -2,12 +2,14 @@ package main import ( "embed" + "fmt" "io/fs" - "log" "mime" "net/http" "path" "strings" + + "github.com/sipeed/picoclaw/pkg/logger" ) //go:embed all:dist @@ -19,16 +21,16 @@ func registerEmbedRoutes(mux *http.ServeMux) { // Go's built-in mime.TypeByExtension returns "image/svg" which is incorrect // The correct MIME type per RFC 6838 is "image/svg+xml" if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil { - log.Printf("Warning: failed to register SVG MIME type: %v", err) + logger.ErrorC("web", fmt.Sprintf("Warning: failed to register SVG MIME type: %v", err)) } // Attempt to get the subdirectory 'dist' where Vite usually builds subFS, err := fs.Sub(frontendFS, "dist") if err != nil { // Log a warning if dist doesn't exist yet (e.g., during development before a frontend build) - log.Printf( - "Warning: no 'dist' folder found in embedded frontend. " + - "Ensure you run `pnpm build:backend` in the frontend directory " + + logger.WarnC("web", + "Warning: no 'dist' folder found in embedded frontend. "+ + "Ensure you run `pnpm build:backend` in the frontend directory "+ "before building the Go backend.", ) return diff --git a/web/backend/i18n.go b/web/backend/i18n.go new file mode 100644 index 000000000..9cda9e5d5 --- /dev/null +++ b/web/backend/i18n.go @@ -0,0 +1,120 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// Language represents the supported languages +type Language string + +const ( + LanguageEnglish Language = "en" + LanguageChinese Language = "zh" +) + +// current language (default: English) +var currentLang Language = LanguageEnglish + +// TranslationKey represents a translation key used for i18n +type TranslationKey string + +const ( + AppTooltip TranslationKey = "AppTooltip" + MenuOpen TranslationKey = "MenuOpen" + MenuOpenTooltip TranslationKey = "MenuOpenTooltip" + MenuAbout TranslationKey = "MenuAbout" + MenuAboutTooltip TranslationKey = "MenuAboutTooltip" + MenuVersion TranslationKey = "MenuVersion" + MenuVersionTooltip TranslationKey = "MenuVersionTooltip" + MenuGitHub TranslationKey = "MenuGitHub" + MenuDocs TranslationKey = "MenuDocs" + MenuRestart TranslationKey = "MenuRestart" + MenuRestartTooltip TranslationKey = "MenuRestartTooltip" + MenuQuit TranslationKey = "MenuQuit" + MenuQuitTooltip TranslationKey = "MenuQuitTooltip" + Exiting TranslationKey = "Exiting" + DocUrl TranslationKey = "DocUrl" +) + +// Translation tables +// Chinese translations intentionally contain Han script +// +//nolint:gosmopolitan +var translations = map[Language]map[TranslationKey]string{ + LanguageEnglish: { + AppTooltip: "%s - Web Console", + MenuOpen: "Open Console", + MenuOpenTooltip: "Open PicoClaw console in browser", + MenuAbout: "About", + MenuAboutTooltip: "About PicoClaw", + MenuVersion: "Version: %s", + MenuVersionTooltip: "Current version number", + MenuGitHub: "GitHub", + MenuDocs: "Documentation", + MenuRestart: "Restart Service", + MenuRestartTooltip: "Restart Gateway service", + MenuQuit: "Quit", + MenuQuitTooltip: "Exit PicoClaw", + Exiting: "Exiting PicoClaw...", + DocUrl: "https://docs.picoclaw.io/docs/", + }, + LanguageChinese: { + AppTooltip: "%s - Web Console", + MenuOpen: "打开控制台", + MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台", + MenuAbout: "关于", + MenuAboutTooltip: "关于 PicoClaw", + MenuVersion: "版本: %s", + MenuVersionTooltip: "当前版本号", + MenuGitHub: "GitHub", + MenuDocs: "文档", + MenuRestart: "重启服务", + MenuRestartTooltip: "重启核心服务", + MenuQuit: "退出", + MenuQuitTooltip: "退出 PicoClaw", + Exiting: "正在退出 PicoClaw...", + DocUrl: "https://docs.picoclaw.io/zh-Hans/docs/", + }, +} + +// SetLanguage sets the current language +func SetLanguage(lang string) { + lang = strings.ToLower(strings.TrimSpace(lang)) + + // Extract language code before first underscore or dot + // e.g., "en_US.UTF-8" -> "en", "zh_CN" -> "zh" + if idx := strings.IndexAny(lang, "_."); idx > 0 { + lang = lang[:idx] + } + + if lang == "zh" || lang == "zh-cn" || lang == "chinese" { + currentLang = LanguageChinese + } else { + currentLang = LanguageEnglish + } +} + +// GetLanguage returns the current language +func GetLanguage() Language { + return currentLang +} + +// T translates a key to the current language +func T(key TranslationKey, args ...any) string { + if trans, ok := translations[currentLang][key]; ok { + if len(args) > 0 { + return fmt.Sprintf(trans, args...) + } + return trans + } + return string(key) +} + +// Initialize i18n from environment variable +func init() { + if lang := os.Getenv("LANG"); lang != "" { + SetLanguage(lang) + } +} diff --git a/web/backend/icon.png b/web/backend/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e0b4aab9c42f1b84b3ff7ffb145a965bcd67e925 GIT binary patch literal 104580 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Ð(DO2QQ;r?v9Hw5{pgM358g3j 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 0 { @@ -59,11 +104,11 @@ func main() { absPath, err := filepath.Abs(configPath) if err != nil { - log.Fatalf("Failed to resolve config path: %v", err) + logger.Fatalf("Failed to resolve config path: %v", err) } err = utils.EnsureOnboarded(absPath) if err != nil { - log.Printf("Warning: Failed to initialize PicoClaw config automatically: %v", err) + logger.Errorf("Warning: Failed to initialize PicoClaw config automatically: %v", err) } var explicitPort bool @@ -80,7 +125,7 @@ func main() { launcherPath := launcherconfig.PathForAppConfig(absPath) launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default()) if err != nil { - log.Printf("Warning: Failed to load %s: %v", launcherPath, err) + logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load %s: %v", launcherPath, err)) launcherCfg = launcherconfig.Default() } @@ -98,7 +143,7 @@ func main() { if err == nil { err = errors.New("must be in range 1-65535") } - log.Fatalf("Invalid port %q: %v", effectivePort, err) + logger.Fatalf("Invalid port %q: %v", effectivePort, err) } // Determine listen address @@ -113,7 +158,7 @@ func main() { mux := http.NewServeMux() // API Routes (e.g. /api/status) - apiHandler := api.NewHandler(absPath) + apiHandler = api.NewHandler(absPath) apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) apiHandler.RegisterRoutes(mux) @@ -122,7 +167,7 @@ func main() { accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux) if err != nil { - log.Fatalf("Invalid allowed CIDR configuration: %v", err) + logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } // Apply middleware stack @@ -132,29 +177,33 @@ func main() { ), ) - // Print startup banner - fmt.Print(utils.Banner) - fmt.Println() - fmt.Println(" Open the following URL in your browser:") - fmt.Println() - fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) + // Print startup banner (only in console mode) + if enableConsole { + 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) + } + } + fmt.Println() + } + + // 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 != "" { - fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) + logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort)) } } - fmt.Println() - // Auto-open browser - if !*noBrowser { - go func() { - time.Sleep(500 * time.Millisecond) - url := "http://localhost:" + effectivePort - if err := utils.OpenBrowser(url); err != nil { - log.Printf("Warning: Failed to auto-open browser: %v", err) - } - }() - } + // Share the local URL with the launcher runtime. + serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort) + + // Auto-open browser will be handled by the launcher runtime. // Auto-start gateway after backend starts listening. go func() { @@ -162,8 +211,41 @@ func main() { apiHandler.TryAutoStartGateway() }() - // Start the Server - if err := http.ListenAndServe(addr, handler); err != nil { - log.Fatalf("Server failed to start: %v", err) + // 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) + } + }() + + defer shutdownApp() + + // Start system tray or run in console mode + if enableConsole { + if !*noBrowser { + // Auto-open browser after systray is ready (if not disabled) + // Check no-browser flag via environment or pass as parameter if needed + if err := openBrowser(); err != nil { + logger.Errorf("Warning: Failed to auto-open browser: %v", err) + } + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Main event loop - wait for signals or config changes + for { + select { + case <-sigChan: + logger.Info("Shutting down...") + + return + } + } + } else { + // GUI mode: start system tray + runTray() } } diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go index de9e6d870..5e0dfeb90 100644 --- a/web/backend/middleware/middleware.go +++ b/web/backend/middleware/middleware.go @@ -1,19 +1,19 @@ package middleware import ( - "log" + "fmt" "net/http" "runtime/debug" - "strings" "time" + + "github.com/sipeed/picoclaw/pkg/logger" ) // JSONContentType sets the Content-Type header to application/json for // API requests handled by the wrapped handler. -// SSE endpoints (text/event-stream) are excluded. func JSONContentType(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasSuffix(r.URL.Path, "/events") { + if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" { w.Header().Set("Content-Type", "application/json") } next.ServeHTTP(w, r) @@ -32,7 +32,6 @@ func (rr *responseRecorder) WriteHeader(code int) { } // Flush delegates to the underlying ResponseWriter if it implements http.Flusher. -// This is required for SSE (Server-Sent Events) to work through the middleware. func (rr *responseRecorder) Flush() { if f, ok := rr.ResponseWriter.(http.Flusher); ok { f.Flush() @@ -51,7 +50,7 @@ func Logger(next http.Handler) http.Handler { start := time.Now() rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(rec, r) - log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start)) + logger.DebugC("http", fmt.Sprintf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start))) }) } @@ -61,7 +60,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 { - log.Printf("panic recovered: %v\n%s", err, debug.Stack()) + 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/systray.go b/web/backend/systray.go new file mode 100644 index 000000000..fde2e115e --- /dev/null +++ b/web/backend/systray.go @@ -0,0 +1,100 @@ +//go:build (!darwin && !freebsd) || cgo + +package main + +import ( + _ "embed" + "fmt" + + "fyne.io/systray" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +func runTray() { + systray.Run(onReady, onExit) +} + +// onReady is called when the system tray is ready +func onReady() { + // Set icon and tooltip + systray.SetIcon(getIcon()) + systray.SetTooltip(fmt.Sprintf(T(AppTooltip), appName)) + + // Create menu items + mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip)) + mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip)) + + // Add version info under About menu + mVersion := mAbout.AddSubMenuItem(fmt.Sprintf(T(MenuVersion), appVersion), T(MenuVersionTooltip)) + mVersion.Disable() + mRepo := mAbout.AddSubMenuItem(T(MenuGitHub), "") + mDocs := mAbout.AddSubMenuItem(T(MenuDocs), "") + + systray.AddSeparator() + + // Add restart option + mRestart := systray.AddMenuItem(T(MenuRestart), T(MenuRestartTooltip)) + + systray.AddSeparator() + + // Quit option + mQuit := systray.AddMenuItem(T(MenuQuit), T(MenuQuitTooltip)) + + // Handle menu clicks + go func() { + for { + select { + case <-mOpen.ClickedCh: + if err := openBrowser(); err != nil { + logger.Errorf("Failed to open browser: %v", err) + } + + case <-mVersion.ClickedCh: + // Version info - do nothing, just shows current version + + case <-mRepo.ClickedCh: + if err := utils.OpenBrowser("https://github.com/sipeed/picoclaw"); err != nil { + logger.Errorf("Failed to open GitHub: %v", err) + } + + case <-mDocs.ClickedCh: + if err := utils.OpenBrowser(T(DocUrl)); err != nil { + logger.Errorf("Failed to open docs: %v", err) + } + + case <-mRestart.ClickedCh: + fmt.Println("Restart request received...") + if apiHandler != nil { + if pid, err := apiHandler.RestartGateway(); err != nil { + logger.Errorf("Failed to restart gateway: %v", err) + } else { + logger.Infof("Gateway restarted (PID: %d)", pid) + } + } + + case <-mQuit.ClickedCh: + systray.Quit() + } + } + }() + + if !*noBrowser { + // Auto-open browser after systray is ready (if not disabled) + // Check no-browser flag via environment or pass as parameter if needed + if err := openBrowser(); err != nil { + logger.Errorf("Warning: Failed to auto-open browser: %v", err) + } + } +} + +// onExit is called when the system tray is exiting +func onExit() { + logger.Info(T(Exiting)) +} + +// getIcon returns the system tray icon +func getIcon() []byte { + return iconData +} diff --git a/web/backend/systray_unix.go b/web/backend/systray_unix.go new file mode 100644 index 000000000..0f9d2bb51 --- /dev/null +++ b/web/backend/systray_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +import _ "embed" + +//go:embed icon.png +var iconData []byte diff --git a/web/backend/systray_windows.go b/web/backend/systray_windows.go new file mode 100644 index 000000000..cc1885155 --- /dev/null +++ b/web/backend/systray_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package main + +import _ "embed" + +//go:embed icon.ico +var iconData []byte diff --git a/web/backend/tray_stub_nocgo.go b/web/backend/tray_stub_nocgo.go new file mode 100644 index 000000000..13ecfd2cb --- /dev/null +++ b/web/backend/tray_stub_nocgo.go @@ -0,0 +1,33 @@ +//go:build (darwin || freebsd) && !cgo + +package main + +import ( + "context" + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +func runTray() { + logger.Infof("System tray is unavailable in %s builds without cgo; running without tray", runtime.GOOS) + + if !*noBrowser { + go func() { + time.Sleep(browserDelay) + if err := openBrowser(); err != nil { + logger.Errorf("Warning: Failed to auto-open browser: %v", err) + } + }() + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + <-ctx.Done() + shutdownApp() +} diff --git a/web/backend/utils/onboard.go b/web/backend/utils/onboard.go index fbe34f220..81475ac80 100644 --- a/web/backend/utils/onboard.go +++ b/web/backend/utils/onboard.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "strings" + + "github.com/sipeed/picoclaw/pkg/config" ) var execCommand = exec.Command @@ -19,7 +21,7 @@ func EnsureOnboarded(configPath string) error { } cmd := execCommand(FindPicoclawBinary(), "onboard") - cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+configPath) + cmd.Env = append(os.Environ(), config.EnvConfig+"="+configPath) cmd.Stdin = strings.NewReader("n\n") output, err := cmd.CombinedOutput() diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 4e6c32c56..772cd7ec0 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -7,21 +7,26 @@ import ( "os/exec" "path/filepath" "runtime" + + "github.com/sipeed/picoclaw/pkg/config" ) +// 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") +} + // GetDefaultConfigPath returns the default path to the picoclaw config file. func GetDefaultConfigPath() string { - if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { + if configPath := os.Getenv(config.EnvConfig); configPath != "" { return configPath } - if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { - return filepath.Join(picoclawHome, "config.json") - } - home, err := os.UserHomeDir() - if err != nil { - return "config.json" - } - return filepath.Join(home, ".picoclaw", "config.json") + return filepath.Join(GetPicoclawHome(), "config.json") } // FindPicoclawBinary locates the picoclaw executable. @@ -35,7 +40,7 @@ func FindPicoclawBinary() string { binaryName = "picoclaw.exe" } - if p := os.Getenv("PICOCLAW_BINARY"); p != "" { + if p := os.Getenv(config.EnvBinary); p != "" { if info, _ := os.Stat(p); info != nil && !info.IsDir() { return p } diff --git a/web/frontend/package.json b/web/frontend/package.json index 373b4d468..2e0e37117 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir", + "build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir && node ./scripts/ensure-backend-gitkeep.cjs", "lint": "eslint .", "preview": "vite preview", "format": "prettier --check .", @@ -17,18 +17,18 @@ "@tabler/icons-react": "^3.38.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router": "^1.167.0", "@tanstack/react-router-devtools": "^1.163.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", - "jotai": "^2.18.0", + "jotai": "^2.18.1", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-i18next": "^16.5.4", + "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", @@ -40,7 +40,7 @@ "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.3", "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", @@ -48,8 +48,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.56.1", - "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.39.1", + "@vitejs/plugin-react": "^5.2.0", + "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 75acacfa5..20f0a7342 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^5.90.21 version: 5.90.21(react@19.2.4) '@tanstack/react-router': - specifier: ^1.163.3 - version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.167.0 + version: 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': specifier: ^1.163.3 - version: 1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -33,8 +33,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 dayjs: - specifier: ^1.11.19 - version: 1.11.19 + specifier: ^1.11.20 + version: 1.11.20 i18next: specifier: ^25.8.14 version: 25.8.14(typescript@5.9.3) @@ -42,8 +42,8 @@ importers: specifier: ^8.2.1 version: 8.2.1 jotai: - specifier: ^2.18.0 - version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + 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) 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) @@ -54,8 +54,8 @@ importers: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) react-i18next: - specifier: ^16.5.4 - version: 16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: ^16.5.8 + version: 16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -85,14 +85,14 @@ importers: version: 10.0.0 devDependencies: '@eslint/js': - specifier: ^9.39.1 + specifier: ^9.39.3 version: 9.39.3 '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.1) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.1) @@ -109,10 +109,10 @@ importers: specifier: ^8.56.1 version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) eslint: - specifier: ^9.39.1 + specifier: ^9.39.3 version: 9.39.3(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 @@ -469,8 +469,8 @@ 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.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@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-helpers@0.4.2': @@ -481,8 +481,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.4': - resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.3': @@ -1587,15 +1587,15 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.163.3': - resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==} + '@tanstack/react-router@1.167.0': + resolution: {integrity: sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q==} 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.1': - resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} + '@tanstack/react-store@0.9.2': + resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} 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 @@ -1604,6 +1604,10 @@ packages: resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} engines: {node: '>=20.19'} + '@tanstack/router-core@1.167.0': + resolution: {integrity: sha512-pnaaUP+vMQEyL2XjZGe2PXmtzulxvXfGyvEMUs+AEBaNEk77xWA88bl3ujiBRbUxzpK0rxfJf+eSKPdZmBMFdQ==} + engines: {node: '>=20.19'} + '@tanstack/router-devtools-core@1.163.3': resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==} engines: {node: '>=20.19'} @@ -1646,6 +1650,9 @@ packages: '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tanstack/store@0.9.2': + resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + '@tanstack/virtual-file-routes@1.161.4': resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} engines: {node: '>=20.19'} @@ -1790,11 +1797,11 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-react@5.1.4': - resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -2060,8 +2067,8 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -2355,8 +2362,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.1: + resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} @@ -2648,8 +2655,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.35: - resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} + isbot@5.1.36: + resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} engines: {node: '>=18'} isexe@2.0.0: @@ -2669,8 +2676,8 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - jotai@2.18.0: - resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==} + jotai@2.18.1: + resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': '>=7.0.0' @@ -3323,8 +3330,8 @@ packages: peerDependencies: react: ^19.2.4 - react-i18next@16.5.4: - resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} + react-i18next@16.5.8: + resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} peerDependencies: i18next: '>= 25.6.2' react: '>= 16.8.0' @@ -3476,10 +3483,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.0: resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -4268,7 +4285,7 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 @@ -4284,7 +4301,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.4': + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 debug: 4.4.3 @@ -5365,31 +5382,31 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 - '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3) + '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/router-core': 1.167.0 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 - '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.163.3 - isbot: 5.1.35 + '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.167.0 + 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.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/store': 0.9.1 + '@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) @@ -5404,9 +5421,19 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)': + '@tanstack/router-core@1.167.0': dependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/history': 1.161.4 + '@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.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.167.0 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 @@ -5426,7 +5453,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5442,7 +5469,7 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5463,6 +5490,8 @@ snapshots: '@tanstack/store@0.9.1': {} + '@tanstack/store@0.9.2': {} + '@tanstack/virtual-file-routes@1.161.4': {} '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': @@ -5641,7 +5670,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5894,7 +5923,7 @@ snapshots: data-uri-to-buffer@4.0.1: {} - dayjs@1.11.19: {} + dayjs@1.11.20: {} debug@4.4.3: dependencies: @@ -6048,10 +6077,10 @@ snapshots: dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.4 + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.3 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -6241,10 +6270,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.1 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.1: {} formdata-polyfill@4.0.10: dependencies: @@ -6489,7 +6518,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.35: {} + isbot@5.1.36: {} isexe@2.0.0: {} @@ -6501,7 +6530,7 @@ snapshots: jose@6.1.3: {} - jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): optionalDependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 @@ -7310,7 +7339,7 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.6 html-parse-stringify: 3.0.1 @@ -7517,8 +7546,14 @@ snapshots: dependencies: seroval: 1.5.0 + seroval-plugins@1.5.1(seroval@1.5.1): + dependencies: + seroval: 1.5.1 + seroval@1.5.0: {} + seroval@1.5.1: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 diff --git a/web/frontend/scripts/ensure-backend-gitkeep.cjs b/web/frontend/scripts/ensure-backend-gitkeep.cjs new file mode 100644 index 000000000..db9782ab4 --- /dev/null +++ b/web/frontend/scripts/ensure-backend-gitkeep.cjs @@ -0,0 +1,9 @@ +const fs = require("node:fs") +const path = require("node:path") + +const gitkeepPath = path.resolve(__dirname, "../../backend/dist/.gitkeep") +const gitkeepContents = + "# Keep the embedded web backend dist directory in version control.\n" + +fs.mkdirSync(path.dirname(gitkeepPath), { recursive: true }) +fs.writeFileSync(gitkeepPath, gitkeepContents) diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index fe0c84e69..4f0688008 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -56,14 +56,20 @@ export function AppHeader() { const isRunning = gwState === "running" const isStarting = gwState === "starting" const isRestarting = gwState === "restarting" + const isStopping = gwState === "stopping" const isStopped = gwState === "stopped" || gwState === "unknown" const showNotConnectedHint = - !isRestarting && canStart && (gwState === "stopped" || gwState === "error") + !isRestarting && + !isStopping && + canStart && + (gwState === "stopped" || gwState === "error") const [showStopDialog, setShowStopDialog] = React.useState(false) const handleGatewayToggle = () => { - if (gwLoading || isRestarting || (!isRunning && !canStart)) return + if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) { + return + } if (isRunning) { setShowStopDialog(true) } else { @@ -137,7 +143,7 @@ export function AppHeader() { size="icon-sm" className="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25" onClick={handleGatewayRestart} - disabled={gwLoading || isRestarting || !canStart} + disabled={gwLoading || isRestarting || isStopping || !canStart} aria-label={t("header.gateway.action.restart")} > @@ -168,25 +174,31 @@ export function AppHeader() { ) : ( )} 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 a834a65f9..386adf9a5 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" -import { Field, KeyInput } from "@/components/shared-form" +import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Input } from "@/components/ui/input" interface FeishuFormProps { @@ -16,6 +16,10 @@ function asString(value: unknown): string { return typeof value === "string" ? value : "" } +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") @@ -98,6 +102,12 @@ export function FeishuForm({ )} /> + onChange("is_lark", checked)} + /> diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx index 624ff9c59..0574c44d1 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -34,7 +34,7 @@ export function ChatEmptyState({

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

- diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 1906a0367..ebcde8981 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -15,7 +15,6 @@ 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 { hydrateActiveSession } from "@/lib/pico-chat-controller" export function ChatPage() { const { t } = useTranslation() @@ -26,6 +25,7 @@ export function ChatPage() { const { messages, + connectionState, isTyping, activeSessionId, sendMessage, @@ -34,7 +34,8 @@ export function ChatPage() { } = usePicoChat() const { state: gwState } = useGateway() - const isConnected = gwState === "running" + const isGatewayRunning = gwState === "running" + const isChatConnected = connectionState === "connected" const { defaultModelName, @@ -43,7 +44,8 @@ export function ChatPage() { oauthModels, localModels, handleSetDefault, - } = useChatModels({ isConnected }) + } = useChatModels({ isConnected: isGatewayRunning }) + const canSend = isChatConnected && Boolean(defaultModelName) const { sessions, @@ -68,10 +70,6 @@ export function ChatPage() { syncScrollState(e.currentTarget) } - useEffect(() => { - void hydrateActiveSession() - }, []) - useEffect(() => { if (scrollRef.current) { if (isAtBottom) { @@ -82,9 +80,10 @@ export function ChatPage() { }, [messages, isTyping, isAtBottom]) const handleSend = () => { - if (!input.trim() || !isConnected) return - sendMessage(input.trim()) - setInput("") + if (!input.trim() || !canSend) return + if (sendMessage(input.trim())) { + setInput("") + } } return ( @@ -143,7 +142,7 @@ export function ChatPage() { )} @@ -168,7 +167,7 @@ export function ChatPage() { input={input} onInputChange={setInput} onSend={handleSend} - isConnected={isConnected} + isConnected={isChatConnected} hasDefaultModel={Boolean(defaultModelName)} /> diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index cbce7d27e..e533b956f 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -14,7 +14,9 @@ import { } from "@/api/system" import { AgentDefaultsSection, + CronSection, DevicesSection, + ExecSection, LauncherSection, RuntimeSection, } from "@/components/config/config-sections" @@ -26,6 +28,7 @@ import { buildFormFromConfig, parseCIDRText, parseIntField, + parseMultilineList, } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" @@ -164,6 +167,33 @@ export function ConfigPage() { "Heartbeat interval", { min: 1 }, ) + const cronExecTimeoutMinutes = parseIntField( + form.cronExecTimeoutMinutes, + "Cron exec timeout", + { min: 0 }, + ) + const execConfigPatch: Record = { + enabled: form.execEnabled, + } + + if (form.execEnabled) { + execConfigPatch.allow_remote = form.allowRemote + execConfigPatch.enable_deny_patterns = form.enableDenyPatterns + execConfigPatch.custom_allow_patterns = parseMultilineList( + form.customAllowPatternsText, + ) + execConfigPatch.timeout_seconds = parseIntField( + form.execTimeoutSeconds, + "Exec timeout", + { min: 0 }, + ) + + if (form.enableDenyPatterns) { + execConfigPatch.custom_deny_patterns = parseMultilineList( + form.customDenyPatternsText, + ) + } + } await patchAppConfig({ agents: { @@ -180,9 +210,11 @@ export function ConfigPage() { dm_scope: dmScope, }, tools: { - exec: { - allow_remote: form.allowRemote, + cron: { + allow_command: form.allowCommand, + exec_timeout_minutes: cronExecTimeoutMinutes, }, + exec: execConfigPatch, }, heartbeat: { enabled: form.heartbeatEnabled, @@ -279,6 +311,10 @@ export function ConfigPage() { + + + + - onFieldChange("allowRemote", checked)} - /> - + onFieldChange("execEnabled", checked)} + /> + + {form.execEnabled && ( + <> + onFieldChange("allowRemote", checked)} + /> + + + onFieldChange("enableDenyPatterns", checked) + } + /> + + {form.enableDenyPatterns && ( + +