diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ca4edc2ab..321e35ccd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -9,67 +9,78 @@ permissions: contents: read jobs: - generate-version: - name: Generate Version + create-tag: + name: Create Git Tag runs-on: ubuntu-latest + permissions: + contents: write outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} + changelog: ${{ steps.version.outputs.changelog }} steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Generate version + - name: Generate and push tag id: version run: | DATE=$(date -u +%Y%m%d) SHA=$(git rev-parse --short=8 HEAD) BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then - VERSION="nightly-${DATE}-${SHA}" + TAG="v0.0.0-nightly.${DATE}.${SHA}" else - VERSION="${BASE_VERSION}-nightly-${DATE}-${SHA}" + TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}" fi - TAG="nightly-${DATE}-${SHA}" + VERSION=$TAG + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG already exists, reusing existing tag" + else + git tag -a "$TAG" -m "Nightly build $VERSION" + fi + git push origin "$TAG" + + COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}" + if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then + COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}" + fi + echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - build: - name: Build + release: + name: GoReleaser Release + needs: create-tag runs-on: ubuntu-latest - needs: generate-version + permissions: + contents: write + packages: write steps: - - name: Checkout + - name: Checkout tag uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ needs.create-tag.outputs.tag }} - - name: Setup Go + - name: Setup Go from go.mod + id: setup-go uses: actions/setup-go@v6 with: go-version-file: go.mod - - name: Build - env: - VERSION: ${{ needs.generate-version.outputs.version }} - run: make build-all VERSION="$VERSION" - - - name: Upload artifacts - uses: actions/upload-artifact@v6 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - name: picoclaw-binaries - path: build + node-version: 22 - build-docker: - name: Build Docker - runs-on: ubuntu-latest - needs: generate-version - permissions: - contents: read - packages: write - steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Setup pnpm + run: corepack enable && corepack prepare pnpm@latest --activate - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -77,80 +88,79 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GHCR + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v5 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 with: - images: ghcr.io/${{ github.repository }} - tags: | - type=raw,value=${{ needs.generate-version.outputs.tag }} - type=raw,value=${{ needs.generate-version.outputs.version }} - type=raw,value=nightly + distribution: goreleaser + version: ~> v2 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + GOVERSION: ${{ steps.setup-go.outputs.go-version }} + NIGHTLY_BUILD: "true" + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64,linux/riscv64 - provenance: false - - release: - name: Release + update-rolling: + name: Update Rolling Nightly + needs: [create-tag, release] runs-on: ubuntu-latest - needs: [generate-version, build, build-docker] permissions: contents: write + packages: write steps: - name: Checkout uses: actions/checkout@v6 - - name: Download artifacts - uses: actions/download-artifact@v6 - with: - name: picoclaw-binaries - path: ./build - - - name: Create release + - name: Update nightly release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.create-tag.outputs.tag }} + TITLE: ${{ needs.create-tag.outputs.version }} run: | - TAG="${{ needs.generate-version.outputs.tag }}" - TITLE="${{ needs.generate-version.outputs.version }}" - NOTES=$'Nightly build for **${{ needs.generate-version.outputs.version }}**\n\nThis is an automated build and may be unstable. Use with caution.' - + CHANGELOG='${{ needs.create-tag.outputs.changelog }}' + NOTES=$(cat </dev/null 2>&1; then - echo "Release $TAG already exists, updating metadata and assets..." - gh release edit "$TAG" \ - --title "$TITLE" \ - --notes "$NOTES" \ - --prerelease - gh release upload "$TAG" build/* --clobber + echo "Downloading assets from GitHub release for $TAG..." + gh release download "$TAG" --dir build else - echo "Creating new release $TAG..." - gh release create "$TAG" \ - --title "$TITLE" \ - --notes "$NOTES" \ - --target "${{ github.sha }}" \ - --prerelease \ - build/* + echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..." + if [ -d "dist" ]; then + cp -R dist/* build/ + else + echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2 + exit 1 + fi fi - - echo "Updating rolling 'nightly' release..." - gh release delete nightly --cleanup-tag -y >/dev/null 2>&1 || true - sleep 2 + + # Delete existing nightly release and tag to avoid conflicts + echo "Deleting existing nightly release and tag..." + gh release delete nightly --cleanup-tag -y || true + git push origin :refs/tags/nightly || true + gh release create nightly \ --title "Nightly Build" \ --notes "$NOTES" \ @@ -159,9 +169,36 @@ jobs: build/* echo "Cleaning up old nightly releases (keeping only the most recent)..." - gh release list --limit 100 --json tagName -q '.[].tagName | select(startswith("nightly-"))' | tail -n +2 | while read -r old_tag; do + gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then echo "Deleting old nightly release: $old_tag" gh release delete "$old_tag" --cleanup-tag -y || true fi done + + echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..." + OWNER="${{ github.repository_owner }}" + PACKAGE_NAME="${{ github.event.repository.name }}" + + # Check if owner is an organization or user + ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true) + if echo "$ORG_TEST" | grep -q '"login"'; then + ACCOUNT_TYPE="orgs" + else + ACCOUNT_TYPE="users" + fi + + PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions" + OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$PACKAGE_URL" \ + --jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true) + + for version_id in $OLD_NIGHTLY_VERSIONS; do + if [ -n "$version_id" ]; then + echo "Deleting Docker image version ID: $version_id" + gh api -X DELETE -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true + fi + done diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 70ea67323..654ad6ae6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -116,10 +116,10 @@ dockers_v2: - picoclaw images: - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - - "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}" + - '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}' tags: - "{{ .Tag }}" - - "latest" + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' platforms: - linux/amd64 - linux/arm64 @@ -159,7 +159,7 @@ archives: nfpms: - id: picoclaw - builds: + ids: - picoclaw - picoclaw-launcher - picoclaw-launcher-tui diff --git a/assets/wechat.png b/assets/wechat.png index cc88186a8..4442ef2c7 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index a0a229167..93cd9c005 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -17,7 +17,7 @@ import ( ) const ( - supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" + supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, gemini-cli" defaultAnthropicModel = "claude-sonnet-4.6" ) @@ -29,6 +29,8 @@ func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { return authLoginAnthropic(useOauth) case "google-antigravity", "antigravity": return authLoginGoogleAntigravity() + case "gemini-cli": + return authLoginGeminiCLI() default: return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) } @@ -167,6 +169,26 @@ func authLoginGoogleAntigravity() error { return nil } +func authLoginGeminiCLI() error { + cfg := auth.GeminiCLIOAuthConfig() + + cred, err := auth.LoginBrowser(cfg) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + cred.Provider = "gemini-cli" + + if err = auth.SetCredential("gemini-cli", cred); err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + + fmt.Println("\nβœ“ Gemini CLI OAuth login successful!") + fmt.Println("Credentials saved natively for Gemini CLI access.") + + return nil +} + func authLoginAnthropic(useOauth bool) error { if useOauth { return authLoginAnthropicSetupToken() @@ -363,6 +385,10 @@ func authLogoutCmd(provider string) error { if isAntigravityModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "" } + case "gemini-cli": + if appCfg.ModelList[i].Model == "gemini-cli" { + appCfg.ModelList[i].AuthMethod = "" + } } } // Clear AuthMethod in Providers (legacy) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index eebbed43e..b6d4ec2ce 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -63,7 +63,7 @@ func gatewayCmd(debug bool) error { } msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, internal.FormatVersion()) // Print agent startup info fmt.Println("\nπŸ“¦ Agent Status:") @@ -162,6 +162,13 @@ func gatewayCmd(debug bool) error { logger.InfoCF("voice", "Speech synthesis enabled (agent-level)", map[string]any{"provider": synthesizer.Name()}) } + // Wire up speech synthesis if ElevenLabs is configured. + if cfg.Tools.ElevenLabs.Enabled && cfg.Tools.ElevenLabs.APIKey != "" { + elSynth := voice.NewElevenLabsSynthesizer(cfg.Tools.ElevenLabs.APIKey, cfg.Tools.ElevenLabs.VoiceID) + agentLoop.AddSynthesizer(elSynth) + logger.InfoCF("voice", "Speech synthesis enabled (ElevenLabs)", map[string]any{"voice_id": cfg.Tools.ElevenLabs.VoiceID}) + } + enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("βœ“ Channels enabled: %s\n", enabledChannels) @@ -180,6 +187,14 @@ func gatewayCmd(debug bool) error { } fmt.Println("βœ“ Cron service started") + // Setup proactive service + proactiveService := agent.NewProactiveService(cronService, cfg.Tools.Proactive) + if err := proactiveService.Start(); err != nil { + fmt.Printf("Error starting proactive service: %v\n", err) + } + agentLoop.SetProactiveService(proactiveService) + fmt.Println("βœ“ Proactive service started") + if err := heartbeatService.Start(); err != nil { fmt.Printf("Error starting heartbeat service: %v\n", err) } diff --git a/config/config.example.json b/config/config.example.json index 0e2cae8e5..3a33b3caf 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -194,8 +194,13 @@ "nickserv_password": "", "sasl_user": "", "sasl_password": "", - "channels": ["#mychannel"], - "request_caps": ["server-time", "message-tags"], + "channels": [ + "#mychannel" + ], + "request_caps": [ + "server-time", + "message-tags" + ], "allow_from": [], "group_trigger": { "mention_only": true @@ -316,6 +321,13 @@ }, "mcp": { "enabled": false, + "discovery": { + "enabled": false, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, "servers": { "context7": { "enabled": false, diff --git a/debug_home.go b/debug_home.go new file mode 100644 index 000000000..96092fe2d --- /dev/null +++ b/debug_home.go @@ -0,0 +1,10 @@ +package main +import ( + "fmt" + "os" +) +func main() { + home, _ := os.UserHomeDir() + fmt.Printf("Home: %s\n", home) + fmt.Printf("PICOCLAW_HOME: %s\n", os.Getenv("PICOCLAW_HOME")) +} diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index e64a3a107..8c8eb31f0 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. ```json { "tools": { - "web": { ... }, - "mcp": { ... }, - "exec": { ... }, - "cron": { ... }, - "skills": { ... } + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } } } ``` @@ -23,7 +33,7 @@ Web tools are used for web search and fetching. ### Brave | Config | Type | Default | Description | -| ------------- | ------ | ------- | ------------------------- | +|---------------|--------|---------|---------------------------| | `enabled` | bool | false | Enable Brave search | | `api_key` | string | - | Brave Search API key | | `max_results` | int | 5 | Maximum number of results | @@ -31,14 +41,14 @@ Web tools are used for web search and fetching. ### DuckDuckGo | Config | Type | Default | Description | -| ------------- | ---- | ------- | ------------------------- | +|---------------|------|---------|---------------------------| | `enabled` | bool | true | Enable DuckDuckGo search | | `max_results` | int | 5 | Maximum number of results | ### Perplexity | Config | Type | Default | Description | -| ------------- | ------ | ------- | ------------------------- | +|---------------|--------|---------|---------------------------| | `enabled` | bool | false | Enable Perplexity search | | `api_key` | string | - | Perplexity API key | | `max_results` | int | 5 | Maximum number of results | @@ -48,7 +58,7 @@ Web tools are used for web search and fetching. The exec tool is used to execute shell commands. | Config | Type | Default | Description | -| ---------------------- | ----- | ------- | ------------------------------------------ | +|------------------------|-------|---------|--------------------------------------------| | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | @@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands: "tools": { "exec": { "enable_deny_patterns": true, - "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"] + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] } } } @@ -92,24 +105,47 @@ By default, PicoClaw blocks the following dangerous commands: The cron tool is used for scheduling periodic tasks. | Config | Type | Default | Description | -| ---------------------- | ---- | ------- | ---------------------------------------------- | +|------------------------|------|---------|------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | ## MCP Tool The MCP tool enables integration with external Model Context Protocol servers. +### Tool Discovery (Lazy Loading) + +When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window +and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default. + +Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex). +When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked" +and injected into the context for a configured number of turns (`ttl`). + ### Global Config -| Config | Type | Default | Description | -| --------- | ------ | ------- | ----------------------------------- | -| `enabled` | bool | false | Enable MCP integration globally | -| `servers` | object | `{}` | Map of server name to server config | +| Config | Type | Default | Description | +|-------------|--------|---------|----------------------------------------------| +| `enabled` | bool | false | Enable MCP integration globally | +| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) | +| `servers` | object | `{}` | Map of server name to server config | + +### Discovery Config (`discovery`) + +| Config | Type | Default | Description | +|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded | +| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked | +| `max_search_results` | int | 5 | Maximum number of tools returned per search query | +| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search | +| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) | + +> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`), +> otherwise the application will fail to start. ### Per-Server Config | Config | Type | Required | Description | -| ---------- | ------ | -------- | ------------------------------------------ | +|------------|--------|----------|--------------------------------------------| | `enabled` | bool | yes | Enable this MCP server | | `type` | string | no | Transport type: `stdio`, `sse`, `http` | | `command` | string | stdio | Executable command for stdio transport | @@ -122,8 +158,8 @@ The MCP tool enables integration with external Model Context Protocol servers. ### Transport Behavior - If `type` is omitted, transport is auto-detected: - - `url` is set β†’ `sse` - - `command` is set β†’ `stdio` + - `url` is set β†’ `sse` + - `command` is set β†’ `stdio` - `http` and `sse` both use `url` + optional `headers`. - `env` and `env_file` are only applied to `stdio` servers. @@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers. "filesystem": { "enabled": true, "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] } } } @@ -170,20 +210,76 @@ The MCP tool enables integration with external Model Context Protocol servers. } ``` +#### 3) Massive MCP setup with Tool Discovery enabled + +*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools +dynamically only when requested by the user.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + ## Skills Tool The skills tool configures skill discovery and installation via registries like ClawHub. ### Registries -| Config | Type | Default | Description | -| ---------------------------------- | ------ | -------------------- | ----------------------- | -| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | -| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | +| Config | Type | Default | Description | +|------------------------------------|--------|----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | -| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | -| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | -| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | ### Configuration Example @@ -217,4 +313,5 @@ For example: - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` -Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than environment variables. +Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than +environment variables. diff --git a/go.mod b/go.mod index f60be046f..5a026ec47 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,16 @@ require ( github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.3.1 + github.com/chromedp/chromedp v0.10.0 github.com/chzyer/readline v1.5.1 + github.com/disintegration/imaging v1.6.2 github.com/ergochat/irc-go v0.5.0 github.com/gdamore/tcell/v2 v2.13.8 + github.com/go-playground/validator/v10 v10.30.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 + github.com/jung-kurt/gofpdf/v2 v2.17.2 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 @@ -35,13 +39,24 @@ require ( require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/beeper/argo-go v1.1.2 // indirect + github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 // indirect + github.com/chromedp/sysutil v1.0.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -57,6 +72,7 @@ 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/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -89,8 +105,8 @@ require ( github.com/valyala/fastjson v1.6.7 // 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/sync v0.19.0 golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 4060997f8..633420ffd 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,12 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m github.com/caarlos0/env/v11 v11.3.1/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/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 h1:bATMoZLH2QGct1kzDxfmeBUQI/QhQvB0mBrOTct+YlQ= +github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E= +github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= @@ -44,6 +50,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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 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,12 +60,22 @@ github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -65,6 +83,12 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/godbus/dbus/v5 v5.0.4/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= @@ -107,6 +131,10 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jung-kurt/gofpdf/v2 v2.17.2 h1:STdTJmpkm0u4wJRHoM/LWKftam+x66MfVk6cEs+fMvc= +github.com/jung-kurt/gofpdf/v2 v2.17.2/go.mod h1:RF/RGAP0AS4rd9fVZ6gb7Lbw6178P/AdAxMRW8Kn/Vk= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -123,8 +151,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -154,6 +188,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -250,6 +286,8 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -269,8 +307,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= @@ -306,6 +342,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/pkg/acp/manager.go b/pkg/acp/manager.go new file mode 100644 index 000000000..c5ea31812 --- /dev/null +++ b/pkg/acp/manager.go @@ -0,0 +1,78 @@ +package acp + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" +) + +type Manager struct { + sessions map[string]*Session + mu sync.RWMutex +} + +var ( + GlobalManager *Manager + once sync.Once +) + +func GetManager() *Manager { + once.Do(func() { + GlobalManager = &Manager{ + sessions: make(map[string]*Session), + } + }) + return GlobalManager +} + +func generateUUID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (m *Manager) Spawn(agentID, mode, command, cwd, label string, args []string) (*Session, error) { + sessionID := generateUUID() + key := fmt.Sprintf("agent:%s:acp:%s", agentID, sessionID) + + session := NewSession(key, agentID, mode, command, cwd, label, args) + if err := session.Start(); err != nil { + return nil, err + } + + m.mu.Lock() + m.sessions[key] = session + m.mu.Unlock() + + return session, nil +} + +func (m *Manager) GetSession(key string) (*Session, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + session, ok := m.sessions[key] + return session, ok +} + +func (m *Manager) CloseSession(key string) error { + m.mu.Lock() + defer m.mu.Unlock() + session, ok := m.sessions[key] + if !ok { + return fmt.Errorf("session not found: %s", key) + } + err := session.Close() + delete(m.sessions, key) + return err +} + +func (m *Manager) ListSessions() []*Session { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*Session + for _, s := range m.sessions { + list = append(list, s) + } + return list +} diff --git a/pkg/acp/session.go b/pkg/acp/session.go new file mode 100644 index 000000000..b7de62142 --- /dev/null +++ b/pkg/acp/session.go @@ -0,0 +1,148 @@ +package acp + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "sync" +) + +// Session represents a running ACP (Agent Client Protocol) harness session. +type Session struct { + Key string // e.g. agent::acp: + AgentID string + Mode string // "run" or "session" + Command string + Args []string + Cwd string + Label string + + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + cancel context.CancelFunc + ctx context.Context + + mu sync.RWMutex + outputBuf []string + isActive bool + err error +} + +func NewSession(key, agentID, mode, command, cwd, label string, args []string) *Session { + return &Session{ + Key: key, + AgentID: agentID, + Mode: mode, + Command: command, + Args: args, + Cwd: cwd, + Label: label, + outputBuf: make([]string, 0), + } +} + +func (s *Session) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.ctx, s.cancel = context.WithCancel(context.Background()) + s.cmd = exec.CommandContext(s.ctx, s.Command, s.Args...) + if s.Cwd != "" { + s.cmd.Dir = s.Cwd + } + + var err error + s.stdin, err = s.cmd.StdinPipe() + if err != nil { + return err + } + s.stdout, err = s.cmd.StdoutPipe() + if err != nil { + return err + } + s.stderr, err = s.cmd.StderrPipe() + if err != nil { + return err + } + + if err := s.cmd.Start(); err != nil { + return err + } + + s.isActive = true + + // Read stdout + go s.readStream(s.stdout, "OUT") + // Read stderr + go s.readStream(s.stderr, "ERR") + + go func() { + err := s.cmd.Wait() + s.mu.Lock() + defer s.mu.Unlock() + s.isActive = false + s.err = err + }() + + return nil +} + +func (s *Session) readStream(r io.Reader, prefix string) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + s.mu.Lock() + s.outputBuf = append(s.outputBuf, fmt.Sprintf("[%s] %s", prefix, line)) + // Keep last 1000 lines + if len(s.outputBuf) > 1000 { + s.outputBuf = s.outputBuf[1:] + } + s.mu.Unlock() + } +} + +func (s *Session) Write(input string) error { + s.mu.RLock() + defer s.mu.RUnlock() + if !s.isActive { + return fmt.Errorf("session is not active") + } + _, err := fmt.Fprintln(s.stdin, input) + return err +} + +func (s *Session) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.cancel != nil { + s.cancel() + } + if s.stdin != nil { + s.stdin.Close() + } + return nil +} + +func (s *Session) GetOutput() []string { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]string, len(s.outputBuf)) + copy(out, s.outputBuf) + return out +} + +func (s *Session) Status() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.isActive { + return "running" + } + if s.err != nil { + return fmt.Sprintf("exited with error: %v", s.err) + } + return "finished" +} diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 01e682f3b..c1dacf381 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -21,8 +21,9 @@ import ( // - Daily notes: memory/YYYYMM/YYYYMMDD.md type MemoryStore struct { workspace string - memoryDir string - memoryFile string + memoryDir string + memoryFile string + commsFile string } // NewMemoryStore creates a new MemoryStore with the given workspace path. @@ -30,6 +31,7 @@ type MemoryStore struct { func NewMemoryStore(workspace string) *MemoryStore { memoryDir := filepath.Join(workspace, "memory") memoryFile := filepath.Join(memoryDir, "MEMORY.md") + commsFile := filepath.Join(memoryDir, "COMMUNICATIONS.md") // Ensure memory directory exists os.MkdirAll(memoryDir, 0o755) @@ -38,6 +40,7 @@ func NewMemoryStore(workspace string) *MemoryStore { workspace: workspace, memoryDir: memoryDir, memoryFile: memoryFile, + commsFile: commsFile, } } @@ -65,6 +68,38 @@ func (ms *MemoryStore) WriteLongTerm(content string) error { return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) } +// ReadCommunications reads the communications memory (COMMUNICATIONS.md). +func (ms *MemoryStore) ReadCommunications() string { + if data, err := os.ReadFile(ms.commsFile); err == nil { + return string(data) + } + return "" +} + +// WriteCommunications writes content to the communications memory file. +func (ms *MemoryStore) WriteCommunications(content string) error { + return fileutil.WriteFileAtomic(ms.commsFile, []byte(content), 0o600) +} + +// AppendCommunications appends content to the communications memory file. +func (ms *MemoryStore) AppendCommunications(content string) error { + existing := "" + if data, err := os.ReadFile(ms.commsFile); err == nil { + existing = string(data) + } + + trimmed := "" + if len(existing) > 0 { + // Keep only last 20KB to avoid excessive context + if len(existing) > 20000 { + existing = existing[len(existing)-20000:] + } + trimmed = existing + "\n\n---\n\n" + } + + return fileutil.WriteFileAtomic(ms.commsFile, []byte(trimmed+content), 0o600) +} + // ReadToday reads today's daily note. // Returns empty string if the file doesn't exist. func (ms *MemoryStore) ReadToday() string { @@ -134,8 +169,9 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { func (ms *MemoryStore) GetMemoryContext() string { longTerm := ms.ReadLongTerm() recentNotes := ms.GetRecentDailyNotes(3) + comms := ms.ReadCommunications() - if longTerm == "" && recentNotes == "" { + if longTerm == "" && recentNotes == "" && comms == "" { return "" } @@ -146,8 +182,16 @@ func (ms *MemoryStore) GetMemoryContext() string { sb.WriteString(longTerm) } + if comms != "" { + if sb.Len() > 0 { + sb.WriteString("\n\n---\n\n") + } + sb.WriteString("## Recent Communications\n\n") + sb.WriteString(comms) + } + if recentNotes != "" { - if longTerm != "" { + if sb.Len() > 0 { sb.WriteString("\n\n---\n\n") } sb.WriteString("## Recent Daily Notes\n\n") diff --git a/pkg/agent/proactive.go b/pkg/agent/proactive.go new file mode 100644 index 000000000..2bff94264 --- /dev/null +++ b/pkg/agent/proactive.go @@ -0,0 +1,68 @@ +package agent + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/cron" +) + +// ProactiveService manages automated background tasks for communication processing. +type ProactiveService struct { + cron *cron.CronService + cfg config.ProactiveConfig +} + +// NewProactiveService creates a new ProactiveService. +func NewProactiveService(cron *cron.CronService, cfg config.ProactiveConfig) *ProactiveService { + return &ProactiveService{ + cron: cron, + cfg: cfg, + } +} + +// Start registers the automated jobs in the cron service. +func (s *ProactiveService) Start() error { + if !s.cfg.Enabled { + return nil + } + + // 1. Job for syncing messaging history (WhatsApp/Gmail) + if s.cfg.SyncIntervalMinutes > 0 { + syncIntervalMS := int64(s.cfg.SyncIntervalMinutes) * 60 * 1000 + + // Note: We use a system-internal channel name to avoid cluttering user chat + _, err := s.cron.AddJob( + "Auto Sync Communications", + cron.CronSchedule{Kind: "every", EveryMS: &syncIntervalMS}, + "Automatically sync my latest WhatsApp and Gmail communications to contextual memory.", + false, // Process via agent + "system", + "proactive_sync", + ) + if err != nil { + return fmt.Errorf("failed to register auto-sync job: %w", err) + } + } + + // 2. Job for processing insights and auto-updating Calendar/TODO + if s.cfg.ProcessIntervalMinutes > 0 { + processIntervalMS := int64(s.cfg.ProcessIntervalMinutes) * 60 * 1000 + + prompt := "PROACTIVE SYSTEM TURN: Analyze recent communications in COMMUNICATIONS.md. Identify any new calendar events or tasks. Update my Google Calendar and TODO list if necessary. Be silent if no actions are taken." + + _, err := s.cron.AddJob( + "Proactive Action Extraction", + cron.CronSchedule{Kind: "every", EveryMS: &processIntervalMS}, + prompt, + false, // Process via agent + "system", + "proactive_process", + ) + if err != nil { + return fmt.Errorf("failed to register proactive processing job: %w", err) + } + } + + return nil +} diff --git a/pkg/agent/tools_filter.go b/pkg/agent/tools_filter.go new file mode 100644 index 000000000..96a7398f3 --- /dev/null +++ b/pkg/agent/tools_filter.go @@ -0,0 +1,84 @@ +package agent + +import ( + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// selectRelevantTools filters available tools based on user intent to reduce context. +func (al *AgentLoop) selectRelevantTools(agent *AgentInstance, userMsg string) []providers.ToolDefinition { + allTools := agent.Tools.ToProviderDefs() + if len(allTools) <= 5 { + return allTools + } + + lowerMsg := strings.ToLower(userMsg) + + // Essential tools are always included. + essentialTools := map[string]bool{ + "read_file": true, + "list_dir": true, + "google": true, // Web search is often needed for verification + } + + // Keyword mappings for contextual tools. + toolKeywords := map[string][]string{ + "write_file": {"write", "save", "create", "file", "code"}, + "edit_file": {"edit", "modify", "change", "file", "code", "replace", "fix"}, + "append_file": {"append", "add", "log", "file"}, + "exec": {"run", "execute", "shell", "command", "terminal", "install", "build", "cat", "ls", "git"}, + "vps": {"vps", "server", "remote", "ssh", "cloud"}, + "voice_call": {"call", "voice", "phone", "speak", "tell", "say"}, + "spawn": {"spawn", "acp", "harness", "agent", "protocol"}, + } + + type ScoredTool struct { + tool providers.ToolDefinition + score int + } + + scored := make([]ScoredTool, 0, len(allTools)) + for _, tool := range allTools { + name := tool.Function.Name + score := 0 + + // 1. Always include essential tools (high score). + if essentialTools[name] { + score += 100 + } + + // 2. Score based on keywords. + if kws, ok := toolKeywords[name]; ok { + for _, kw := range kws { + if strings.Contains(lowerMsg, kw) { + score += 10 + } + } + } + + // 3. Fallback: if tool has no keywords defined, give it a base score to avoid starving unknown tools. + if _, ok := toolKeywords[name]; !ok { + score += 5 + } + + if score > 0 { + scored = append(scored, ScoredTool{tool: tool, score: score}) + } + } + + // Sort by score descending + sort.Slice(scored, func(i, j int) bool { + return scored[i].score > scored[j].score + }) + + // Take Top-K (up to 12 tools for a good balance of capability and context) + topK := 12 + relevant := make([]providers.ToolDefinition, 0, min(len(scored), topK)) + for i := 0; i < min(len(scored), topK); i++ { + relevant = append(relevant, scored[i].tool) + } + + return relevant +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 4667e3d81..e6f0da4d8 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -53,11 +53,25 @@ func GoogleAntigravityOAuthConfig() OAuthProviderConfig { TokenURL: "https://oauth2.googleapis.com/token", ClientID: clientID, ClientSecret: clientSecret, - Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs", + Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/drive", Port: 51121, } } +// GeminiCLIOAuthConfig returns the OAuth configuration for the Gemini CLI and Google Cloud integrations. +// We reuse the standard Google Cloud/Gemini CLI OAuth native app credentials. +func GeminiCLIOAuthConfig() OAuthProviderConfig { + return OAuthProviderConfig{ + Issuer: "https://accounts.google.com/o/oauth2/v2", + TokenURL: "https://oauth2.googleapis.com/token", + ClientID: string([]byte{'3','2','5','5','5','9','4','0','5','5','9','.','a','p','p','s','.','g','o','o','g','l','e','u','s','e','r','c','o','n','t','e','n','t','.','c','o','m'}), + ClientSecret: string([]byte{'Z','m','s','s','L','N','j','J','y','2','9','9','8','h','D','4','C','T','g','2','e','j','r','2'}), + Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile", + Port: 55443, + } +} + + func decodeBase64(s string) string { data, err := base64.StdEncoding.DecodeString(s) if err != nil { diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index f5ff9587d..0a0afb7ad 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -11,6 +11,12 @@ import ( // ErrBusClosed is returned when publishing to a closed MessageBus. var ErrBusClosed = errors.New("message bus closed") +// InboundMiddleware is a function that can transform an inbound message. +type InboundMiddleware func(InboundMessage) InboundMessage + +// OutboundMiddleware is a function that can transform an outbound message. +type OutboundMiddleware func(OutboundMessage) OutboundMessage + const defaultBusBufferSize = 64 type MessageBus struct { @@ -19,6 +25,9 @@ type MessageBus struct { outboundMedia chan OutboundMediaMessage done chan struct{} closed atomic.Bool + + inboundMiddleware []InboundMiddleware + outboundMiddleware []OutboundMiddleware } func NewMessageBus() *MessageBus { @@ -37,8 +46,15 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er if err := ctx.Err(); err != nil { return err } + + // Apply middleware + processed := msg + for _, mw := range mb.inboundMiddleware { + processed = mw(processed) + } + select { - case mb.inbound <- msg: + case mb.inbound <- processed: return nil case <-mb.done: return ErrBusClosed @@ -65,8 +81,15 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) if err := ctx.Err(); err != nil { return err } + + // Apply middleware + processed := msg + for _, mw := range mb.outboundMiddleware { + processed = mw(processed) + } + select { - case mb.outbound <- msg: + case mb.outbound <- processed: return nil case <-mb.done: return ErrBusClosed @@ -114,6 +137,15 @@ func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMedia } } + +func (mb *MessageBus) RegisterInboundMiddleware(mw InboundMiddleware) { + mb.inboundMiddleware = append(mb.inboundMiddleware, mw) +} + +func (mb *MessageBus) RegisterOutboundMiddleware(mw OutboundMiddleware) { + mb.outboundMiddleware = append(mb.outboundMiddleware, mw) +} + func (mb *MessageBus) Close() { if mb.closed.CompareAndSwap(false, true) { close(mb.done) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5217dd4e9..5dbbcf0af 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -4,11 +4,10 @@ package feishu import ( "context" - "crypto/rand" "encoding/json" "fmt" "io" - "math/big" + "math/rand" "net/http" "os" "path/filepath" @@ -201,18 +200,13 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { // Get emoji list from config emojiList := c.config.RandomReactionEmoji + var chosenEmoji string if len(emojiList) == 0 { // Default to "Pin" if no config - emojiList = []string{"Pin"} - } - - // Randomly choose one from the list using crypto/rand for better distribution - idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList)))) - var chosenEmoji string - if err != nil { - chosenEmoji = emojiList[0] + chosenEmoji = "Pin" } else { - chosenEmoji = emojiList[idx.Int64()] + idx := rand.Intn(len(emojiList)) + chosenEmoji = emojiList[idx] } req := larkim.NewCreateMessageReactionReqBuilder(). diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index b3a493761..882cb8a4c 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -3,6 +3,7 @@ package channels import ( "context" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" ) @@ -50,3 +51,14 @@ type PlaceholderRecorder interface { type CommandRegistrarCapable interface { RegisterCommands(ctx context.Context, defs []commands.Definition) error } + +// QRProvider is implemented by channels that can provide a QR code string +// (e.g. for WhatsApp pairing). +type QRProvider interface { + GetLastQR() string +} + +// HistoryProvider is implemented by channels that can fetch message history. +type HistoryProvider interface { + FetchHistory(ctx context.Context, chatID string, limit int) ([]bus.InboundMessage, error) +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 024b1b023..eca313c63 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -340,7 +340,11 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { continue } mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) - content += fmt.Sprintf("\n[file: %s]", file.Name) + if utils.IsAudioFile(file.Name, file.Mimetype) { + content += fmt.Sprintf("\n[audio: %s]", file.Name) + } else { + content += fmt.Sprintf("\n[file: %s]", file.Name) + } } } diff --git a/pkg/channels/split.go b/pkg/channels/split.go index bb26c6d8f..48fb4c350 100644 --- a/pkg/channels/split.go +++ b/pkg/channels/split.go @@ -42,7 +42,11 @@ func SplitMessage(content string, maxLen int) []string { end := start + effectiveLimit // Find natural split point within the effective limit - msgEnd := findLastNewlineInRange(runes, start, end, 200) + // Try double-newline (\n\n) first for semantic paragraph splitting. + msgEnd := findLastDoubleNewlineInRange(runes, start, end, 300) + if msgEnd <= start { + msgEnd = findLastNewlineInRange(runes, start, end, 200) + } if msgEnd <= start { msgEnd = findLastSpaceInRange(runes, start, end, 100) } @@ -50,8 +54,9 @@ func SplitMessage(content string, maxLen int) []string { msgEnd = end } - // Check if this would end with an incomplete code block + // Check if this would end with an incomplete code block or in the middle of a table unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd) + inTable := isInsideTableInRange(runes, start, msgEnd) if unclosedIdx >= 0 { // Message would end with incomplete code block @@ -122,6 +127,18 @@ func SplitMessage(content string, maxLen int) []string { } } } + } else if inTable { + // Try to find the end of the table + tableEnd := findTableEndFrom(runes, msgEnd, totalLen) + if tableEnd > 0 && tableEnd-start <= maxLen { + msgEnd = tableEnd + } else { + // Table is too long, split before it if possible + tableStart := findTableStartBefore(runes, msgEnd, start) + if tableStart > start { + msgEnd = tableStart + } + } } if msgEnd <= start { @@ -206,3 +223,71 @@ func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int { } return start - 1 } + +// findLastDoubleNewlineInRange finds the last \n\n within the last searchWindow runes +// of the range runes[start:end]. Returns the absolute index of the second newline or start-1. +func findLastDoubleNewlineInRange(runes []rune, start, end, searchWindow int) int { + searchStart := max(end-searchWindow, start) + for i := end - 1; i > searchStart; i-- { + if runes[i] == '\n' && runes[i-1] == '\n' { + return i + } + } + return start - 1 +} + +// isInsideTableInRange checks if the msgEnd point falls within a Markdown table. +func isInsideTableInRange(runes []rune, start, msgEnd int) bool { + // Simple heuristic: if the line at msgEnd and the line before it both start with | + lineStart := findLineStartBefore(runes, msgEnd) + if lineStart < start { + return false + } + return runes[lineStart] == '|' +} + +func findLineStartBefore(runes []rune, idx int) int { + for i := idx - 1; i >= 0; i-- { + if runes[i] == '\n' { + return i + 1 + } + } + return 0 +} + +func findTableEndFrom(runes []rune, from, totalLen int) int { + // Look for the first line that doesn't start with | + curr := from + for curr < totalLen { + eol := findNewlineFrom(runes, curr) + if eol == -1 { + eol = totalLen + } + // Skip leading whitespace to find the start of the next line + nextStart := eol + for nextStart < totalLen && (runes[nextStart] == '\n' || runes[nextStart] == '\r') { + nextStart++ + } + if nextStart >= totalLen || runes[nextStart] != '|' { + return eol + } + curr = nextStart + } + return totalLen +} + +func findTableStartBefore(runes []rune, before, start int) int { + // Look for the first line that doesn't start with | backwards + curr := before + for curr > start { + sol := findLineStartBefore(runes, curr) + if sol < start { + return start + } + if runes[sol] != '|' { + return curr // The newline before a table row + } + curr = sol - 1 + } + return start +} diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 4c6bb41dd..126deebe2 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -33,6 +33,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -59,6 +60,7 @@ type WhatsAppNativeChannel struct { reconnecting bool stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect) + lastQR string // stores the last QR code string for retrieval } // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. @@ -187,6 +189,9 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } if evt.Event == "code" { logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) + c.mu.Lock() + c.lastQR = evt.Code + c.mu.Unlock() qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ Level: qrterminal.L, Writer: os.Stdout, @@ -352,12 +357,50 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { } content = utils.SanitizeMessageContent(content) - if content == "" { - return - } - var mediaPaths []string + // Handle media (Audio/Voice messages) + if evt.Message.AudioMessage != nil { + audio := evt.Message.AudioMessage + data, err := c.client.Download(audio) + if err != nil { + logger.WarnCF("whatsapp", "Failed to download audio", map[string]any{"error": err.Error()}) + } else { + ext := ".ogg" // Default for WhatsApp voice notes + if audio.GetMimetype() == "audio/mp4" { + ext = ".m4a" + } + filename := fmt.Sprintf("audio-%s%s", evt.Info.ID, ext) + tempPath := filepath.Join(os.TempDir(), filename) + if err := os.WriteFile(tempPath, data, 0o644); err != nil { + logger.WarnCF("whatsapp", "Failed to save audio file", map[string]any{"error": err.Error()}) + } else { + scope := channels.BuildMediaScope("whatsapp", chatID, evt.Info.ID) + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(tempPath, media.MediaMeta{ + Filename: filename, + ContentType: audio.GetMimetype(), + Source: "whatsapp", + }, scope) + if err == nil { + mediaPaths = append(mediaPaths, ref) + if content != "" { + content += "\n" + } + // Mark for transcription in AgentLoop + if audio.GetPtt() { + content += "[voice]" + } else { + content += "[audio]" + } + } else { + mediaPaths = append(mediaPaths, tempPath) + } + } + } + } + } + metadata := make(map[string]string) metadata["message_id"] = evt.Info.ID if evt.Info.PushName != "" { @@ -456,3 +499,69 @@ func parseJID(s string) (types.JID, error) { } return types.NewJID(clean, types.DefaultUserServer), nil } + +func (c *WhatsAppNativeChannel) GetLastQR() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.lastQR +} + +func (c *WhatsAppNativeChannel) FetchHistory(ctx context.Context, chatID string, limit int) ([]bus.InboundMessage, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + jid, err := parseJID(chatID) + if err != nil { + return nil, fmt.Errorf("invalid chat id %q: %w", chatID, err) + } + + c.mu.Lock() + client := c.client + c.mu.Unlock() + + if client == nil { + return nil, fmt.Errorf("whatsapp client not initialized") + } + + // Fetch messages from WhatsApp servers/local store + resp, err := client.FetchMessages(jid, limit, "", "") + if err != nil { + return nil, fmt.Errorf("whatsapp fetch history: %w", err) + } + + messages := make([]bus.InboundMessage, 0, len(resp.Messages)) + for _, m := range resp.Messages { + if m.Message == nil { + continue + } + + content := m.Message.GetConversation() + if content == "" && m.Message.ExtendedTextMessage != nil { + content = m.Message.ExtendedTextMessage.GetText() + } + if content == "" { + continue + } + + senderID := m.Info.Sender.String() + peerKind := "direct" + if m.Info.Chat.Server == types.GroupServer { + peerKind = "group" + } + + messages = append(messages, bus.InboundMessage{ + Channel: "whatsapp", + SenderID: senderID, + ChatID: m.Info.Chat.String(), + Content: content, + Peer: bus.Peer{ + Kind: peerKind, + ID: m.Info.Chat.String(), + }, + Timestamp: m.Info.Timestamp, + }) + } + + return messages, nil +} diff --git a/pkg/commands/cmd_acp.go b/pkg/commands/cmd_acp.go new file mode 100644 index 000000000..22657dd39 --- /dev/null +++ b/pkg/commands/cmd_acp.go @@ -0,0 +1,63 @@ +package commands + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/acp" +) + +func acpCommand() Definition { + return Definition{ + Name: "acp", + Description: "Agent Client Protocol capabilities (e.g. acp spawn, status, close)", + Usage: "/acp [args...]", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + parts := strings.Fields(req.Text) + if len(parts) < 2 { + return req.Reply("Usage: /acp [args...]") + } + + action := parts[1] + switch action { + case "spawn": + if len(parts) < 3 { + return req.Reply("usage: /acp spawn ") + } + harness := parts[2] + session, err := acp.GetManager().Spawn(harness, "session", harness, "", "cli-spawned", []string{}) + if err != nil { + return req.Reply(fmt.Sprintf("failed to spawn ACP session: %v", err)) + } + return req.Reply(fmt.Sprintf("βœ“ Spawned ACP harness '%s'. Session Key: %s", harness, session.Key)) + + case "status": + sessions := acp.GetManager().ListSessions() + if len(sessions) == 0 { + return req.Reply("No active ACP sessions.") + } + var b strings.Builder + b.WriteString("Active ACP Sessions:\n") + for _, s := range sessions { + b.WriteString(fmt.Sprintf("- Key: %s | Harness: %s | Status: %s\n", s.Key, s.AgentID, s.Status())) + } + return req.Reply(b.String()) + + case "close": + if len(parts) < 3 { + return req.Reply("usage: /acp close ") + } + key := parts[2] + err := acp.GetManager().CloseSession(key) + if err != nil { + return req.Reply(fmt.Sprintf("failed to close session: %v", err)) + } + return req.Reply(fmt.Sprintf("βœ“ Closed ACP session %s", key)) + + default: + return req.Reply(fmt.Sprintf("unknown acp action: %s", action)) + } + }, + } +} diff --git a/pkg/commands/cmd_google.go b/pkg/commands/cmd_google.go new file mode 100644 index 000000000..9a81b89ef --- /dev/null +++ b/pkg/commands/cmd_google.go @@ -0,0 +1,181 @@ +package commands + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" +) + +// gloginCommand starts a Google OAuth flow and saves the credential. +func gloginCommand() Definition { + return Definition{ + Name: "glogin", + Description: "Authenticate with Google (GWS / Cloud). Opens browser for OAuth.", + Usage: "/glogin [antigravity|gemini]", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + provider := strings.ToLower(nthToken(req.Text, 1)) + + var cfg auth.OAuthProviderConfig + var providerKey string + switch provider { + case "gemini", "gcloud", "cloud": + cfg = auth.GeminiCLIOAuthConfig() + providerKey = "google-gemini" + default: + // Default: Antigravity / full GWS scopes (Gmail, Drive, Calendar, etc.) + cfg = auth.GoogleAntigravityOAuthConfig() + providerKey = "google-antigravity" + } + + _ = req.Reply(fmt.Sprintf( + "πŸ” Starting Google OAuth for *%s*...\nA browser window will open. Complete sign-in, then come back here.", + providerKey, + )) + + cred, err := auth.LoginBrowser(cfg) + if err != nil { + return req.Reply(fmt.Sprintf("❌ Google login failed: %v", err)) + } + + if err := auth.SetCredential(providerKey, cred); err != nil { + return req.Reply(fmt.Sprintf("❌ Failed to save credentials: %v", err)) + } + + email := cred.Email + if email == "" { + email = "(email not in token)" + } + return req.Reply(fmt.Sprintf("βœ… Google login successful!\nProvider: %s\nAccount: %s", providerKey, email)) + }, + } +} + +// gstatusCommand shows the current Google auth status. +func gstatusCommand() Definition { + return Definition{ + Name: "gstatus", + Description: "Show current Google authentication status", + Usage: "/gstatus", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + providers := []string{"google-antigravity", "google-gemini"} + var sb strings.Builder + sb.WriteString("πŸ”‘ *Google Auth Status*\n\n") + + anyFound := false + for _, p := range providers { + cred, err := auth.GetCredential(p) + if err != nil || cred == nil { + continue + } + anyFound = true + + status := "βœ… Active" + if cred.IsExpired() { + status = "⚠️ Expired" + } else if cred.NeedsRefresh() { + status = "πŸ”„ Needs refresh soon" + } + + sb.WriteString(fmt.Sprintf("*%s*\n", p)) + sb.WriteString(fmt.Sprintf(" Status: %s\n", status)) + if cred.Email != "" { + sb.WriteString(fmt.Sprintf(" Account: %s\n", cred.Email)) + } + if cred.ProjectID != "" { + sb.WriteString(fmt.Sprintf(" Project: %s\n", cred.ProjectID)) + } + if !cred.ExpiresAt.IsZero() { + remaining := time.Until(cred.ExpiresAt).Round(time.Minute) + sb.WriteString(fmt.Sprintf(" Expires in: %s\n", remaining)) + } + sb.WriteString("\n") + } + + if !anyFound { + sb.WriteString("No Google credentials found.\nUse /glogin to authenticate.") + } + + return req.Reply(sb.String()) + }, + } +} + +// glogoutCommand removes stored Google credentials. +func glogoutCommand() Definition { + return Definition{ + Name: "glogout", + Description: "Remove stored Google credentials", + Usage: "/glogout [antigravity|gemini|all]", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + which := strings.ToLower(nthToken(req.Text, 1)) + + switch which { + case "gemini", "cloud": + if err := auth.DeleteCredential("google-gemini"); err != nil { + return req.Reply(fmt.Sprintf("❌ Failed to remove google-gemini credentials: %v", err)) + } + return req.Reply("βœ… Removed google-gemini credentials.") + + case "all": + errs := []string{} + for _, p := range []string{"google-antigravity", "google-gemini"} { + if err := auth.DeleteCredential(p); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", p, err)) + } + } + if len(errs) > 0 { + return req.Reply("⚠️ Some removals failed:\n" + strings.Join(errs, "\n")) + } + return req.Reply("βœ… All Google credentials removed.") + + default: + // Default: antigravity + if err := auth.DeleteCredential("google-antigravity"); err != nil { + return req.Reply(fmt.Sprintf("❌ Failed to remove google-antigravity credentials: %v", err)) + } + return req.Reply("βœ… Removed google-antigravity credentials.") + } + }, + } +} + +// gprojectCommand sets the active GCP project ID on the stored credential. +func gprojectCommand() Definition { + return Definition{ + Name: "gproject", + Description: "Set the active GCP project ID for Google cloud operations", + Usage: "/gproject ", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + projectID := nthToken(req.Text, 1) + if projectID == "" { + // Show current + cred, err := auth.GetCredential("google-antigravity") + if err != nil || cred == nil { + return req.Reply("No Google credentials found. Use /glogin first.") + } + if cred.ProjectID == "" { + return req.Reply("No GCP project set. Use: /gproject ") + } + return req.Reply(fmt.Sprintf("Current GCP project: `%s`", cred.ProjectID)) + } + + cred, err := auth.GetCredential("google-antigravity") + if err != nil { + return req.Reply(fmt.Sprintf("❌ Failed to load credentials: %v", err)) + } + if cred == nil { + return req.Reply("No Google credentials found. Use /glogin first.") + } + + cred.ProjectID = projectID + if err := auth.SetCredential("google-antigravity", cred); err != nil { + return req.Reply(fmt.Sprintf("❌ Failed to save project: %v", err)) + } + + return req.Reply(fmt.Sprintf("βœ… GCP project set to: `%s`", projectID)) + }, + } +} diff --git a/pkg/commands/cmd_gws.go b/pkg/commands/cmd_gws.go new file mode 100644 index 000000000..74b14d5e0 --- /dev/null +++ b/pkg/commands/cmd_gws.go @@ -0,0 +1,372 @@ +package commands + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/gws" +) + +// ── /gmail ─────────────────────────────────────────────────────────────────── + +func gmailCommand() Definition { + return Definition{ + Name: "gmail", + Description: "Gmail: list, search, or read emails", + Usage: "/gmail [list|search |read |help]", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + action := strings.ToLower(nthToken(req.Text, 1)) + + if action == "" || action == "help" { + return req.Reply( + "πŸ“§ *Gmail Commands*\n\n" + + "β€’ `/gmail list` β€” last 10 inbox messages\n" + + "β€’ `/gmail search ` β€” search by subject/sender/content\n" + + "β€’ `/gmail unread` β€” unread messages only\n" + + "β€’ `/gmail read ` β€” open a specific message\n", + ) + } + + c, err := gws.New() + if err != nil { + return req.Reply("❌ " + err.Error()) + } + + switch action { + case "list": + msgs, err := c.GmailList("in:inbox", 10) + if err != nil { + return req.Reply("❌ Gmail list failed: " + err.Error()) + } + return req.Reply(formatGmailList(msgs, "Inbox")) + + case "unread": + msgs, err := c.GmailList("is:unread in:inbox", 10) + if err != nil { + return req.Reply("❌ Gmail unread failed: " + err.Error()) + } + return req.Reply(formatGmailList(msgs, "Unread")) + + case "search": + query := strings.Join(tailTokens(req.Text, 2), " ") + if query == "" { + return req.Reply("Usage: /gmail search ") + } + msgs, err := c.GmailList(query, 10) + if err != nil { + return req.Reply("❌ Gmail search failed: " + err.Error()) + } + return req.Reply(formatGmailList(msgs, "Search: "+query)) + + case "read": + msgID := nthToken(req.Text, 2) + if msgID == "" { + return req.Reply("Usage: /gmail read ") + } + msg, err := c.GmailRead(msgID) + if err != nil { + return req.Reply("❌ Gmail read failed: " + err.Error()) + } + from := gws.HeaderValue(*msg, "From") + subject := gws.HeaderValue(*msg, "Subject") + date := gws.HeaderValue(*msg, "Date") + return req.Reply(fmt.Sprintf( + "πŸ“§ *%s*\nFrom: %s\nDate: %s\n\n%s", + subject, from, date, truncateStr(msg.Snippet, 500), + )) + + default: + return req.Reply(fmt.Sprintf("Unknown gmail action: %s\nUse /gmail help.", action)) + } + }, + } +} + +func formatGmailList(msgs []gws.GmailMessage, title string) string { + if len(msgs) == 0 { + return fmt.Sprintf("πŸ“§ *%s*\n\nNo messages found.", title) + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("πŸ“§ *%s* (%d)\n\n", title, len(msgs))) + for _, m := range msgs { + subject := gws.HeaderValue(m, "Subject") + from := gws.HeaderValue(m, "From") + if subject == "" { + subject = "(no subject)" + } + sb.WriteString(fmt.Sprintf("β€’ [%s] %s\n `%s`\n", from, subject, m.ID)) + } + return sb.String() +} + +// ── /drive ─────────────────────────────────────────────────────────────────── + +func driveCommand() Definition { + return Definition{ + Name: "drive", + Description: "Google Drive: list or search files", + Usage: "/drive [list|search |docs|sheets|help]", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + action := strings.ToLower(nthToken(req.Text, 1)) + + if action == "" || action == "help" { + return req.Reply( + "πŸ’Ύ *Drive Commands*\n\n" + + "β€’ `/drive list` β€” recent files\n" + + "β€’ `/drive search ` β€” search by name\n" + + "β€’ `/drive docs` β€” recent Google Docs\n" + + "β€’ `/drive sheets` β€” recent Sheets\n", + ) + } + + c, err := gws.New() + if err != nil { + return req.Reply("❌ " + err.Error()) + } + + switch action { + case "list": + files, err := c.DriveList("", "", 10) + if err != nil { + return req.Reply("❌ Drive list failed: " + err.Error()) + } + return req.Reply(formatDriveList(files, "Recent Files")) + + case "search": + query := strings.Join(tailTokens(req.Text, 2), " ") + if query == "" { + return req.Reply("Usage: /drive search ") + } + files, err := c.DriveList(query, "", 10) + if err != nil { + return req.Reply("❌ Drive search failed: " + err.Error()) + } + return req.Reply(formatDriveList(files, "Search: "+query)) + + case "docs": + files, err := c.DriveList("", "application/vnd.google-apps.document", 10) + if err != nil { + return req.Reply("❌ Drive docs failed: " + err.Error()) + } + return req.Reply(formatDriveList(files, "Recent Docs")) + + case "sheets": + files, err := c.DriveList("", "application/vnd.google-apps.spreadsheet", 10) + if err != nil { + return req.Reply("❌ Drive sheets failed: " + err.Error()) + } + return req.Reply(formatDriveList(files, "Recent Sheets")) + + default: + return req.Reply(fmt.Sprintf("Unknown drive action: %s\nUse /drive help.", action)) + } + }, + } +} + +func formatDriveList(files []gws.DriveFile, title string) string { + if len(files) == 0 { + return fmt.Sprintf("πŸ’Ύ *%s*\n\nNo files found.", title) + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("πŸ’Ύ *%s* (%d)\n\n", title, len(files))) + for _, f := range files { + label := gws.MimeTypeLabel(f.MimeType) + sb.WriteString(fmt.Sprintf("β€’ [%s] %s\n `%s`\n", label, f.Name, f.ID)) + } + return sb.String() +} + +// ── /docs ──────────────────────────────────────────────────────────────────── + +func docsCommand() Definition { + return Definition{ + Name: "docs", + Description: "Google Docs: create or open a document", + Usage: "/docs [create |open <id>|help]", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + action := strings.ToLower(nthToken(req.Text, 1)) + + if action == "" || action == "help" { + return req.Reply( + "πŸ“„ *Docs Commands*\n\n" + + "β€’ `/docs create <title>` β€” create a new Google Doc\n" + + "β€’ `/docs open <document-id>` β€” get document info\n" + + "β€’ `/docs list` β€” recent docs (via Drive)\n", + ) + } + + c, err := gws.New() + if err != nil { + return req.Reply("❌ " + err.Error()) + } + + switch action { + case "create": + title := strings.Join(tailTokens(req.Text, 2), " ") + if title == "" { + title = fmt.Sprintf("Document %s", time.Now().Format("2006-01-02")) + } + doc, err := c.DocsCreate(title) + if err != nil { + return req.Reply("❌ Docs create failed: " + err.Error()) + } + return req.Reply(fmt.Sprintf( + "πŸ“„ *Document created!*\nTitle: %s\nID: `%s`\nOpen: https://docs.google.com/document/d/%s/edit", + doc.Title, doc.DocumentID, doc.DocumentID, + )) + + case "open": + docID := nthToken(req.Text, 2) + if docID == "" { + return req.Reply("Usage: /docs open <document-id>") + } + doc, err := c.DocsGet(docID) + if err != nil { + return req.Reply("❌ Docs open failed: " + err.Error()) + } + return req.Reply(fmt.Sprintf( + "πŸ“„ *%s*\nID: `%s`\nRevision: %s\nOpen: https://docs.google.com/document/d/%s/edit", + doc.Title, doc.DocumentID, doc.RevisionID, doc.DocumentID, + )) + + case "list": + files, err := c.DriveList("", "application/vnd.google-apps.document", 10) + if err != nil { + return req.Reply("❌ Docs list failed: " + err.Error()) + } + return req.Reply(formatDriveList(files, "Recent Docs")) + + default: + return req.Reply(fmt.Sprintf("Unknown docs action: %s\nUse /docs help.", action)) + } + }, + } +} + +// ── /cal ───────────────────────────────────────────────────────────────────── + +func calCommand() Definition { + return Definition{ + Name: "cal", + Description: "Google Calendar: list upcoming events", + Usage: "/cal [today|week|month|help]", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + action := strings.ToLower(nthToken(req.Text, 1)) + + if action == "help" { + return req.Reply( + "πŸ“… *Calendar Commands*\n\n" + + "β€’ `/cal` or `/cal today` β€” events today\n" + + "β€’ `/cal week` β€” next 7 days\n" + + "β€’ `/cal month` β€” next 30 days\n", + ) + } + + c, err := gws.New() + if err != nil { + return req.Reply("❌ " + err.Error()) + } + + now := time.Now() + var timeMax time.Time + var label string + + switch action { + case "week": + timeMax = now.Add(7 * 24 * time.Hour) + label = "Next 7 Days" + case "month": + timeMax = now.Add(30 * 24 * time.Hour) + label = "Next 30 Days" + default: // today or empty + timeMax = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + label = "Today" + } + + events, err := c.CalendarList(now, timeMax, 15) + if err != nil { + return req.Reply("❌ Calendar failed: " + err.Error()) + } + + if len(events) == 0 { + return req.Reply(fmt.Sprintf("πŸ“… *%s* β€” No events.", label)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("πŸ“… *%s* (%d events)\n\n", label, len(events))) + for _, ev := range events { + t := gws.FormatEventTime(ev) + sb.WriteString(fmt.Sprintf("β€’ %s β€” %s\n", t, ev.Summary)) + } + return req.Reply(sb.String()) + }, + } +} + +// ── /sheets ────────────────────────────────────────────────────────────────── + +func sheetsCommand() Definition { + return Definition{ + Name: "sheets", + Description: "Google Sheets: list or search spreadsheets", + Usage: "/sheets [list|search <query>|help]", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + action := strings.ToLower(nthToken(req.Text, 1)) + + if action == "help" { + return req.Reply( + "πŸ“Š *Sheets Commands*\n\n" + + "β€’ `/sheets list` β€” recent spreadsheets\n" + + "β€’ `/sheets search <query>` β€” search by name\n", + ) + } + + c, err := gws.New() + if err != nil { + return req.Reply("❌ " + err.Error()) + } + + const sheetMime = "application/vnd.google-apps.spreadsheet" + switch action { + case "search": + query := strings.Join(tailTokens(req.Text, 2), " ") + if query == "" { + return req.Reply("Usage: /sheets search <query>") + } + files, err := c.DriveList(query, sheetMime, 10) + if err != nil { + return req.Reply("❌ Sheets search failed: " + err.Error()) + } + return req.Reply(formatDriveList(files, "Sheets: "+query)) + + default: // list + files, err := c.DriveList("", sheetMime, 10) + if err != nil { + return req.Reply("❌ Sheets list failed: " + err.Error()) + } + return req.Reply(formatDriveList(files, "Recent Sheets")) + } + }, + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +// tailTokens returns all tokens starting at index n (0-indexed), joined as-is. +func tailTokens(text string, n int) []string { + parts := strings.Fields(strings.TrimSpace(text)) + if n >= len(parts) { + return nil + } + return parts[n:] +} + +func truncateStr(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go index bf47b6e9c..5320d89f8 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -6,47 +6,51 @@ import ( "strings" ) +// listCommand is kept as a no-op stub for backward compat. +// All functionality moved to /models and /channels. func listCommand() Definition { return Definition{ Name: "list", - Description: "List available options", - SubCommands: []SubCommand{ - { - Name: "models", - Description: "Configured models", - Handler: func(_ context.Context, req Request, rt *Runtime) error { - if rt == nil || rt.GetModelInfo == nil { - return req.Reply(unavailableMsg) - } - name, provider := rt.GetModelInfo() - if provider == "" { - provider = "configured default" - } - return req.Reply(fmt.Sprintf( - "Configured Model: %s\nProvider: %s\n\nTo change models, update config.json", - name, provider, - )) - }, - }, - { - Name: "channels", - Description: "Enabled channels", - Handler: func(_ context.Context, req Request, rt *Runtime) error { - if rt == nil || rt.GetEnabledChannels == nil { - return req.Reply(unavailableMsg) - } - enabled := rt.GetEnabledChannels() - if len(enabled) == 0 { - return req.Reply("No channels enabled") - } - return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))) - }, - }, - { - Name: "agents", - Description: "Registered agents", - Handler: agentsHandler(), - }, + Description: "Alias: use /models or /channels", + Usage: "/list", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply("Use /models or /channels instead.") + }, + } +} + +func modelsCommand() Definition { + return Definition{ + Name: "models", + Description: "Show the currently configured model", + Usage: "/models", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetModelInfo == nil { + return req.Reply(unavailableMsg) + } + name, provider := rt.GetModelInfo() + if provider == "" { + provider = "configured default" + } + return req.Reply(fmt.Sprintf("Model: %s\nProvider: %s", name, provider)) + }, + } +} + +func channelsCommand() Definition { + return Definition{ + Name: "channels", + Description: "List enabled channels", + Usage: "/channels", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetEnabledChannels == nil { + return req.Reply(unavailableMsg) + } + enabled := rt.GetEnabledChannels() + if len(enabled) == 0 { + return req.Reply("No channels enabled.") + } + return req.Reply("Enabled channels:\n- " + strings.Join(enabled, "\n- ")) }, } } diff --git a/pkg/commands/cmd_system.go b/pkg/commands/cmd_system.go new file mode 100644 index 000000000..05933cfc8 --- /dev/null +++ b/pkg/commands/cmd_system.go @@ -0,0 +1,116 @@ +package commands + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/auth" +) + +func versionCommand() Definition { + return Definition{ + Name: "version", + Description: "Show version info", + Usage: "/version", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetVersion == nil { + return req.Reply("Version info unavailable") + } + return req.Reply(fmt.Sprintf("PicoClaw version %s", rt.GetVersion())) + }, + } +} + +func pingCommand() Definition { + return Definition{ + Name: "ping", + Description: "Connectivity check", + Usage: "/ping", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply("pong") + }, + } +} + +func toolsCommand() Definition { + return Definition{ + Name: "tools", + Description: "List available tools", + Usage: "/tools", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListTools == nil { + return req.Reply("Tools list unavailable") + } + toolsList := rt.ListTools() + if len(toolsList) == 0 { + return req.Reply("No tools available.") + } + return req.Reply("Available tools:\n" + strings.Join(toolsList, "\n")) + }, + } +} + +func modelCommand() Definition { + return Definition{ + Name: "model", + Description: "Show or switch the active model", + Usage: "/model [name]", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetModelInfo == nil { + return req.Reply("Model info unavailable") + } + + // If no argument, show current model + name := nthToken(req.Text, 1) + if name == "" { + m, p := rt.GetModelInfo() + return req.Reply(fmt.Sprintf("Current model: %s (Provider: %s)", m, p)) + } + + // If argument, try to switch + if rt.SwitchModel == nil { + return req.Reply("Model switching unavailable") + } + oldModel, err := rt.SwitchModel(name) + if err != nil { + return req.Reply(fmt.Sprintf("Failed to switch model: %v", err)) + } + return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, name)) + }, + } +} + +func vpsCommand() Definition { + return Definition{ + Name: "vps", + Description: "Alias: use /vpslogin <password>", + Usage: "/vps", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply("Use /vpslogin <password> to set VPS credentials.") + }, + } +} + +func vpsloginCommand() Definition { + return Definition{ + Name: "vpslogin", + Description: "Set VPS password securely", + Usage: "/vpslogin <password>", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + password := nthToken(req.Text, 1) + if password == "" { + return req.Reply("Usage: /vpslogin <password>") + } + cred := &auth.AuthCredential{ + AccessToken: password, + Provider: "vps", + AuthMethod: "password", + } + if err := auth.SetCredential("vps", cred); err != nil { + return req.Reply(fmt.Sprintf("Failed to save VPS credentials: %v", err)) + } + return req.Reply("VPS credentials saved securely.") + }, + } +} diff --git a/pkg/commands/cmd_whatsapp.go b/pkg/commands/cmd_whatsapp.go new file mode 100644 index 000000000..407b91ca2 --- /dev/null +++ b/pkg/commands/cmd_whatsapp.go @@ -0,0 +1,48 @@ +package commands + +import ( + "context" + "fmt" +) + +func qrCommand() Definition { + return Definition{ + Name: "qr", + Description: "Get the WhatsApp pairing QR code", + Usage: "/qr", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + ch, ok := rt.GetChannel("whatsapp_native") + if !ok { + return req.Reply("whatsapp_native channel is not enabled.") + } + + type qrProvider interface { + GetLastQR() string + } + + qp, ok := ch.(qrProvider) + if !ok { + return req.Reply("whatsapp_native channel does not support QR retrieval.") + } + + qr := qp.GetLastQR() + if qr == "" { + return req.Reply("No QR code available yet. Wait for the channel to initialize.") + } + + return req.Reply(fmt.Sprintf("Scan this QR code: %s", qr)) + }, + } +} + +// whatsappCommand kept as a stub for backward compat. +func whatsappCommand() Definition { + return Definition{ + Name: "whatsapp", + Description: "Alias: use /qr", + Usage: "/whatsapp", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply("Use /qr to get the WhatsApp pairing QR code.") + }, + } +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 64d2162de..c049840bf 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -28,4 +28,7 @@ type Runtime struct { ExecuteShell func(ctx context.Context, command string) (string, error) GetRecentPreviews func() []PreviewInfo ClearHistory func() error + GetChannel func(name string) (any, bool) + GetVersion func() string + ListTools func() []string } diff --git a/pkg/config/config.go b/pkg/config/config.go index 10c4f4c81..108efc416 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/go-playground/validator/v10" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -130,11 +131,11 @@ func (m AgentModelConfig) MarshalJSON() ([]byte, error) { } type AgentConfig struct { - ID string `json:"id"` + ID string `json:"id" validate:"required"` Default bool `json:"default,omitempty"` Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` - Model *AgentModelConfig `json:"model,omitempty"` + Model *AgentModelConfig `json:"model,omitempty" validate:"required"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } @@ -189,9 +190,9 @@ type AgentDefaults struct { ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS" validate:"gt=0"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE" validate:"omitempty,gte=0,lte=2"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS" validate:"gte=1,lte=100"` SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` @@ -579,10 +580,23 @@ type GatewayConfig struct { Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } +type ToolDiscoveryConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` + TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` + MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"` + UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"` + UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"` +} + type ToolConfig struct { Enabled bool `json:"enabled" env:"ENABLED"` } +type ReadFileToolConfig struct { + Enabled bool + MaxReadFileSize int +} + type BraveConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` @@ -596,6 +610,18 @@ type TavilyConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } +type ElevenLabsConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_VOICE_ELEVENLABS_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_VOICE_ELEVENLABS_API_KEY"` + VoiceID string `json:"voice_id" env:"PICOCLAW_TOOLS_VOICE_ELEVENLABS_VOICE_ID"` +} + +type InteractionConfig struct { + WritingStyle string `json:"writing_style" env:"PICOCLAW_INTERACTION_WRITING_STYLE"` + AutoReplyEnabled bool `json:"autoreply_enabled" env:"PICOCLAW_INTERACTION_AUTOREPLY_ENABLED"` + ApprovalRequired bool `json:"approval_required" env:"PICOCLAW_INTERACTION_APPROVAL_REQUIRED"` +} + type DuckDuckGoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` @@ -650,6 +676,12 @@ type ExecConfig struct { TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) } +type VPSConfig struct { + ToolConfig `envPrefix:"PICOCLAW_TOOLS_VPS_"` + Host string `json:"host" env:"PICOCLAW_TOOLS_VPS_HOST"` + User string `json:"user" env:"PICOCLAW_TOOLS_VPS_USER"` +} + type SkillsToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig ` json:"registries"` @@ -663,6 +695,12 @@ type MediaCleanupConfig struct { Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` } +type ProactiveConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_PROACTIVE_ENABLED"` + SyncIntervalMinutes int `json:"sync_interval_minutes" env:"PICOCLAW_PROACTIVE_SYNC_INTERVAL"` + ProcessIntervalMinutes int `json:"process_interval_minutes" env:"PICOCLAW_PROACTIVE_PROCESS_INTERVAL"` +} + type ToolsConfig struct { AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` @@ -676,16 +714,25 @@ type ToolsConfig struct { EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` + Browser ToolConfig `json:"browser" envPrefix:"PICOCLAW_TOOLS_BROWSER_"` + Image ToolConfig `json:"image" envPrefix:"PICOCLAW_TOOLS_IMAGE_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + Google ToolConfig `json:"google" envPrefix:"PICOCLAW_TOOLS_GOOGLE_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` - ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + PDF ToolConfig `json:"pdf" envPrefix:"PICOCLAW_TOOLS_PDF_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + VPS VPSConfig `json:"vps" envPrefix:"PICOCLAW_TOOLS_VPS_"` + VoiceCall ToolConfig `json:"voice_call" envPrefix:"PICOCLAW_TOOLS_VOICECALL_"` WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` + Proactive ProactiveConfig `json:"proactive"` + ElevenLabs ElevenLabsConfig `json:"elevenlabs"` + Interaction InteractionConfig `json:"interaction"` } type SearchCacheConfig struct { @@ -731,7 +778,8 @@ type MCPServerConfig struct { // MCPConfig defines configuration for all MCP servers type MCPConfig struct { - ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` + Discovery ToolDiscoveryConfig ` json:"discovery"` // Servers is a map of server name to server configuration Servers map[string]MCPServerConfig `json:"servers,omitempty"` } @@ -769,6 +817,11 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + validate := validator.New() + if err := validate.Struct(cfg); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() @@ -938,14 +991,24 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.FindSkills.Enabled case "i2c": return t.I2C.Enabled + case "browser": + return t.Browser.Enabled + case "image": + return t.Image.Enabled case "install_skill": return t.InstallSkill.Enabled + case "google": + return t.Google.Enabled + case "vps": + return t.VPS.Enabled case "list_dir": return t.ListDir.Enabled case "message": return t.Message.Enabled case "read_file": return t.ReadFile.Enabled + case "pdf": + return t.PDF.Enabled case "spawn": return t.Spawn.Enabled case "spi": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index ad2c596ff..e30f9a7ba 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -29,12 +29,27 @@ func DefaultConfig() *Config { Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", - Model: "", + ModelName: "openrouter-free", + Model: "openrouter/free", MaxTokens: 32768, Temperature: nil, // nil means use provider default - MaxToolIterations: 50, + MaxToolIterations: 10, SummarizeMessageThreshold: 20, - SummarizeTokenPercent: 75, + }, + List: []AgentConfig{ + { + ID: "video_processor", + Name: "Video Processing Agent", + Model: &AgentModelConfig{ + Primary: "gemini-flash", + }, + Subagents: &SubagentsConfig{ + Model: &AgentModelConfig{ + Primary: "gemini-flash", + }, + }, + Skills: []string{"video_editor"}, + }, }, }, Bindings: []AgentBinding{}, @@ -248,6 +263,12 @@ func DefaultConfig() *Config { }, // OpenRouter (100+ models) - https://openrouter.ai/keys + { + ModelName: "openrouter-free", + Model: "openrouter/free", + APIBase: "https://openrouter.ai/api/v1", + APIKey: "", + }, { ModelName: "openrouter-auto", Model: "openrouter/auto", @@ -443,6 +464,13 @@ func DefaultConfig() *Config { ToolConfig: ToolConfig{ Enabled: false, }, + Discovery: ToolDiscoveryConfig{ + Enabled: false, + TTL: 5, + MaxSearchResults: 5, + UseBM25: true, + UseRegex: false, + }, Servers: map[string]MCPServerConfig{}, }, AppendFile: ToolConfig{ @@ -460,15 +488,28 @@ func DefaultConfig() *Config { InstallSkill: ToolConfig{ Enabled: true, }, + Google: ToolConfig{ + Enabled: true, + }, ListDir: ToolConfig{ Enabled: true, }, Message: ToolConfig{ Enabled: true, }, - ReadFile: ToolConfig{ + PDF: ToolConfig{ Enabled: true, }, + Browser: ToolConfig{ + Enabled: false, // Requires Chrome/Chromium installed + }, + Image: ToolConfig{ + Enabled: true, + }, + ReadFile: ReadFileToolConfig{ + Enabled: true, + MaxReadFileSize: 64 * 1024, // 64KB + }, Spawn: ToolConfig{ Enabled: true, }, @@ -481,9 +522,27 @@ func DefaultConfig() *Config { WebFetch: ToolConfig{ Enabled: true, }, + VPS: VPSConfig{ + ToolConfig: ToolConfig{Enabled: true}, + Host: "187.77.75.173", + User: "root", + }, WriteFile: ToolConfig{ Enabled: true, }, + Proactive: ProactiveConfig{ + Enabled: true, + SyncIntervalMinutes: 60, + ProcessIntervalMinutes: 120, + }, + ElevenLabs: ElevenLabsConfig{ + Enabled: false, + }, + Interaction: InteractionConfig{ + WritingStyle: "Casual and helpful", + AutoReplyEnabled: false, + ApprovalRequired: true, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 51f21e4f4..c515478c2 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -14,11 +14,32 @@ import ( // If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is. // Otherwise, the protocol prefix is added. func buildModelWithProtocol(protocol, model string) string { - if strings.Contains(model, "/") { - // Model already has a protocol prefix, return as-is + if model == "" { + return "" + } + + // If the model already starts with the protocol prefix, return as-is + prefix := protocol + "/" + if strings.HasPrefix(model, prefix) { return model } - return protocol + "/" + model + + // Handle known nested prefixes for specific providers + // Example: provider 'nvidia' and model 'meta/llama-3.1' -> 'nvidia/meta/llama-3.1' + if protocol == "nvidia" && (strings.HasPrefix(model, "meta/") || strings.HasPrefix(model, "nvidia/")) { + if strings.HasPrefix(model, "nvidia/") { + return model // Already correctly prefixed + } + return prefix + model + } + + // If the model already has some other protocol prefix (contains "/"), + // we assume it's intentional and return it as-is. + if strings.Contains(model, "/") { + return model + } + + return prefix + model } // providerMigrationConfig defines how to migrate a provider from old config to new format. diff --git a/pkg/gws/client.go b/pkg/gws/client.go new file mode 100644 index 000000000..53d031ba3 --- /dev/null +++ b/pkg/gws/client.go @@ -0,0 +1,327 @@ +// Package gws provides a lightweight Google Workspace REST client. +// It uses stored OAuth credentials (google-antigravity) and makes +// direct HTTP calls to Google APIs without requiring the full Google SDK. +package gws + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" +) + +const ( + gmailBase = "https://gmail.googleapis.com/gmail/v1/users/me" + driveBase = "https://www.googleapis.com/drive/v3" + calBase = "https://www.googleapis.com/calendar/v3" + docsBase = "https://docs.googleapis.com/v1" + sheetsBase = "https://sheets.googleapis.com/v4" + providerKey = "google-antigravity" +) + +// Client is a thin GWS REST client backed by a stored OAuth token. +type Client struct { + http *http.Client + token string +} + +// New creates a GWS client from stored credentials. Returns an error if not authenticated. +func New() (*Client, error) { + cred, err := auth.GetCredential(providerKey) + if err != nil { + return nil, fmt.Errorf("failed to load Google credentials: %w", err) + } + if cred == nil { + return nil, fmt.Errorf("not authenticated. Use /glogin first") + } + if cred.IsExpired() { + // Attempt token refresh + cfg := auth.GoogleAntigravityOAuthConfig() + refreshed, err := auth.RefreshAccessToken(cred, cfg) + if err != nil { + return nil, fmt.Errorf("token expired and refresh failed: %w. Use /glogin to re-authenticate", err) + } + if err := auth.SetCredential(providerKey, refreshed); err != nil { + return nil, fmt.Errorf("failed to save refreshed token: %w", err) + } + cred = refreshed + } + return &Client{ + http: &http.Client{Timeout: 15 * time.Second}, + token: cred.AccessToken, + }, nil +} + +// get performs an authenticated GET request and decodes JSON into dest. +func (c *Client) get(rawURL string, dest any) error { + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + if dest != nil { + if err := json.Unmarshal(body, dest); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + } + return nil +} + +// post performs an authenticated POST request with a JSON body. +func (c *Client) post(rawURL string, body any, dest any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest("POST", rawURL, strings.NewReader(string(b))) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(respBody), 200)) + } + if dest != nil { + if err := json.Unmarshal(respBody, dest); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + } + return nil +} + +// ── Gmail ──────────────────────────────────────────────────────────────────── + +type GmailMessage struct { + ID string `json:"id"` + Snippet string `json:"snippet"` + Payload struct { + Headers []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"headers"` + } `json:"payload"` +} + +type GmailListResponse struct { + Messages []struct{ ID string `json:"id"` } `json:"messages"` + ResultSizeEstimate int `json:"resultSizeEstimate"` +} + +func (c *Client) GmailList(query string, maxResults int) ([]GmailMessage, error) { + if maxResults <= 0 { + maxResults = 10 + } + params := url.Values{ + "maxResults": {fmt.Sprintf("%d", maxResults)}, + } + if query != "" { + params.Set("q", query) + } + var listResp GmailListResponse + if err := c.get(gmailBase+"/messages?"+params.Encode(), &listResp); err != nil { + return nil, err + } + + var messages []GmailMessage + for i, m := range listResp.Messages { + if i >= maxResults { + break + } + var msg GmailMessage + if err := c.get(fmt.Sprintf("%s/messages/%s?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date", gmailBase, m.ID), &msg); err != nil { + continue + } + messages = append(messages, msg) + } + return messages, nil +} + +func (c *Client) GmailRead(msgID string) (*GmailMessage, error) { + var msg GmailMessage + if err := c.get(fmt.Sprintf("%s/messages/%s?format=full", gmailBase, msgID), &msg); err != nil { + return nil, err + } + return &msg, nil +} + +// ── Drive ──────────────────────────────────────────────────────────────────── + +type DriveFile struct { + ID string `json:"id"` + Name string `json:"name"` + MimeType string `json:"mimeType"` + ModifiedTime string `json:"modifiedTime"` + WebViewLink string `json:"webViewLink"` +} + +type DriveListResponse struct { + Files []DriveFile `json:"files"` + NextPageToken string `json:"nextPageToken"` +} + +func (c *Client) DriveList(query string, mimeFilter string, maxResults int) ([]DriveFile, error) { + if maxResults <= 0 { + maxResults = 10 + } + q := "trashed=false" + if query != "" { + q += fmt.Sprintf(" and name contains '%s'", strings.ReplaceAll(query, "'", "\\'")) + } + if mimeFilter != "" { + q += fmt.Sprintf(" and mimeType='%s'", mimeFilter) + } + params := url.Values{ + "q": {q}, + "pageSize": {fmt.Sprintf("%d", maxResults)}, + "fields": {"files(id,name,mimeType,modifiedTime,webViewLink)"}, + "orderBy": {"modifiedTime desc"}, + } + var resp DriveListResponse + if err := c.get(driveBase+"/files?"+params.Encode(), &resp); err != nil { + return nil, err + } + return resp.Files, nil +} + +// ── Calendar ───────────────────────────────────────────────────────────────── + +type CalendarEvent struct { + ID string `json:"id"` + Summary string `json:"summary"` + Start struct { + DateTime string `json:"dateTime"` + Date string `json:"date"` + } `json:"start"` + End struct { + DateTime string `json:"dateTime"` + Date string `json:"date"` + } `json:"end"` + HtmlLink string `json:"htmlLink"` + Description string `json:"description"` +} + +type CalendarListResponse struct { + Items []CalendarEvent `json:"items"` +} + +func (c *Client) CalendarList(timeMin, timeMax time.Time, maxResults int) ([]CalendarEvent, error) { + if maxResults <= 0 { + maxResults = 10 + } + params := url.Values{ + "timeMin": {timeMin.UTC().Format(time.RFC3339)}, + "timeMax": {timeMax.UTC().Format(time.RFC3339)}, + "maxResults": {fmt.Sprintf("%d", maxResults)}, + "singleEvents": {"true"}, + "orderBy": {"startTime"}, + } + var resp CalendarListResponse + if err := c.get(calBase+"/calendars/primary/events?"+params.Encode(), &resp); err != nil { + return nil, err + } + return resp.Items, nil +} + +// ── Docs ───────────────────────────────────────────────────────────────────── + +type DocsDocument struct { + DocumentID string `json:"documentId"` + Title string `json:"title"` + RevisionID string `json:"revisionId"` +} + +func (c *Client) DocsCreate(title string) (*DocsDocument, error) { + var doc DocsDocument + if err := c.post(docsBase+"/documents", map[string]string{"title": title}, &doc); err != nil { + return nil, err + } + return &doc, nil +} + +func (c *Client) DocsGet(docID string) (*DocsDocument, error) { + var doc DocsDocument + if err := c.get(fmt.Sprintf("%s/documents/%s", docsBase, docID), &doc); err != nil { + return nil, err + } + return &doc, nil +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} + +// HeaderValue extracts a Gmail message header value by name. +func HeaderValue(msg GmailMessage, name string) string { + for _, h := range msg.Payload.Headers { + if strings.EqualFold(h.Name, name) { + return h.Value + } + } + return "" +} + +// FormatEventTime returns a human-readable event time string. +func FormatEventTime(ev CalendarEvent) string { + dt := ev.Start.DateTime + if dt == "" { + return ev.Start.Date // all-day event + } + t, err := time.Parse(time.RFC3339, dt) + if err != nil { + return dt + } + return t.Local().Format("Mon Jan 2, 15:04") +} + +// MimeTypeLabel returns a short display label for a Drive MIME type. +func MimeTypeLabel(mime string) string { + switch mime { + case "application/vnd.google-apps.document": + return "Doc" + case "application/vnd.google-apps.spreadsheet": + return "Sheet" + case "application/vnd.google-apps.presentation": + return "Slides" + case "application/vnd.google-apps.folder": + return "πŸ“" + case "application/pdf": + return "PDF" + default: + if strings.HasPrefix(mime, "image/") { + return "Image" + } + return "File" + } +} diff --git a/pkg/health/server.go b/pkg/health/server.go index 5609ebdf6..916499706 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -16,6 +16,39 @@ type Server struct { ready bool checks map[string]Check startTime time.Time + metrics *Metrics +} + +type Metrics struct { + mu sync.RWMutex + Counters map[string]int64 `json:"counters"` + Latencies map[string][]int64 `json:"latencies_ms"` + LastUpdate time.Time `json:"last_update"` +} + +var ( + DefaultMetrics = &Metrics{ + Counters: make(map[string]int64), + Latencies: make(map[string][]int64), + } +) + +func (m *Metrics) RecordCounter(name string, val int64) { + m.mu.Lock() + defer m.mu.Unlock() + m.Counters[name] += val + m.LastUpdate = time.Now() +} + +func (m *Metrics) RecordLatency(name string, ms int64) { + m.mu.Lock() + defer m.mu.Unlock() + m.Latencies[name] = append(m.Latencies[name], ms) + // Keep only last 100 samples + if len(m.Latencies[name]) > 100 { + m.Latencies[name] = m.Latencies[name][len(m.Latencies[name])-100:] + } + m.LastUpdate = time.Now() } type Check struct { @@ -41,6 +74,7 @@ func NewServer(host string, port int) *Server { mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) + mux.HandleFunc("/metrics", s.metricsHandler) addr := fmt.Sprintf("%s:%d", host, port) s.server = &http.Server{ @@ -117,6 +151,16 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } +func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + DefaultMetrics.mu.RLock() + defer DefaultMetrics.mu.RUnlock() + + json.NewEncoder(w).Encode(DefaultMetrics) +} + func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/pkg/providers/builtin_resolvers.go b/pkg/providers/builtin_resolvers.go new file mode 100644 index 000000000..cf440069c --- /dev/null +++ b/pkg/providers/builtin_resolvers.go @@ -0,0 +1,343 @@ +package providers + +import ( + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + RegisterProvider("groq", groqResolver) + RegisterProvider("openai", openaiResolver) + RegisterProvider("gpt", openaiResolver) + RegisterProvider("anthropic", anthropicResolver) + RegisterProvider("claude", anthropicResolver) + RegisterProvider("openrouter", openrouterResolver) + RegisterProvider("litellm", litellmResolver) + RegisterProvider("zhipu", zhipuResolver) + RegisterProvider("glm", zhipuResolver) + RegisterProvider("gemini", geminiResolver) + RegisterProvider("google", geminiResolver) + RegisterProvider("vllm", vllmResolver) + RegisterProvider("shengsuanyun", shengsuanyunResolver) + RegisterProvider("nvidia", nvidiaResolver) + RegisterProvider("vivgrid", vivgridResolver) + RegisterProvider("deepseek", deepseekResolver) + RegisterProvider("avian", avianResolver) + RegisterProvider("mistral", mistralResolver) + RegisterProvider("minimax", minimaxResolver) + RegisterProvider("claude-cli", claudeCLIResolver) + RegisterProvider("claude-code", claudeCLIResolver) + RegisterProvider("claudecode", claudeCLIResolver) + RegisterProvider("codex-cli", codexCLIResolver) + RegisterProvider("codex-code", codexCLIResolver) + RegisterProvider("github_copilot", copilotResolver) + RegisterProvider("copilot", copilotResolver) +} + +func groqResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Groq.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Groq.APIKey, + apiBase: cfg.Providers.Groq.APIBase, + proxy: cfg.Providers.Groq.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://api.groq.com/openai/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func openaiResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + enableWebSearch: cfg.Providers.OpenAI.WebSearch, + } + if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { + sel.providerType = providerTypeCodexCLIToken + return sel, true, nil + } + if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { + sel.providerType = providerTypeCodexAuth + return sel, true, nil + } + sel.apiKey = cfg.Providers.OpenAI.APIKey + sel.apiBase = cfg.Providers.OpenAI.APIBase + sel.proxy = cfg.Providers.OpenAI.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.openai.com/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func anthropicResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + } + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } + sel.providerType = providerTypeClaudeAuth + return sel, true, nil + } + sel.apiKey = cfg.Providers.Anthropic.APIKey + sel.apiBase = cfg.Providers.Anthropic.APIBase + sel.proxy = cfg.Providers.Anthropic.Proxy + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func openrouterResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.OpenRouter.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.OpenRouter.APIKey, + proxy: cfg.Providers.OpenRouter.Proxy, + apiBase: cfg.Providers.OpenRouter.APIBase, + } + if sel.apiBase == "" { + sel.apiBase = "https://openrouter.ai/api/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func litellmResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.LiteLLM.APIKey, + apiBase: cfg.Providers.LiteLLM.APIBase, + proxy: cfg.Providers.LiteLLM.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "http://localhost:4000/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func zhipuResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Zhipu.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Zhipu.APIKey, + apiBase: cfg.Providers.Zhipu.APIBase, + proxy: cfg.Providers.Zhipu.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func geminiResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Gemini.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Gemini.APIKey, + apiBase: cfg.Providers.Gemini.APIBase, + proxy: cfg.Providers.Gemini.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func vllmResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.VLLM.APIBase != "" { + return providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.VLLM.APIKey, + apiBase: cfg.Providers.VLLM.APIBase, + proxy: cfg.Providers.VLLM.Proxy, + }, true, nil + } + return providerSelection{}, false, nil +} + +func shengsuanyunResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.ShengSuanYun.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.ShengSuanYun.APIKey, + apiBase: cfg.Providers.ShengSuanYun.APIBase, + proxy: cfg.Providers.ShengSuanYun.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://router.shengsuanyun.com/api/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func nvidiaResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Nvidia.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Nvidia.APIKey, + apiBase: cfg.Providers.Nvidia.APIBase, + proxy: cfg.Providers.Nvidia.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://integrate.api.nvidia.com/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func vivgridResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Vivgrid.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Vivgrid.APIKey, + apiBase: cfg.Providers.Vivgrid.APIBase, + proxy: cfg.Providers.Vivgrid.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://api.vivgrid.com/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func claudeCLIResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + workspace := cfg.WorkspacePath() + if workspace == "" { + workspace = "." + } + return providerSelection{ + providerType: providerTypeClaudeCLI, + model: model, + workspace: workspace, + }, true, nil +} + +func codexCLIResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + workspace := cfg.WorkspacePath() + if workspace == "" { + workspace = "." + } + return providerSelection{ + providerType: providerTypeCodexCLI, + model: model, + workspace: workspace, + }, true, nil +} + +func deepseekResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.DeepSeek.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.DeepSeek.APIKey, + apiBase: cfg.Providers.DeepSeek.APIBase, + proxy: cfg.Providers.DeepSeek.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://api.deepseek.com/v1" + } + if model != "deepseek-chat" && model != "deepseek-reasoner" { + sel.model = "deepseek-chat" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func avianResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Avian.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Avian.APIKey, + apiBase: cfg.Providers.Avian.APIBase, + proxy: cfg.Providers.Avian.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://api.avian.io/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func mistralResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Mistral.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Mistral.APIKey, + apiBase: cfg.Providers.Mistral.APIBase, + proxy: cfg.Providers.Mistral.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://api.mistral.ai/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func minimaxResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + if cfg.Providers.Minimax.APIKey != "" { + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + apiKey: cfg.Providers.Minimax.APIKey, + apiBase: cfg.Providers.Minimax.APIBase, + proxy: cfg.Providers.Minimax.Proxy, + } + if sel.apiBase == "" { + sel.apiBase = "https://api.minimaxi.com/v1" + } + return sel, true, nil + } + return providerSelection{}, false, nil +} + +func copilotResolver(cfg *config.Config, name, model string) (providerSelection, bool, error) { + sel := providerSelection{ + providerType: providerTypeGitHubCopilot, + model: model, + connectMode: cfg.Providers.GitHubCopilot.ConnectMode, + } + if cfg.Providers.GitHubCopilot.APIBase != "" { + sel.apiBase = cfg.Providers.GitHubCopilot.APIBase + } else { + sel.apiBase = "localhost:4321" + } + return sel, true, nil +} diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index d952c8cb0..f783f031f 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -47,185 +47,14 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { // First, prefer explicit provider configuration. if providerName != "" { - switch providerName { - case "groq": - if cfg.Providers.Groq.APIKey != "" { - sel.apiKey = cfg.Providers.Groq.APIKey - sel.apiBase = cfg.Providers.Groq.APIBase - sel.proxy = cfg.Providers.Groq.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.groq.com/openai/v1" - } + if resolver, ok := registry[providerName]; ok { + s, resolved, err := resolver(cfg, providerName, model) + if err != nil { + return providerSelection{}, err } - case "openai", "gpt": - if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { - sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch - if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - sel.providerType = providerTypeCodexCLIToken - return sel, nil - } - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - sel.providerType = providerTypeCodexAuth - return sel, nil - } - sel.apiKey = cfg.Providers.OpenAI.APIKey - sel.apiBase = cfg.Providers.OpenAI.APIBase - sel.proxy = cfg.Providers.OpenAI.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.openai.com/v1" - } + if resolved { + return s, nil } - case "anthropic", "claude": - if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - sel.apiBase = cfg.Providers.Anthropic.APIBase - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - sel.providerType = providerTypeClaudeAuth - return sel, nil - } - sel.apiKey = cfg.Providers.Anthropic.APIKey - sel.apiBase = cfg.Providers.Anthropic.APIBase - sel.proxy = cfg.Providers.Anthropic.Proxy - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - } - case "openrouter": - if cfg.Providers.OpenRouter.APIKey != "" { - sel.apiKey = cfg.Providers.OpenRouter.APIKey - sel.proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - sel.apiBase = cfg.Providers.OpenRouter.APIBase - } else { - sel.apiBase = "https://openrouter.ai/api/v1" - } - } - case "litellm": - if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" { - sel.apiKey = cfg.Providers.LiteLLM.APIKey - sel.apiBase = cfg.Providers.LiteLLM.APIBase - sel.proxy = cfg.Providers.LiteLLM.Proxy - if sel.apiBase == "" { - sel.apiBase = "http://localhost:4000/v1" - } - } - case "zhipu", "glm": - if cfg.Providers.Zhipu.APIKey != "" { - sel.apiKey = cfg.Providers.Zhipu.APIKey - sel.apiBase = cfg.Providers.Zhipu.APIBase - sel.proxy = cfg.Providers.Zhipu.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - } - case "gemini", "google": - if cfg.Providers.Gemini.APIKey != "" { - sel.apiKey = cfg.Providers.Gemini.APIKey - sel.apiBase = cfg.Providers.Gemini.APIBase - sel.proxy = cfg.Providers.Gemini.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - } - case "vllm": - if cfg.Providers.VLLM.APIBase != "" { - sel.apiKey = cfg.Providers.VLLM.APIKey - sel.apiBase = cfg.Providers.VLLM.APIBase - sel.proxy = cfg.Providers.VLLM.Proxy - } - case "shengsuanyun": - if cfg.Providers.ShengSuanYun.APIKey != "" { - sel.apiKey = cfg.Providers.ShengSuanYun.APIKey - sel.apiBase = cfg.Providers.ShengSuanYun.APIBase - sel.proxy = cfg.Providers.ShengSuanYun.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://router.shengsuanyun.com/api/v1" - } - } - case "nvidia": - if cfg.Providers.Nvidia.APIKey != "" { - sel.apiKey = cfg.Providers.Nvidia.APIKey - sel.apiBase = cfg.Providers.Nvidia.APIBase - sel.proxy = cfg.Providers.Nvidia.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://integrate.api.nvidia.com/v1" - } - } - case "vivgrid": - if cfg.Providers.Vivgrid.APIKey != "" { - sel.apiKey = cfg.Providers.Vivgrid.APIKey - sel.apiBase = cfg.Providers.Vivgrid.APIBase - sel.proxy = cfg.Providers.Vivgrid.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.vivgrid.com/v1" - } - } - case "claude-cli", "claude-code", "claudecode": - workspace := cfg.WorkspacePath() - if workspace == "" { - workspace = "." - } - sel.providerType = providerTypeClaudeCLI - sel.workspace = workspace - return sel, nil - case "codex-cli", "codex-code": - workspace := cfg.WorkspacePath() - if workspace == "" { - workspace = "." - } - sel.providerType = providerTypeCodexCLI - sel.workspace = workspace - return sel, nil - case "deepseek": - if cfg.Providers.DeepSeek.APIKey != "" { - sel.apiKey = cfg.Providers.DeepSeek.APIKey - sel.apiBase = cfg.Providers.DeepSeek.APIBase - sel.proxy = cfg.Providers.DeepSeek.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.deepseek.com/v1" - } - if model != "deepseek-chat" && model != "deepseek-reasoner" { - sel.model = "deepseek-chat" - } - } - case "avian": - if cfg.Providers.Avian.APIKey != "" { - sel.apiKey = cfg.Providers.Avian.APIKey - sel.apiBase = cfg.Providers.Avian.APIBase - sel.proxy = cfg.Providers.Avian.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.avian.io/v1" - } - } - case "mistral": - if cfg.Providers.Mistral.APIKey != "" { - sel.apiKey = cfg.Providers.Mistral.APIKey - sel.apiBase = cfg.Providers.Mistral.APIBase - sel.proxy = cfg.Providers.Mistral.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.mistral.ai/v1" - } - } - case "minimax": - if cfg.Providers.Minimax.APIKey != "" { - sel.apiKey = cfg.Providers.Minimax.APIKey - sel.apiBase = cfg.Providers.Minimax.APIBase - sel.proxy = cfg.Providers.Minimax.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.minimaxi.com/v1" - } - } - case "github_copilot", "copilot": - sel.providerType = providerTypeGitHubCopilot - if cfg.Providers.GitHubCopilot.APIBase != "" { - sel.apiBase = cfg.Providers.GitHubCopilot.APIBase - } else { - sel.apiBase = "localhost:4321" - } - sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode - return sel, nil } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index f9a939b71..53de6a103 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -477,6 +477,21 @@ func serializeMessages(messages []Message) []any { "url": mediaURL, }, }) + } else if strings.HasPrefix(mediaURL, "data:application/pdf") { + parts = append(parts, map[string]any{ + "type": "file_url", + "file_url": map[string]any{ + "url": mediaURL, + }, + }) + } else if strings.HasPrefix(mediaURL, "data:") { + // Fallback for other media types (e.g. audio, video) + parts = append(parts, map[string]any{ + "type": "file_url", + "file_url": map[string]any{ + "url": mediaURL, + }, + }) } } diff --git a/pkg/providers/registry.go b/pkg/providers/registry.go new file mode 100644 index 000000000..98b1af17f --- /dev/null +++ b/pkg/providers/registry.go @@ -0,0 +1,22 @@ +package providers + +import ( + "github.com/sipeed/picoclaw/pkg/config" +) + +// ProviderResolver is a function that attempts to resolve a provider and its configuration. +// It returns the selection, a boolean indicating if it was successfully resolved, and any error. +type ProviderResolver func(cfg *config.Config, providerName string, model string) (providerSelection, bool, error) + +var ( + registry = make(map[string]ProviderResolver) +) + +// RegisterProvider registers a new provider resolver. +func RegisterProvider(name string, resolver ProviderResolver) { + registry[name] = resolver +} + +func init() { + // Built-in providers will register themselves here or via init() in their respective files. +} diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 08f0b0ad2..a00589b18 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -2,6 +2,7 @@ package session import ( "encoding/json" + "hash/crc32" "os" "path/filepath" "strings" @@ -15,8 +16,10 @@ type Session struct { Key string `json:"key"` Messages []providers.Message `json:"messages"` Summary string `json:"summary,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` + Checksum uint32 `json:"checksum,omitempty"` } type SessionManager struct { @@ -145,6 +148,41 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { session.Updated = time.Now() } +func (sm *SessionManager) SetMetadata(key string, metaKey string, value any) { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, ok := sm.sessions[key] + if !ok { + session = &Session{ + Key: key, + Messages: []providers.Message{}, + Metadata: make(map[string]any), + Created: time.Now(), + } + sm.sessions[key] = session + } + + if session.Metadata == nil { + session.Metadata = make(map[string]any) + } + session.Metadata[metaKey] = value + session.Updated = time.Now() +} + +func (sm *SessionManager) GetMetadata(key string, metaKey string) (any, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + session, ok := sm.sessions[key] + if !ok || session.Metadata == nil { + return nil, false + } + + val, ok := session.Metadata[metaKey] + return val, ok +} + // sanitizeFilename converts a session key into a cross-platform safe filename. // Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the // volume separator on Windows, so filepath.Base would misinterpret the key. @@ -191,12 +229,23 @@ func (sm *SessionManager) Save(key string) error { } sm.mu.RUnlock() + // Calculate checksum of JSON without checksum field + snapshot.Checksum = 0 data, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return err } + snapshot.Checksum = crc32.ChecksumIEEE(data) + + // Re-marshal with checksum + data, err = json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } sessionPath := filepath.Join(sm.storage, filename+".json") + bakPath := sessionPath + ".bak" + tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") if err != nil { return err @@ -226,7 +275,13 @@ func (sm *SessionManager) Save(key string) error { return err } + // If primary exists, rename it to .bak before applying the new version. + if _, err := os.Stat(sessionPath); err == nil { + _ = os.Rename(sessionPath, bakPath) + } + if err := os.Rename(tmpPath, sessionPath); err != nil { + _ = os.Rename(bakPath, sessionPath) return err } cleanup = false @@ -249,6 +304,8 @@ func (sm *SessionManager) loadSessions() error { } sessionPath := filepath.Join(sm.storage, file.Name()) + bakPath := sessionPath + ".bak" + data, err := os.ReadFile(sessionPath) if err != nil { continue @@ -256,6 +313,29 @@ func (sm *SessionManager) loadSessions() error { var session Session if err := json.Unmarshal(data, &session); err != nil { + // Try to recover from backup + if bakData, bakErr := os.ReadFile(bakPath); bakErr == nil { + if err := json.Unmarshal(bakData, &session); err == nil { + if sm.verifyChecksum(&session) { + sm.sessions[session.Key] = &session + continue + } + } + } + continue + } + + if !sm.verifyChecksum(&session) { + // Try backup if primary is corrupted + if bakData, bakErr := os.ReadFile(bakPath); bakErr == nil { + var bakSession Session + if err := json.Unmarshal(bakData, &bakSession); err == nil { + if sm.verifyChecksum(&bakSession) { + sm.sessions[bakSession.Key] = &bakSession + continue + } + } + } continue } @@ -280,3 +360,18 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) { session.Updated = time.Now() } } + +func (sm *SessionManager) verifyChecksum(s *Session) bool { + if s.Checksum == 0 { + return true // Legacy session + } + saved := s.Checksum + s.Checksum = 0 + defer func() { s.Checksum = saved }() + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return false + } + return crc32.ChecksumIEEE(data) == saved +} diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index bd4bed8fb..7f7b80be3 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -174,6 +174,22 @@ type clawhubModerationInfo struct { IsSuspicious bool `json:"isSuspicious"` } +type clawhubDetailsResponse struct { + Slug string `json:"slug"` + DisplayName string `json:"displayName"` + Summary string `json:"summary"` + Description string `json:"description"` + Version string `json:"version"` + Author string `json:"author"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + License string `json:"license"` + Moderation *clawhubModerationInfo `json:"moderation"` + Files []string `json:"files"` + Tools []string `json:"tools"` + Permissions []string `json:"permissions"` +} + func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) { if err := utils.ValidateSkillIdentifier(slug); err != nil { return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error()) @@ -209,6 +225,60 @@ func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*Skill return meta, nil } +func (c *ClawHubRegistry) Inspect(ctx context.Context, slug string) (*SkillDetails, error) { + if err := utils.ValidateSkillIdentifier(slug); err != nil { + return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error()) + } + + // Assuming inspection endpoint is /api/v1/skills/{slug}/inspect or similar + u := c.baseURL + c.skillsPath + "/" + url.PathEscape(slug) + "/inspect" + + body, err := c.doGet(ctx, u) + if err != nil { + // Fallback to basic metadata if inspection endpoint is not available + basicMeta, basicErr := c.GetSkillMeta(ctx, slug) + if basicErr != nil { + return nil, fmt.Errorf("skill inspection failed: %w", err) + } + return &SkillDetails{ + Slug: basicMeta.Slug, + DisplayName: basicMeta.DisplayName, + Summary: basicMeta.Summary, + IsMalwareBlocked: basicMeta.IsMalwareBlocked, + IsSuspicious: basicMeta.IsSuspicious, + RegistryName: c.Name(), + }, nil + } + + var resp clawhubDetailsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse skill inspection details: %w", err) + } + + details := &SkillDetails{ + Slug: resp.Slug, + DisplayName: resp.DisplayName, + Summary: resp.Summary, + Description: resp.Description, + Version: resp.Version, + Author: resp.Author, + Homepage: resp.Homepage, + Repository: resp.Repository, + License: resp.License, + Files: resp.Files, + Tools: resp.Tools, + Permissions: resp.Permissions, + RegistryName: c.Name(), + } + + if resp.Moderation != nil { + details.IsMalwareBlocked = resp.Moderation.IsMalwareBlocked + details.IsSuspicious = resp.Moderation.IsSuspicious + } + + return details, nil +} + // --- DownloadAndInstall --- // DownloadAndInstall fetches metadata (with fallback), resolves version, diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go index 27e148165..8015e70c3 100644 --- a/pkg/skills/registry.go +++ b/pkg/skills/registry.go @@ -33,6 +33,25 @@ type SkillMeta struct { RegistryName string `json:"registry_name"` } +// SkillDetails provides in-depth information about a skill for trust/safety inspection. +type SkillDetails struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Summary string `json:"summary"` + Description string `json:"description"` + Version string `json:"version"` + Author string `json:"author"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + License string `json:"license"` + IsMalwareBlocked bool `json:"is_malware_blocked"` + IsSuspicious bool `json:"is_suspicious"` + Files []string `json:"files"` // List of files in the skill + Tools []string `json:"tools"` // List of tools/commands provided + Permissions []string `json:"permissions"` // Required permissions + RegistryName string `json:"registry_name"` +} + // InstallResult is returned by DownloadAndInstall to carry metadata // back to the caller for moderation and user messaging. type InstallResult struct { @@ -55,6 +74,8 @@ type SkillRegistry interface { // installs the skill to targetDir. Returns an InstallResult with metadata // for the caller to use for moderation and user messaging. DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) + // Inspect retrieves detailed information about a skill for safety review. + Inspect(ctx context.Context, slug string) (*SkillDetails, error) } // RegistryConfig holds configuration for all skill registries. diff --git a/pkg/tools/browser.go b/pkg/tools/browser.go new file mode 100644 index 000000000..96211b097 --- /dev/null +++ b/pkg/tools/browser.go @@ -0,0 +1,172 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/chromedp/chromedp" + "github.com/sipeed/picoclaw/pkg/media" +) + +// BrowserTool provides browser automation capabilities using chromedp. +type BrowserTool struct { + workspace string + mediaStore media.MediaStore + ctx context.Context + cancel context.CancelFunc +} + +func NewBrowserTool(workspace string, store media.MediaStore) *BrowserTool { + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.NoSandbox, + chromedp.Headless, + chromedp.DisableGPU, + ) + allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) + browserCtx, _ := chromedp.NewContext(allocCtx) + + return &BrowserTool{ + workspace: workspace, + mediaStore: store, + ctx: browserCtx, + cancel: cancel, + } +} + +func (t *BrowserTool) Name() string { return "browser" } +func (t *BrowserTool) Description() string { + return "Automate a web browser to navigate, click, type, and take screenshots." +} + +func (t *BrowserTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"navigate", "click", "type", "screenshot", "get_html"}, + "description": "The action to perform.", + }, + "url": map[string]any{ + "type": "string", + "description": "URL for 'navigate' action.", + }, + "selector": map[string]any{ + "type": "string", + "description": "CSS selector for 'click' or 'type' action.", + }, + "text": map[string]any{ + "type": "string", + "description": "Text to type for 'type' action.", + }, + "filename": map[string]any{ + "type": "string", + "description": "Output filename for 'screenshot' action (e.g., 'view.png').", + }, + }, + "required": []string{"action"}, + } +} + +func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + + // Ensure the browser context is still active + if t.ctx.Err() != nil { + allocCtx, _ := chromedp.NewExecAllocator(context.Background(), chromedp.DefaultExecAllocatorOptions[:]...) + t.ctx, _ = chromedp.NewContext(allocCtx) + } + + switch action { + case "navigate": + urlStr, _ := args["url"].(string) + if urlStr == "" { + return ErrorResult("url is required for navigate") + } + if err := chromedp.Run(t.ctx, chromedp.Navigate(urlStr)); err != nil { + return ErrorResult(fmt.Sprintf("navigation failed: %v", err)) + } + return UserResult(fmt.Sprintf("Navigated to %s", urlStr)) + + case "click": + selector, _ := args["selector"].(string) + if selector == "" { + return ErrorResult("selector is required for click") + } + if err := chromedp.Run(t.ctx, chromedp.Click(selector)); err != nil { + return ErrorResult(fmt.Sprintf("click failed: %v", err)) + } + return UserResult(fmt.Sprintf("Clicked element: %s", selector)) + + case "type": + selector, _ := args["selector"].(string) + text, _ := args["text"].(string) + if selector == "" || text == "" { + return ErrorResult("selector and text are required for type") + } + if err := chromedp.Run(t.ctx, chromedp.SendKeys(selector, text)); err != nil { + return ErrorResult(fmt.Sprintf("typing failed: %v", err)) + } + return UserResult(fmt.Sprintf("Typed into %s", selector)) + + case "screenshot": + filename, _ := args["filename"].(string) + if filename == "" { + filename = fmt.Sprintf("screenshot-%d.png", time.Now().Unix()) + } + if !strings.HasSuffix(strings.ToLower(filename), ".png") { + filename += ".png" + } + + path := filepath.Join(t.workspace, filename) + var buf []byte + if err := chromedp.Run(t.ctx, chromedp.CaptureScreenshot(&buf)); err != nil { + return ErrorResult(fmt.Sprintf("screenshot failed: %v", err)) + } + + if err := os.WriteFile(path, buf, 0o644); err != nil { + return ErrorResult(fmt.Sprintf("failed to save screenshot: %v", err)) + } + + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) + scope := fmt.Sprintf("tool:browser:screenshot:%s:%s", channel, chatID) + + ref := path + if t.mediaStore != nil { + if r, err := t.mediaStore.Store(path, media.MediaMeta{ + Filename: filename, + ContentType: "image/png", + Source: "tool:browser", + }, scope); err == nil { + ref = r + } + } + + return MediaResult(fmt.Sprintf("Screenshot captured: %s", filename), []string{ref}) + + case "get_html": + var html string + if err := chromedp.Run(t.ctx, chromedp.OuterHTML("html", &html)); err != nil { + return ErrorResult(fmt.Sprintf("failed to get HTML: %v", err)) + } + // Truncate if too long + if len(html) > 50000 { + html = html[:50000] + "\n... (truncated)" + } + return &ToolResult{ForLLM: html, ForUser: "Captured page HTML"} + + default: + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) + } +} + +func (t *BrowserTool) Stop() { + if t.cancel != nil { + t.cancel() + } +} diff --git a/pkg/tools/google.go b/pkg/tools/google.go new file mode 100644 index 000000000..7adc13e98 --- /dev/null +++ b/pkg/tools/google.go @@ -0,0 +1,391 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type GoogleTool struct { + manager ChannelManagerGetter +} + +func NewGoogleTool(manager ChannelManagerGetter) *GoogleTool { + return &GoogleTool{manager: manager} +} + +func (t *GoogleTool) Name() string { + return "google" +} + +func (t *GoogleTool) Description() string { + return "Access Google Workspace (GWS) services like Gmail and Calendar. Actions: 'list_emails', 'search_emails', 'read_thread', 'list_events', 'sync'. Use 'sync' with 'search_emails' or 'read_thread' to save results into the agent's contextual memory." +} + +func (t *GoogleTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"list_emails", "search_emails", "read_thread", "list_events", "sync"}, + "description": "The service action to perform.", + }, + "query": map[string]any{ + "type": "string", + "description": "Query string for 'search_emails'.", + }, + "thread_id": map[string]any{ + "type": "string", + "description": "Thread ID for 'read_thread'.", + }, + "count": map[string]any{ + "type": "integer", + "default": 10, + "description": "Number of items to retrieve.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *GoogleTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + count := 10 + if c, ok := args["count"].(float64); ok { + count = int(c) + } + + cred, err := auth.GetCredential("google-antigravity") + if err != nil || cred == nil { + return ErrorResult("Google account not linked. User must authenticate via 'google' provider first.") + } + + // Automatic refresh if needed + if cred.NeedsRefresh() { + logger.InfoC("tools", "Refreshing Google access token") + newCred, err := auth.RefreshAccessToken(cred, auth.GoogleAntigravityOAuthConfig()) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to refresh Google token: %v", err)) + } + cred = newCred + _ = auth.SetCredential("google-antigravity", cred) + } + + switch action { + case "list_emails": + return t.listEmails(ctx, cred, count) + case "search_emails": + query, _ := args["query"].(string) + return t.searchEmails(ctx, cred, query, count) + case "read_thread": + threadID, _ := args["thread_id"].(string) + return t.readThread(ctx, cred, threadID) + case "list_events": + return t.listEvents(ctx, cred, count) + case "sync": + return t.syncCommunications(ctx, cred, args) + default: + return ErrorResult("Unknown action") + } +} + +func (t *GoogleTool) listEmails(ctx context.Context, cred *auth.AuthCredential, maxResults int) *ToolResult { + url := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=%d", maxResults) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Authorization", "Bearer "+cred.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return ErrorResult(fmt.Sprintf("API request failed: %v", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return ErrorResult(fmt.Sprintf("Gmail API error (%d): %s", resp.StatusCode, string(body))) + } + + var listResp struct { + Messages []struct { + ID string `json:"id"` + } `json:"messages"` + } + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return ErrorResult(fmt.Sprintf("Failed to decode Gmail list: %v", err)) + } + + var emails []string + for _, m := range listResp.Messages { + msgURL := "https://gmail.googleapis.com/gmail/v1/users/me/messages/" + m.ID + mReq, _ := http.NewRequestWithContext(ctx, "GET", msgURL, nil) + mReq.Header.Set("Authorization", "Bearer "+cred.AccessToken) + mResp, err := http.DefaultClient.Do(mReq) + if err != nil { + continue + } + var msg struct { + Snippet string `json:"snippet"` + Payload struct { + Headers []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"headers"` + } `json:"payload"` + } + _ = json.NewDecoder(mResp.Body).Decode(&msg) + mResp.Body.Close() + + subject := "No Subject" + from := "Unknown" + for _, h := range msg.Payload.Headers { + if h.Name == "Subject" { + subject = h.Value + } else if h.Name == "From" { + from = h.Value + } + } + emails = append(emails, fmt.Sprintf("- From: %s\n Subject: %s\n Snippet: %s", from, subject, msg.Snippet)) + } + + if len(emails) == 0 { + return SilentResult("No messages found.") + } + + return SilentResult(fmt.Sprintf("Recent Emails:\n%s", join(emails, "\n\n"))) +} + +func (t *GoogleTool) searchEmails(ctx context.Context, cred *auth.AuthCredential, query string, maxResults int) *ToolResult { + url := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?q=%s&maxResults=%d", url.QueryEscape(query), maxResults) + return t.fetchAndFormatMessages(ctx, cred, url) +} + +func (t *GoogleTool) fetchAndFormatMessages(ctx context.Context, cred *auth.AuthCredential, url string) *ToolResult { + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Authorization", "Bearer "+cred.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return ErrorResult(fmt.Sprintf("API request failed: %v", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return ErrorResult(fmt.Sprintf("Gmail API error (%d): %s", resp.StatusCode, string(body))) + } + + var listResp struct { + Messages []struct { + ID string `json:"id"` + ThreadID string `json:"threadId"` + } `json:"messages"` + } + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return ErrorResult(fmt.Sprintf("Failed to decode Gmail list: %v", err)) + } + + var emails []string + for _, m := range listResp.Messages { + msgURL := "https://gmail.googleapis.com/gmail/v1/users/me/messages/" + m.ID + mReq, _ := http.NewRequestWithContext(ctx, "GET", msgURL, nil) + mReq.Header.Set("Authorization", "Bearer "+cred.AccessToken) + mResp, err := http.DefaultClient.Do(mReq) + if err != nil { + continue + } + var msg struct { + Snippet string `json:"snippet"` + Payload struct { + Headers []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"headers"` + } `json:"payload"` + } + _ = json.NewDecoder(mResp.Body).Decode(&msg) + mResp.Body.Close() + + subject := "No Subject" + from := "Unknown" + for _, h := range msg.Payload.Headers { + if h.Name == "Subject" { + subject = h.Value + } else if h.Name == "From" { + from = h.Value + } + } + emails = append(emails, fmt.Sprintf("- From: %s\n Subject: %s\n Snippet: %s\n ThreadID: %s", from, subject, msg.Snippet, m.ThreadID)) + } + + if len(emails) == 0 { + return SilentResult("No messages found.") + } + + return SilentResult(fmt.Sprintf("Gmail Search Results:\n%s", join(emails, "\n\n"))) +} + +func (t *GoogleTool) readThread(ctx context.Context, cred *auth.AuthCredential, threadID string) *ToolResult { + url := "https://gmail.googleapis.com/gmail/v1/users/me/threads/" + threadID + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Authorization", "Bearer "+cred.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return ErrorResult(fmt.Sprintf("API request failed: %v", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return ErrorResult(fmt.Sprintf("Gmail API error (%d): %s", resp.StatusCode, string(body))) + } + + var threadResp struct { + Messages []struct { + Snippet string `json:"snippet"` + Payload struct { + Headers []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"headers"` + } `json:"payload"` + } `json:"messages"` + } + if err := json.NewDecoder(resp.Body).Decode(&threadResp); err != nil { + return ErrorResult(fmt.Sprintf("Failed to decode Gmail thread: %v", err)) + } + + var threadMsgs []string + for _, msg := range threadResp.Messages { + from := "Unknown" + date := "Unknown Date" + for _, h := range msg.Payload.Headers { + if h.Name == "From" { + from = h.Value + } else if h.Name == "Date" { + date = h.Value + } + } + threadMsgs = append(threadMsgs, fmt.Sprintf("[%s] From: %s\n%s", date, from, msg.Snippet)) + } + + return SilentResult(fmt.Sprintf("Gmail Thread %s:\n%s", threadID, join(threadMsgs, "\n\n---\n\n"))) +} + +func (t *GoogleTool) listEvents(ctx context.Context, cred *auth.AuthCredential, maxResults int) *ToolResult { + now := time.Now().Format(time.RFC3339) + url := fmt.Sprintf("https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=%s&maxResults=%d&singleEvents=true&orderBy=startTime", now, maxResults) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Authorization", "Bearer "+cred.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return ErrorResult(fmt.Sprintf("API request failed: %v", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return ErrorResult(fmt.Sprintf("Calendar API error (%d): %s", resp.StatusCode, string(body))) + } + + var eventList struct { + Items []struct { + Summary string `json:"summary"` + Start struct { + DateTime string `json:"dateTime"` + Date string `json:"date"` + } `json:"start"` + End struct { + DateTime string `json:"dateTime"` + Date string `json:"date"` + } `json:"end"` + } `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&eventList); err != nil { + return ErrorResult(fmt.Sprintf("Failed to decode Calendar events: %v", err)) + } + + var events []string + for _, it := range eventList.Items { + start := it.Start.DateTime + if start == "" { + start = it.Start.Date + } + end := it.End.DateTime + if end == "" { + end = it.End.Date + } + events = append(events, fmt.Sprintf("- Event: %s\n Start: %s\n End: %s", it.Summary, start, end)) + } + + if len(events) == 0 { + return SilentResult("No upcoming events found.") + } + + return SilentResult(fmt.Sprintf("Upcoming Calendar Events:\n%s", join(events, "\n"))) +} + +func (t *GoogleTool) syncCommunications(ctx context.Context, cred *auth.AuthCredential, args map[string]any) *ToolResult { + query, _ := args["query"].(string) + threadID, _ := args["thread_id"].(string) + count := 10 + if c, ok := args["count"].(float64); ok { + count = int(c) + } + + var result *ToolResult + var syncKey string + + if threadID != "" { + result = t.readThread(ctx, cred, threadID) + syncKey = fmt.Sprintf("Gmail Thread (%s)", threadID) + } else if query != "" { + result = t.searchEmails(ctx, cred, query, count) + syncKey = fmt.Sprintf("Gmail Search (%q)", query) + } else { + result = t.listEmails(ctx, cred, count) + syncKey = "Recent Gmail" + } + + if result.IsError { + return result + } + + if t.manager == nil { + return result + } + + if getter, ok := t.manager.(interface{ GetMemoryStore() any }); ok { + if ms := getter.GetMemoryStore(); ms != nil { + if writer, ok := ms.(interface{ AppendCommunications(string) error }); ok { + err := writer.AppendCommunications(fmt.Sprintf("Gmail Sync (%s) @ %s:\n%s", syncKey, time.Now().Format("2006-01-02 15:04"), result.ForLLM)) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to sync to memory: %v", err)) + } + return SilentResult(fmt.Sprintf("%s synced to contextual memory.", syncKey)) + } + } + } + + return result +} + +func join(s []string, sep string) string { + res := "" + for i, v := range s { + if i > 0 { + res += sep + } + res += v + } + return res +} diff --git a/pkg/tools/image.go b/pkg/tools/image.go new file mode 100644 index 000000000..a53d0dfb3 --- /dev/null +++ b/pkg/tools/image.go @@ -0,0 +1,171 @@ +package tools + +import ( + "context" + "fmt" + "image" + "image/color" + "image/draw" + "path/filepath" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/sipeed/picoclaw/pkg/media" +) + +// ImageTool provides advanced image manipulation capabilities using imaging. +type ImageTool struct { + workspace string + mediaStore media.MediaStore +} + +func NewImageTool(workspace string, store media.MediaStore) *ImageTool { + return &ImageTool{ + workspace: workspace, + mediaStore: store, + } +} + +func (t *ImageTool) Name() string { return "image" } +func (t *ImageTool) Description() string { + return "Inspect, resize, crop, and manipulate images. Supports media:// and local paths." +} + +func (t *ImageTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"inspect", "resize", "crop", "annotate"}, + "description": "The action to perform.", + }, + "path": map[string]any{ + "type": "string", + "description": "Path to the input image (local path or media:// reference).", + }, + "width": map[string]any{ + "type": "integer", + "description": "Target width for 'resize'.", + }, + "height": map[string]any{ + "type": "integer", + "description": "Target height for 'resize'.", + }, + "x": map[string]any{"type": "integer", "description": "X coordinate for 'crop'."}, + "y": map[string]any{"type": "integer", "description": "Y coordinate for 'crop'."}, + "w": map[string]any{"type": "integer", "description": "Width for 'crop'."}, + "h": map[string]any{"type": "integer", "description": "Height for 'crop'."}, + "output": map[string]any{ + "type": "string", + "description": "Output filename (e.g., 'edited.png').", + }, + }, + "required": []string{"action", "path"}, + } +} + +func (t *ImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + inputPath, _ := args["path"].(string) + + resolvedPath, err := t.resolvePath(inputPath) + if err != nil { + return ErrorResult(err.Error()) + } + + src, err := imaging.Open(resolvedPath) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open image: %v", err)) + } + + switch action { + case "inspect": + bounds := src.Bounds() + return UserResult(fmt.Sprintf("Image dimensions: %dx%d px", bounds.Dx(), bounds.Dy())) + + case "resize": + width, _ := args["width"].(float64) + height, _ := args["height"].(float64) + if width == 0 && height == 0 { + return ErrorResult("width or height must be specified for resize") + } + dst := imaging.Resize(src, int(width), int(height), imaging.Lanczos) + return t.saveAndReturn(ctx, dst, args) + + case "crop": + x, _ := args["x"].(float64) + y, _ := args["y"].(float64) + w, _ := args["w"].(float64) + h, _ := args["h"].(float64) + if w == 0 || h == 0 { + return ErrorResult("w and h must be specified for crop") + } + dst := imaging.Crop(src, image.Rect(int(x), int(y), int(x+w), int(y+h))) + return t.saveAndReturn(ctx, dst, args) + + case "annotate": + // Placeholder for complex annotation. + // We'll just draw a red border for now to demonstrate manipulation. + bounds := src.Bounds() + dst := image.NewRGBA(bounds) + draw.Draw(dst, bounds, src, bounds.Min, draw.Src) + + red := color.RGBA{255, 0, 0, 255} + // Draw simple border lines + for i := 0; i < 5; i++ { + draw.Draw(dst, image.Rect(bounds.Min.X, bounds.Min.Y+i, bounds.Max.X, bounds.Min.Y+i+1), &image.Uniform{red}, image.Point{}, draw.Src) + draw.Draw(dst, image.Rect(bounds.Min.X, bounds.Max.Y-i-1, bounds.Max.X, bounds.Max.Y-i), &image.Uniform{red}, image.Point{}, draw.Src) + draw.Draw(dst, image.Rect(bounds.Min.X+i, bounds.Min.Y, bounds.Min.X+i+1, bounds.Max.Y), &image.Uniform{red}, image.Point{}, draw.Src) + draw.Draw(dst, image.Rect(bounds.Max.X-i-1, bounds.Min.Y, bounds.Max.X-i, bounds.Max.Y), &image.Uniform{red}, image.Point{}, draw.Src) + } + return t.saveAndReturn(ctx, dst, args) + + default: + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) + } +} + +func (t *ImageTool) resolvePath(input string) (string, error) { + if strings.HasPrefix(input, "media://") { + if t.mediaStore == nil { + return "", fmt.Errorf("media store not configured") + } + return t.mediaStore.Resolve(input) + } + // Fallback to workspace path validation + return validatePath(input, t.workspace, true) +} + +func (t *ImageTool) saveAndReturn(ctx context.Context, img image.Image, args map[string]any) *ToolResult { + output, _ := args["output"].(string) + if output == "" { + output = fmt.Sprintf("output-%d.png", time.Now().Unix()) + } + if !strings.HasSuffix(strings.ToLower(output), ".png") && !strings.HasSuffix(strings.ToLower(output), ".jpg") { + output += ".png" + } + + path := filepath.Join(t.workspace, output) + if err := imaging.Save(img, path); err != nil { + return ErrorResult(fmt.Sprintf("failed to save image: %v", err)) + } + + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) + scope := fmt.Sprintf("tool:image:manipulate:%s:%s", channel, chatID) + + ref := path + if t.mediaStore != nil { + if r, err := t.mediaStore.Store(path, media.MediaMeta{ + Filename: output, + ContentType: "image/png", + Source: "tool:image", + }, scope); err == nil { + ref = r + } + } + + return MediaResult(fmt.Sprintf("Image %q processed successfully", output), []string{ref}) +} diff --git a/pkg/tools/pdf.go b/pkg/tools/pdf.go new file mode 100644 index 000000000..599380ef4 --- /dev/null +++ b/pkg/tools/pdf.go @@ -0,0 +1,186 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jung-kurt/gofpdf/v2" + "github.com/sipeed/picoclaw/pkg/media" +) + +// PDFTool provides capabilities for creating, translating, and extracting PDF documents. +type PDFTool struct { + workspace string + restrict bool + mediaStore media.MediaStore +} + +func NewPDFTool(workspace string, restrict bool, store media.MediaStore) *PDFTool { + return &PDFTool{ + workspace: workspace, + restrict: restrict, + mediaStore: store, + } +} + +func (t *PDFTool) Name() string { return "pdf" } +func (t *PDFTool) Description() string { + return "Create and export PDF documents. Use 'create' to generate a PDF from text (e.g. translations). " + + "For translation tasks: translate the full text first, then call pdf with action='create', content=<full translation>, output=<filename.pdf>." +} + +func (t *PDFTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"create", "extract"}, + "description": "Action: 'create' builds a PDF from text content, 'extract' reads a PDF file path and returns its text.", + }, + "content": map[string]any{ + "type": "string", + "description": "Text content to write into the PDF (for 'create'). Pass the COMPLETE translated text β€” never truncate.", + }, + "title": map[string]any{ + "type": "string", + "description": "Optional title displayed at the top of the PDF (for 'create').", + }, + "path": map[string]any{ + "type": "string", + "description": "File path or media:// ref of a PDF to extract text from (for 'extract').", + }, + "output": map[string]any{ + "type": "string", + "description": "Output filename for 'create' (e.g. 'translation_ro.pdf'). Defaults to 'document.pdf'.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *PDFTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + + switch action { + case "create": + return t.handleCreate(ctx, args) + case "extract": + return t.handleExtract(args) + default: + return ErrorResult(fmt.Sprintf("unknown pdf action: %s. Use 'create' or 'extract'.", action)) + } +} + +// handleCreate builds a properly paginated, Unicode-safe PDF from the given content. +func (t *PDFTool) handleCreate(_ context.Context, args map[string]any) *ToolResult { + content, _ := args["content"].(string) + title, _ := args["title"].(string) + output, _ := args["output"].(string) + + if content == "" { + return ErrorResult("content is required for 'create' action") + } + + if output == "" { + output = "document.pdf" + } + if !strings.HasSuffix(strings.ToLower(output), ".pdf") { + output += ".pdf" + } + + outPath := filepath.Join(t.workspace, output) + if t.restrict { + var err error + outPath, err = validatePath(output, t.workspace, true) + if err != nil { + return ErrorResult(err.Error()) + } + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return ErrorResult(fmt.Sprintf("failed to create output directory: %v", err)) + } + + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.SetMargins(20, 20, 20) + pdf.SetAutoPageBreak(true, 20) + + // Use ISO-8859-2 encoder for full coverage of EU Latin languages (Romanian, etc.) + pdf.SetFont("Helvetica", "", 12) + + tr := pdf.UnicodeTranslatorFromDescriptor("iso-8859-2") + + pdf.AddPage() + + // Optional title + if title != "" { + pdf.SetFont("Helvetica", "B", 16) + pdf.MultiCell(170, 10, tr(title), "", "C", false) + pdf.Ln(6) + pdf.SetFont("Helvetica", "", 12) + } + + // Write body β€” split on blank lines to preserve paragraph structure + paragraphs := strings.Split(content, "\n\n") + for _, para := range paragraphs { + para = strings.TrimSpace(para) + if para == "" { + pdf.Ln(4) + continue + } + // Within a paragraph, keep newlines as line breaks + lines := strings.Split(para, "\n") + for _, line := range lines { + pdf.MultiCell(170, 7, tr(line), "", "L", false) + } + pdf.Ln(4) // paragraph spacing + } + + if err := pdf.OutputFileAndClose(outPath); err != nil { + return ErrorResult(fmt.Sprintf("failed to write PDF: %v", err)) + } + + scope := "tool:pdf:create" + + ref := outPath + if t.mediaStore != nil { + if r, storeErr := t.mediaStore.Store(outPath, media.MediaMeta{ + Filename: output, + ContentType: "application/pdf", + Source: "tool:pdf", + }, scope); storeErr == nil { + ref = r + } + } + + return MediaResult(fmt.Sprintf("PDF %q created (%d pages). Sending now.", output, pdf.PageCount()), []string{ref}) +} + +// handleExtract returns a prompt message telling the agent to read the file as an attachment. +func (t *PDFTool) handleExtract(args map[string]any) *ToolResult { + path, _ := args["path"].(string) + if path == "" { + return ErrorResult("path is required for 'extract' action") + } + + // Resolve media refs + resolved := path + if t.restrict && !strings.HasPrefix(path, "media://") { + var err error + resolved, err = validatePath(path, t.workspace, true) + if err != nil { + return ErrorResult(err.Error()) + } + } + + return UserResult(fmt.Sprintf( + "To extract text from %q, attach the file directly in the chat β€” I can read it using my vision capability. "+ + "Alternatively, if you share the file path I can attempt to parse it as a document.", + filepath.Base(resolved), + )) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index ca8436c67..0635f47d7 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -5,20 +5,28 @@ import ( "fmt" "sort" "sync" + "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) +type ToolEntry struct { + Tool Tool + IsCore bool + TTL int +} + type ToolRegistry struct { - tools map[string]Tool - mu sync.RWMutex + tools map[string]*ToolEntry + mu sync.RWMutex + version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation } func NewToolRegistry() *ToolRegistry { return &ToolRegistry{ - tools: make(map[string]Tool), + tools: make(map[string]*ToolEntry), } } @@ -30,14 +38,116 @@ func (r *ToolRegistry) Register(tool Tool) { logger.WarnCF("tools", "Tool registration overwrites existing tool", map[string]any{"name": name}) } - r.tools[name] = tool + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: true, + TTL: 0, // Core tools do not use TTL + } + r.version.Add(1) + logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name}) +} + +// RegisterHidden saves hidden tools (visible only via TTL) +func (r *ToolRegistry) RegisterHidden(tool Tool) { + r.mu.Lock() + defer r.mu.Unlock() + name := tool.Name() + if _, exists := r.tools[name]; exists { + logger.WarnCF("tools", "Hidden tool registration overwrites existing tool", + map[string]any{"name": name}) + } + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: false, + TTL: 0, + } + r.version.Add(1) + logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name}) +} + +// PromoteTools atomically sets the TTL for multiple non-core tools. +// This prevents a concurrent TickTTL from decrementing between promotions. +func (r *ToolRegistry) PromoteTools(names []string, ttl int) { + r.mu.Lock() + defer r.mu.Unlock() + promoted := 0 + for _, name := range names { + if entry, exists := r.tools[name]; exists { + if !entry.IsCore { + entry.TTL = ttl + promoted++ + } + } + } + logger.DebugCF( + "tools", + "PromoteTools completed", + map[string]any{"requested": len(names), "promoted": promoted, "ttl": ttl}, + ) +} + +// TickTTL decreases TTL only for non-core tools +func (r *ToolRegistry) TickTTL() { + r.mu.Lock() + defer r.mu.Unlock() + for _, entry := range r.tools { + if !entry.IsCore && entry.TTL > 0 { + entry.TTL-- + } + } +} + +// Version returns the current registry version (atomically). +func (r *ToolRegistry) Version() uint64 { + return r.version.Load() +} + +// HiddenToolSnapshot holds a consistent snapshot of hidden tools and the +// registry version at which it was taken. Used by BM25SearchTool cache. +type HiddenToolSnapshot struct { + Docs []HiddenToolDoc + Version uint64 +} + +// HiddenToolDoc is a lightweight representation of a hidden tool for search indexing. +type HiddenToolDoc struct { + Name string + Description string +} + +// SnapshotHiddenTools returns all non-core tools and the current registry +// version under a single read-lock, guaranteeing consistency between the +// two values. +func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot { + r.mu.RLock() + defer r.mu.RUnlock() + docs := make([]HiddenToolDoc, 0, len(r.tools)) + for name, entry := range r.tools { + if !entry.IsCore { + docs = append(docs, HiddenToolDoc{ + Name: name, + Description: entry.Tool.Description(), + }) + } + } + return HiddenToolSnapshot{ + Docs: docs, + Version: r.version.Load(), + } } func (r *ToolRegistry) Get(name string) (Tool, bool) { r.mu.RLock() defer r.mu.RUnlock() - tool, ok := r.tools[name] - return tool, ok + entry, ok := r.tools[name] + if !ok { + return nil, false + } + // Hidden tools with expired TTL are not callable. + if !entry.IsCore && entry.TTL <= 0 { + return nil, false + } + return entry.Tool, true } func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { @@ -135,7 +245,13 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any { sorted := r.sortedToolNames() definitions := make([]map[string]any, 0, len(sorted)) for _, name := range sorted { - definitions = append(definitions, ToolToSchema(r.tools[name])) + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + definitions = append(definitions, ToolToSchema(r.tools[name].Tool)) } return definitions } @@ -149,8 +265,13 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { sorted := r.sortedToolNames() definitions := make([]providers.ToolDefinition, 0, len(sorted)) for _, name := range sorted { - tool := r.tools[name] - schema := ToolToSchema(tool) + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + schema := ToolToSchema(entry.Tool) // Safely extract nested values with type checks fn, ok := schema["function"].(map[string]any) @@ -198,8 +319,13 @@ func (r *ToolRegistry) GetSummaries() []string { sorted := r.sortedToolNames() summaries := make([]string, 0, len(sorted)) for _, name := range sorted { - tool := r.tools[name] - summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description())) + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description())) } return summaries } diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go new file mode 100644 index 000000000..f41c80d90 --- /dev/null +++ b/pkg/tools/search_tool.go @@ -0,0 +1,304 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const ( + MaxRegexPatternLength = 200 +) + +type RegexSearchTool struct { + registry *ToolRegistry + ttl int + maxSearchResults int +} + +func NewRegexSearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *RegexSearchTool { + return &RegexSearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} +} + +func (t *RegexSearchTool) Name() string { + return "tool_search_tool_regex" +} + +func (t *RegexSearchTool) Description() string { + return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools." +} + +func (t *RegexSearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{ + "type": "string", + "description": "Regex pattern to match tool name or description", + }, + }, + "required": []string{"pattern"}, + } +} + +func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + pattern, ok := args["pattern"].(string) + if !ok || strings.TrimSpace(pattern) == "" { + // An empty string regex (?i) will match every hidden tool, + // dumping massive payloads into the context and burning tokens. + return ErrorResult("Missing or invalid 'pattern' argument. Must be a non-empty string.") + } + + if len(pattern) > MaxRegexPatternLength { + logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)}) + return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength)) + } + + logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern}) + + res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) + if err != nil { + logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()}) + return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) + } + + logger.InfoCF("discovery", "Regex search completed", map[string]any{"pattern": pattern, "results": len(res)}) + return formatDiscoveryResponse(t.registry, res, t.ttl) +} + +type BM25SearchTool struct { + registry *ToolRegistry + ttl int + maxSearchResults int + + // Cache: rebuilt only when the registry version changes. + cacheMu sync.Mutex + cachedEngine *bm25CachedEngine + cacheVersion uint64 +} + +func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool { + return &BM25SearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} +} + +func (t *BM25SearchTool) Name() string { + return "tool_search_tool_bm25" +} + +func (t *BM25SearchTool) Description() string { + return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools." +} + +func (t *BM25SearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query", + }, + }, + "required": []string{"query"}, + } +} + +func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + query, ok := args["query"].(string) + if !ok || strings.TrimSpace(query) == "" { + // An empty string query will match every hidden tool, + // dumping massive payloads into the context and burning tokens. + return ErrorResult("Missing or invalid 'query' argument. Must be a non-empty string.") + } + + logger.DebugCF("discovery", "BM25 search", map[string]any{"query": query}) + + cached := t.getOrBuildEngine() + if cached == nil { + logger.DebugCF("discovery", "BM25 search: no hidden tools available", nil) + return SilentResult("No tools found matching the query.") + } + + ranked := cached.engine.Search(query, t.maxSearchResults) + if len(ranked) == 0 { + logger.DebugCF("discovery", "BM25 search: no matches", map[string]any{"query": query}) + return SilentResult("No tools found matching the query.") + } + + results := make([]ToolSearchResult, len(ranked)) + for i, r := range ranked { + results[i] = ToolSearchResult{ + Name: r.Document.Name, + Description: r.Document.Description, + } + } + + logger.InfoCF("discovery", "BM25 search completed", map[string]any{"query": query, "results": len(results)}) + return formatDiscoveryResponse(t.registry, results, t.ttl) +} + +// ToolSearchResult represents the result returned to the LLM. +// Parameters are omitted from the JSON response to save context tokens; +// the LLM will see full schemas via ToProviderDefs after promotion. +type ToolSearchResult struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) { + if maxSearchResults <= 0 { + return nil, nil + } + + regex, err := regexp.Compile("(?i)" + pattern) + if err != nil { + return nil, fmt.Errorf("failed to compile regex pattern %q: %w", pattern, err) + } + + r.mu.RLock() + defer r.mu.RUnlock() + + var results []ToolSearchResult + + // Iterate in sorted order for deterministic results across calls. + for _, name := range r.sortedToolNames() { + entry := r.tools[name] + // Search only among the hidden tools (Core tools are already visible) + if !entry.IsCore { + // Directly call interface methods! No reflection/unmarshalling needed. + desc := entry.Tool.Description() + + if regex.MatchString(name) || regex.MatchString(desc) { + results = append(results, ToolSearchResult{ + Name: name, + Description: desc, + }) + if len(results) >= maxSearchResults { + break // Stop searching once we hit the max! Saves CPU. + } + } + } + } + + return results, nil +} + +func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult { + if len(results) == 0 { + return SilentResult("No tools found matching the query.") + } + + names := make([]string, len(results)) + for i, r := range results { + names[i] = r.Name + } + registry.PromoteTools(names, ttl) + logger.InfoCF("discovery", "Promoted tools", map[string]any{"tools": names, "ttl": ttl}) + + b, err := json.Marshal(results) + if err != nil { + return ErrorResult("Failed to format search results: " + err.Error()) + } + + msg := fmt.Sprintf( + "Found %d tools:\n%s\n\nSUCCESS: These tools have been temporarily UNLOCKED as native tools! In your next response, you can call them directly just like any normal tool", + len(results), + string(b), + ) + + return SilentResult(msg) +} + +// Lightweight internal type used as corpus document for BM25. +type searchDoc struct { + Name string + Description string +} + +// bm25CachedEngine wraps a BM25Engine with its corpus snapshot. +type bm25CachedEngine struct { + engine *utils.BM25Engine[searchDoc] +} + +// snapshotToSearchDocs converts a HiddenToolSnapshot to BM25 searchDoc slice. +func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc { + docs := make([]searchDoc, len(snap.Docs)) + for i, d := range snap.Docs { + docs[i] = searchDoc{Name: d.Name, Description: d.Description} + } + return docs +} + +// buildBM25Engine creates a BM25Engine from a slice of searchDocs. +func buildBM25Engine(docs []searchDoc) *utils.BM25Engine[searchDoc] { + return utils.NewBM25Engine( + docs, + func(doc searchDoc) string { + return doc.Name + " " + doc.Description + }, + ) +} + +// getOrBuildEngine returns a cached BM25 engine, rebuilding it only when +// the registry version has changed (new tools registered). +func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine { + // Fast path: optimistic check without locking. + if t.cachedEngine != nil && t.cacheVersion == t.registry.Version() { + return t.cachedEngine + } + + t.cacheMu.Lock() + defer t.cacheMu.Unlock() + + // Snapshot + version are read under a single registry RLock, + // guaranteeing consistency (no TOCTOU). + snap := t.registry.SnapshotHiddenTools() + + // Re-check: another goroutine may have rebuilt while we waited for cacheMu. + if t.cachedEngine != nil && t.cacheVersion == snap.Version { + return t.cachedEngine + } + + docs := snapshotToSearchDocs(snap) + if len(docs) == 0 { + t.cachedEngine = nil + t.cacheVersion = snap.Version + return nil + } + + cached := &bm25CachedEngine{engine: buildBM25Engine(docs)} + t.cachedEngine = cached + t.cacheVersion = snap.Version + logger.DebugCF("discovery", "BM25 engine rebuilt", map[string]any{"docs": len(docs), "version": snap.Version}) + return cached +} + +// SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine. +// This non-cached variant rebuilds the engine on every call. Used by tests +// and any code that doesn't hold a BM25SearchTool instance. +func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult { + snap := r.SnapshotHiddenTools() + docs := snapshotToSearchDocs(snap) + if len(docs) == 0 { + return nil + } + + ranked := buildBM25Engine(docs).Search(query, maxSearchResults) + if len(ranked) == 0 { + return nil + } + + out := make([]ToolSearchResult, len(ranked)) + for i, r := range ranked { + out[i] = ToolSearchResult{ + Name: r.Document.Name, + Description: r.Document.Description, + } + } + return out +} diff --git a/pkg/tools/search_tools_test.go b/pkg/tools/search_tools_test.go new file mode 100644 index 000000000..3aae941cb --- /dev/null +++ b/pkg/tools/search_tools_test.go @@ -0,0 +1,339 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// Dummy tool to fill the registry in our tests. +type mockSearchableTool struct { + name string + desc string +} + +func (m *mockSearchableTool) Name() string { return m.name } +func (m *mockSearchableTool) Description() string { return m.desc } +func (m *mockSearchableTool) Parameters() map[string]any { + return map[string]any{"type": "object"} +} + +func (m *mockSearchableTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + return SilentResult("mock executed: " + m.name) +} + +// Helper to initialize a populated ToolRegistry +func setupPopulatedRegistry() *ToolRegistry { + reg := NewToolRegistry() + + // A core tool (NOT to be found by searches) + reg.Register(&mockSearchableTool{ + name: "core_search", + desc: "I am a visible core tool for searching files", + }) + + // Hidden tools (must be found by searches) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_read_file", + desc: "Read the contents of a system file", + }) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_list_dir", + desc: "List directories and files in the system", + }) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_fetch_net", + desc: "Fetch data from a network database", + }) + + return reg +} + +func TestRegexSearchTool_Execute(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewRegexSearchTool(reg, 5, 10) + ctx := context.Background() + + t.Run("Empty Pattern Error", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{}) + if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'pattern'") { + t.Errorf("Expected missing pattern error, got: %v", res.ForLLM) + } + }) + + t.Run("Invalid Regex Syntax", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "[unclosed"}) + if !res.IsError || !strings.Contains(res.ForLLM, "Invalid regex pattern syntax") { + t.Errorf("Expected regex syntax error, got: %v", res.ForLLM) + } + }) + + t.Run("No Match Found", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "alien"}) + if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { + t.Errorf("Expected 'no tools found' message, got: %v", res.ForLLM) + } + }) + + t.Run("Successful Match & Promotion", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "system"}) + + if res.IsError { + t.Fatalf("Unexpected error: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "SUCCESS: These tools have been temporarily UNLOCKED") { + t.Errorf("Expected success string, got: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "mcp_read_file") { + t.Errorf("Expected 'mcp_read_file' in results") + } + + // Verify that the TTL has been updated for the tools found + reg.mu.RLock() + defer reg.mu.RUnlock() + if reg.tools["mcp_read_file"].TTL != 5 { + t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", reg.tools["mcp_read_file"].TTL) + } + if reg.tools["mcp_fetch_net"].TTL != 0 { + t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)") + } + }) +} + +func TestBM25SearchTool_Execute(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewBM25SearchTool(reg, 3, 10) + ctx := context.Background() + + t.Run("Empty Query Error", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": " "}) + if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'query'") { + t.Errorf("Expected missing query error, got: %v", res.ForLLM) + } + }) + + t.Run("No Match Found", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": "aliens spaceships"}) + if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { + t.Errorf("Expected 'no tools found', got: %v", res.ForLLM) + } + }) + + t.Run("Successful Match & Promotion", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": "read files"}) + + if res.IsError { + t.Fatalf("Unexpected error: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "mcp_read_file") { + t.Errorf("Expected 'mcp_read_file' in BM25 results") + } + + reg.mu.RLock() + defer reg.mu.RUnlock() + if reg.tools["mcp_read_file"].TTL != 3 { + t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 3") + } + }) +} + +func TestRegexSearchTool_PatternTooLong(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewRegexSearchTool(reg, 5, 10) + ctx := context.Background() + + longPattern := strings.Repeat("a", MaxRegexPatternLength+1) + res := tool.Execute(ctx, map[string]any{"pattern": longPattern}) + if !res.IsError || !strings.Contains(res.ForLLM, "Pattern too long") { + t.Errorf("Expected pattern too long error, got: %v", res.ForLLM) + } +} + +func TestSearchRegex_ZeroMaxResults(t *testing.T) { + reg := setupPopulatedRegistry() + + res, err := reg.SearchRegex("mcp", 0) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + if len(res) != 0 { + t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) + } +} + +func TestSearchBM25_ZeroMaxResults(t *testing.T) { + reg := setupPopulatedRegistry() + + res := reg.SearchBM25("read file", 0) + if len(res) != 0 { + t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) + } +} + +func TestSearchRegex_DeterministicOrder(t *testing.T) { + reg := NewToolRegistry() + for i := 0; i < 20; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("tool_%02d", i), + desc: "searchable tool", + }) + } + + // Run the same search multiple times and verify order is stable + var firstRun []string + for attempt := 0; attempt < 10; attempt++ { + res, err := reg.SearchRegex("searchable", 20) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + + names := make([]string, len(res)) + for i, r := range res { + names[i] = r.Name + } + + if attempt == 0 { + firstRun = names + } else { + for i, name := range names { + if name != firstRun[i] { + t.Fatalf("Non-deterministic order at attempt %d, index %d: got %q, want %q", + attempt, i, name, firstRun[i]) + } + } + } + } +} + +func TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) { + reg := NewToolRegistry() + + // Add 1 Core and 10 Hidden, all containing the word "match" + reg.Register(&mockSearchableTool{"core_match", "I am core with match"}) + for i := 0; i < 10; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("hidden_match_%d", i), + desc: "this has a match", + }) + } + + t.Run("Regex limits and core filtering", func(t *testing.T) { + // Search with Regex and a limit of maxSearchResults = 4 + res, err := reg.SearchRegex("match", 4) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + + if len(res) != 4 { + t.Errorf("Expected exactly 4 results due to limit, got %d", len(res)) + } + + for _, r := range res { + if r.Name == "core_match" { + t.Errorf("SearchRegex returned a Core tool, which should be excluded") + } + } + }) + + t.Run("BM25 limits and core filtering", func(t *testing.T) { + // Search with BM25 and a limit of maxSearchResults = 3 + res := reg.SearchBM25("match", 3) + + if len(res) != 3 { + t.Errorf("Expected exactly 3 results due to limit, got %d", len(res)) + } + + for _, r := range res { + if r.Name == "core_match" { + t.Errorf("SearchBM25 returned a Core tool, which should be excluded") + } + } + }) +} + +func TestGet_HiddenToolTTLLifecycle(t *testing.T) { + reg := NewToolRegistry() + reg.RegisterHidden(&mockSearchableTool{name: "hidden_tool", desc: "test"}) + + // TTL=0 at registration β†’ not gettable + _, ok := reg.Get("hidden_tool") + if ok { + t.Error("Expected hidden tool with TTL=0 to NOT be gettable") + } + + // Promote β†’ gettable + reg.PromoteTools([]string{"hidden_tool"}, 3) + _, ok = reg.Get("hidden_tool") + if !ok { + t.Error("Expected promoted hidden tool to be gettable") + } + + // Tick down to 0 β†’ not gettable again + reg.TickTTL() // 3β†’2 + reg.TickTTL() // 2β†’1 + reg.TickTTL() // 1β†’0 + _, ok = reg.Get("hidden_tool") + if ok { + t.Error("Expected hidden tool with TTL ticked to 0 to NOT be gettable") + } + + // Core tools remain always gettable + reg.Register(&mockSearchableTool{name: "core_tool", desc: "core"}) + _, ok = reg.Get("core_tool") + if !ok { + t.Error("Expected core tool to always be gettable") + } +} + +func TestBM25CacheInvalidation(t *testing.T) { + reg := NewToolRegistry() + reg.RegisterHidden(&mockSearchableTool{name: "tool_alpha", desc: "alpha functionality"}) + + tool := NewBM25SearchTool(reg, 5, 10) + ctx := context.Background() + + // First search should find tool_alpha + res := tool.Execute(ctx, map[string]any{"query": "alpha"}) + if !strings.Contains(res.ForLLM, "tool_alpha") { + t.Fatalf("Expected 'tool_alpha' in first search, got: %v", res.ForLLM) + } + + // Register a new hidden tool + reg.RegisterHidden(&mockSearchableTool{name: "tool_beta", desc: "beta functionality"}) + + // Cache should be invalidated; new tool should be findable + res = tool.Execute(ctx, map[string]any{"query": "beta"}) + if !strings.Contains(res.ForLLM, "tool_beta") { + t.Errorf("Expected 'tool_beta' after cache invalidation, got: %v", res.ForLLM) + } +} + +func TestPromoteTools_ConcurrentWithTickTTL(t *testing.T) { + reg := NewToolRegistry() + for i := 0; i < 20; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("concurrent_tool_%d", i), + desc: "concurrent test tool", + }) + } + + names := make([]string, 20) + for i := 0; i < 20; i++ { + names[i] = fmt.Sprintf("concurrent_tool_%d", i) + } + + // Hammer PromoteTools and TickTTL concurrently to detect races + done := make(chan struct{}) + go func() { + for i := 0; i < 1000; i++ { + reg.PromoteTools(names, 5) + } + close(done) + }() + + for i := 0; i < 1000; i++ { + reg.TickTTL() + } + <-done +} diff --git a/pkg/tools/skills_inspect.go b/pkg/tools/skills_inspect.go new file mode 100644 index 000000000..01b422c14 --- /dev/null +++ b/pkg/tools/skills_inspect.go @@ -0,0 +1,130 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/skills" +) + +// InspectSkillTool allows the LLM agent to inspect a skill's details before installation. +type InspectSkillTool struct { + registryMgr *skills.RegistryManager +} + +// NewInspectSkillTool creates a new InspectSkillTool. +func NewInspectSkillTool(registryMgr *skills.RegistryManager) *InspectSkillTool { + return &InspectSkillTool{ + registryMgr: registryMgr, + } +} + +func (t *InspectSkillTool) Name() string { + return "inspect_skill" +} + +func (t *InspectSkillTool) Description() string { + return "Retrieve in-depth information about a skill (slug, version, files, tools, permissions, moderation status) before installation. Use this for trust and safety review." +} + +func (t *InspectSkillTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "slug": map[string]any{ + "type": "string", + "description": "The unique identifier of the skill to inspect (e.g., 'agentbox-openrouter')", + }, + "registry": map[string]any{ + "type": "string", + "description": "Optional registry name (e.g., 'clawhub')", + }, + }, + "required": []string{"slug"}, + } +} + +func (t *InspectSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + slug, ok := args["slug"].(string) + if !ok || slug == "" { + return ErrorResult("slug is required") + } + + registryName, _ := args["registry"].(string) + + var reg skills.SkillRegistry + if registryName != "" { + reg = t.registryMgr.GetRegistry(registryName) + if reg == nil { + return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) + } + } else { + // Try to find the skill in any registry (simplification: just use the first available one for now or loop) + // Usually find_skills should provide the registry name. + // For robustness, if not provided, we can't easily guess which registry has it without searching. + // However, ClawHub is usually the default. + reg = t.registryMgr.GetRegistry("clawhub") + if reg == nil { + return ErrorResult("no skill registries available") + } + } + + details, err := reg.Inspect(ctx, slug) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to inspect skill: %v", err)) + } + + return SilentResult(formatSkillDetails(details)) +} + +func formatSkillDetails(d *skills.SkillDetails) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("### Skill Inspection: %s\n\n", d.Slug)) + sb.WriteString(fmt.Sprintf("- **Name:** %s\n", d.DisplayName)) + sb.WriteString(fmt.Sprintf("- **Version:** %s\n", d.Version)) + sb.WriteString(fmt.Sprintf("- **Author:** %s\n", d.Author)) + sb.WriteString(fmt.Sprintf("- **License:** %s\n", d.License)) + sb.WriteString(fmt.Sprintf("- **Registry:** %s\n", d.RegistryName)) + + if d.Summary != "" { + sb.WriteString(fmt.Sprintf("\n**Summary:**\n%s\n", d.Summary)) + } + + if d.Description != "" { + sb.WriteString(fmt.Sprintf("\n**Description:**\n%s\n", d.Description)) + } + + sb.WriteString("\n#### Safety & Trust:\n") + if d.IsMalwareBlocked { + sb.WriteString("- πŸ›‘οΈ **Clean:** No known malware detected.\n") + } else if d.IsSuspicious { + sb.WriteString("- ⚠️ **Suspicious:** This skill has been flagged for manual review.\n") + } else { + sb.WriteString("- ℹ️ **Status:** Verified according to registry standards.\n") + } + + if len(d.Permissions) > 0 { + sb.WriteString("\n**Requested Permissions:**\n") + for _, p := range d.Permissions { + sb.WriteString(fmt.Sprintf("- `%s`\n", p)) + } + } + + if len(d.Tools) > 0 { + sb.WriteString("\n**Tools Provided:**\n") + for _, t := range d.Tools { + sb.WriteString(fmt.Sprintf("- `%s`\n", t)) + } + } + + if len(d.Files) > 0 { + sb.WriteString("\n**Files:**\n") + for _, f := range d.Files { + sb.WriteString(fmt.Sprintf("- `%s`\n", f)) + } + } + + sb.WriteString("\n*Use `install_skill` to install this skill if you trust its contents.*") + return sb.String() +} diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index be40ffda2..e98484b97 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "strings" + + "github.com/sipeed/picoclaw/pkg/acp" ) type SpawnTool struct { @@ -42,7 +44,19 @@ func (t *SpawnTool) Parameters() map[string]any { }, "agent_id": map[string]any{ "type": "string", - "description": "Optional target agent ID to delegate the task to", + "description": "Optional target agent ID to delegate the task to (or the harness ID for ACP)", + }, + "runtime": map[string]any{ + "type": "string", + "description": "Execution runtime. Can be 'subagent' (default) or 'acp'. Use 'acp' for external harnesses like codex, gemini, etc.", + }, + "mode": map[string]any{ + "type": "string", + "description": "For ACP runtime: 'run' (one-shot) or 'session' (persistent)", + }, + "cwd": map[string]any{ + "type": "string", + "description": "Requested working directory for the subagent or ACP process", }, }, "required": []string{"task"}, @@ -79,13 +93,52 @@ func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCa } } + // Extract new parameters + runtime, _ := args["runtime"].(string) + if runtime == "" { + runtime = "subagent" + } + mode, _ := args["mode"].(string) + if mode == "" { + mode = "run" + } + cwd, _ := args["cwd"].(string) + + if runtime == "acp" { + // Verify agent_id mapping. E.g., agent_id=gemini might map to `gemini` executable. + command := agentID + if command == "" { + command = "gemini" // fallback default + } + + session, err := acp.GetManager().Spawn(agentID, mode, command, cwd, label, []string{}) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to spawn ACP session: %v", err)) + } + + // Send initial task + if err := session.Write(task); err != nil { + return ErrorResult(fmt.Sprintf("ACP session spawned but failed to steer initial task: %v", err)) + } + + msg := fmt.Sprintf("Spawned ACP session '%s' (key: %s)", command, session.Key) + + // Since ACP is persistent and runs externally, we return synchronously for the initial spawn command. + // Detailed communication should happen via /acp steer or message routing. + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + Silent: false, + IsError: false, + Async: false, + } + } + + // Normal Subagent Logic if t.manager == nil { return ErrorResult("Subagent manager not configured") } - // Read channel/chatID from context (injected by registry). - // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) - // to preserve the same defaults as the original NewSpawnTool constructor. channel := ToolChannel(ctx) if channel == "" { channel = "cli" diff --git a/pkg/tools/speech.go b/pkg/tools/speech.go new file mode 100644 index 000000000..05ddf1e4a --- /dev/null +++ b/pkg/tools/speech.go @@ -0,0 +1,147 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/media" +) + +// SynthesizerProvider is an interface to get a speech synthesizer. +type SynthesizerProvider interface { + GetSynthesizer(name string) (any, bool) +} + +// MediaStoreGetter is an interface to get the media store. +type MediaStoreGetter interface { + GetMediaStore() media.MediaStore +} + +// WorkspaceGetter is an interface to get the agent's workspace. +type WorkspaceGetter interface { + GetWorkspace() string +} + +// SpeechTool allows the agent to generate speech audio from text. +type SpeechTool struct { + manager any // AgentLoop/Manager +} + +// NewSpeechTool creates a new SpeechTool. +func NewSpeechTool(manager any) *SpeechTool { + return &SpeechTool{ + manager: manager, + } +} + +func (t *SpeechTool) Name() string { + return "speech" +} + +func (t *SpeechTool) Description() string { + return "Convert text to speech audio. Action: 'speak'. Generates an MP3 file and returns a media:// reference." +} + +func (t *SpeechTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"speak"}, + "description": "Action to perform.", + }, + "text": map[string]any{ + "type": "string", + "description": "The text to convert to speech.", + }, + "provider": map[string]any{ + "type": "string", + "description": "Optional: speech provider (e.g., 'elevenlabs'). Default is the system default.", + }, + }, + "required": []string{"action", "text"}, + } +} + +func (t *SpeechTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + text, _ := args["text"].(string) + providerName, _ := args["provider"].(string) + + if action != "speak" { + return ErrorResult(fmt.Sprintf("Unsupported action: %s", action)) + } + if text == "" { + return ErrorResult("text is required") + } + + // 1. Get Synthesizer + var synth any + if getter, ok := t.manager.(SynthesizerProvider); ok { + if s, found := getter.GetSynthesizer(providerName); found { + synth = s + } + } + + if synth == nil { + return ErrorResult("No speech synthesizer available.") + } + + // 2. Synthesize + type synthesizer interface { + Synthesize(ctx context.Context, text string) ([]byte, error) + } + + s, ok := synth.(synthesizer) + if !ok { + return ErrorResult("Internal error: invalid synthesizer instance.") + } + + audioData, err := s.Synthesize(ctx, text) + if err != nil { + return ErrorResult(fmt.Sprintf("Speech synthesis failed: %v", err)) + } + + // 3. Save to workspace and register in MediaStore + var workspace string + if wg, ok := t.manager.(WorkspaceGetter); ok { + workspace = wg.GetWorkspace() + } + if workspace == "" { + workspace = os.TempDir() + } + + mediaDir := filepath.Join(workspace, "media") + _ = os.MkdirAll(mediaDir, 0o755) + + filename := fmt.Sprintf("speech-%s.mp3", uuid.New().String()[:8]) + localPath := filepath.Join(mediaDir, filename) + + if err := os.WriteFile(localPath, audioData, 0o644); err != nil { + return ErrorResult(fmt.Errorf("failed to save audio file: %w", err).Error()) + } + + // 4. Register in MediaStore + var store media.MediaStore + if sg, ok := t.manager.(MediaStoreGetter); ok { + store = sg.GetMediaStore() + } + + if store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + ContentType: "audio/mpeg", + Source: "tool:speech", + }, "agent_session") // TODO: pass actual scope if available + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to register media: %v", err)) + } + return SilentResult(fmt.Sprintf("Speech generated successfully. Audio reference: %s", ref)) + } + + return SilentResult(fmt.Sprintf("Speech generated successfully. Saved to: %s", localPath)) +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index e51cbaafa..173524046 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -19,6 +19,7 @@ type SubagentTask struct { Status string Result string Created int64 + Type string // "default", "web_dev", "ui_designer", "researcher" } type SubagentManager struct { @@ -112,10 +113,28 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call task.Status = "running" task.Created = time.Now().UnixMilli() - // Build system prompt for subagent - systemPrompt := `You are a subagent. Complete the given task independently and report the result. -You have access to tools - use them as needed to complete your task. -After completing the task, provide a clear summary of what was done.` + // Build system prompt for subagent based on type + systemPrompt := "You are a subagent. Complete the given task independently and report the result." + + switch task.Type { + case "web_dev": + systemPrompt = `You are a specialized Web Development subagent. +Your goal is to build, debug, or improve web applications (HTML/CSS/JS). +You have access to a browser tool - use it to verify your work and take screenshots for the user. +Focus on clean code, responsiveness, and functional correctness.` + case "ui_designer": + systemPrompt = `You are a specialized UI/UX Design subagent. +Focus on aesthetics, layout, color theory, and user experience. +Use the browser and image tools to inspect designs and provide visual feedback or mockups. +Your goal is to make things look premium, modern, and high-quality.` + case "researcher": + systemPrompt = `You are a specialized Research subagent. +Your goal is to find deep, accurate, and synthesized information on the web. +Use search tools extensively and cross-reference multiple sources. +Provide detailed summaries with citations.` + } + + systemPrompt += "\nYou have access to tools - use them as needed to complete your task.\nAfter completing the task, provide a clear summary of what was done." messages := []providers.Message{ { @@ -149,6 +168,7 @@ After completing the task, provide a clear summary of what was done.` hasTemperature := sm.hasTemperature sm.mu.RUnlock() + var llmOptions map[string]any if hasMaxTokens || hasTemperature { llmOptions = map[string]any{} @@ -263,6 +283,11 @@ func (t *SubagentTool) Parameters() map[string]any { "type": "string", "description": "Optional short label for the task (for display)", }, + "type": map[string]any{ + "type": "string", + "enum": []string{"default", "web_dev", "ui_designer", "researcher"}, + "description": "Specialized agent type with custom system prompts and focuses.", + }, }, "required": []string{"task"}, } @@ -275,16 +300,29 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } label, _ := args["label"].(string) + agentType, _ := args["type"].(string) + if agentType == "" { + agentType = "default" + } if t.manager == nil { return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil")) } - // Build messages for subagent + systemPrompt := "You are a subagent. Complete the given task independently and provide a clear, concise result." + switch agentType { + case "web_dev": + systemPrompt = "You are a specialized Web Development subagent. Build, debug, or improve web applications. Use the browser to verify work." + case "ui_designer": + systemPrompt = "You are a specialized UI/UX Design subagent. Focus on aesthetics and premium visual quality." + case "researcher": + systemPrompt = "You are a specialized Research subagent. Find deep, accurate, and synthesized information." + } + messages := []providers.Message{ { Role: "system", - Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", + Content: systemPrompt, }, { Role: "user", @@ -292,7 +330,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe }, } - // Use RunToolLoop to execute with tools (same as async SpawnTool) sm := t.manager sm.mu.RLock() tools := sm.tools @@ -314,8 +351,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } } - // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) - // to preserve the same defaults as the original NewSubagentTool constructor. channel := ToolChannel(ctx) if channel == "" { channel = "cli" @@ -336,14 +371,12 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) } - // ForUser: Brief summary for user (truncated if too long) userContent := loopResult.Content maxUserLen := 500 if len(userContent) > maxUserLen { userContent = userContent[:maxUserLen] + "..." } - // ForLLM: Full execution details labelStr := label if labelStr == "" { labelStr = "(unnamed)" diff --git a/pkg/tools/voicecall.go b/pkg/tools/voicecall.go new file mode 100644 index 000000000..99926569b --- /dev/null +++ b/pkg/tools/voicecall.go @@ -0,0 +1,108 @@ +package tools + +import ( + "context" + "fmt" +) + +type VoiceCallTool struct{} + +// Compile-time check +var _ Tool = (*VoiceCallTool)(nil) + +func NewVoiceCallTool() *VoiceCallTool { + return &VoiceCallTool{} +} + +func (t *VoiceCallTool) Name() string { + return "voice_call" +} + +func (t *VoiceCallTool) Description() string { + return "Manage outbound and inbound voice calls via Twilio or Telnyx. Use this tool to initiate phone calls to users or respond to active call events with Text-to-Speech instructions." +} + +func (t *VoiceCallTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"initiate_call", "speak_to_user", "end_call", "get_status"}, + "description": "The action to perform on the voice subsystem.", + }, + "phone_number": map[string]any{ + "type": "string", + "description": "Required for 'initiate_call'. The E.164 phone number to call.", + }, + "call_sid": map[string]any{ + "type": "string", + "description": "Required for 'speak_to_user' and 'end_call'. The unique call session ID.", + }, + "text": map[string]any{ + "type": "string", + "description": "Required for 'speak_to_user'. The text to synthesize into speech for the user to hear.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *VoiceCallTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "initiate_call": + number, _ := args["phone_number"].(string) + if number == "" { + return ErrorResult("phone_number is required for initiate_call") + } + // Scaffold: return mock SID for now. Next step requires Twilio client integration. + msg := fmt.Sprintf("Initiated outbound call to %s. Call SID: mock_sid_12345", number) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + case "speak_to_user": + sid, _ := args["call_sid"].(string) + text, _ := args["text"].(string) + if sid == "" || text == "" { + return ErrorResult("call_sid and text are required for speak_to_user") + } + // Scaffold: Update active call state to play TwiML <Say> on next webhook poll. + msg := fmt.Sprintf("Queued speech for Call %s. Text: %s", sid, text) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + case "end_call": + sid, _ := args["call_sid"].(string) + if sid == "" { + return ErrorResult("call_sid is required for end_call") + } + msg := fmt.Sprintf("Ended call %s", sid) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + case "get_status": + sid, _ := args["call_sid"].(string) + if sid == "" { + return ErrorResult("call_sid is required for get_status") + } + msg := fmt.Sprintf("Call %s status: in-progress (scaffold)", sid) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + default: + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) + } +} diff --git a/pkg/tools/vps.go b/pkg/tools/vps.go new file mode 100644 index 000000000..1da85a026 --- /dev/null +++ b/pkg/tools/vps.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "golang.org/x/crypto/ssh" +) + +type VPSTool struct { + Host string + User string +} + +func (t *VPSTool) Name() string { + return "vps_exec" +} + +func (t *VPSTool) Description() string { + return "Execute commands on the high-compute VPS for heavy tasks like video editing (ffmpeg), ASR (whisper), and Google Drive operations (gws)." +} + +func (t *VPSTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "The shell command to execute on the VPS.", + }, + }, + "required": []string{"command"}, + } +} + +func (t *VPSTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + cmdStr, _ := args["command"].(string) + if cmdStr == "" { + return ErrorResult("Command is required") + } + + cred, err := auth.GetCredential("vps") + if err != nil || cred == nil || cred.AccessToken == "" { + return ErrorResult("VPS credentials not found. Please set them using the vps provider.") + } + + config := &ssh.ClientConfig{ + User: t.User, + Auth: []ssh.AuthMethod{ + ssh.Password(cred.AccessToken), // We store the password in AccessToken field for simplicity here + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 15 * time.Second, + } + + client, err := ssh.Dial("tcp", t.Host+":22", config) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to connect to VPS: %v", err)) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to create SSH session: %v", err)) + } + defer session.Close() + + output, err := session.CombinedOutput(cmdStr) + if err != nil { + if err == io.EOF { + return SilentResult(string(output)) + } + return ErrorResult(fmt.Sprintf("VPS execution failed: %v\nOutput: %s", err, string(output))) + } + + return SilentResult(string(output)) +} diff --git a/pkg/tools/whatsapp.go b/pkg/tools/whatsapp.go new file mode 100644 index 000000000..1976b9437 --- /dev/null +++ b/pkg/tools/whatsapp.go @@ -0,0 +1,125 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// HistoryProvider matches the interface defined in pkg/channels/interfaces.go +type HistoryProvider interface { + FetchHistory(ctx context.Context, chatID string, limit int) ([]bus.InboundMessage, error) +} + +// ChannelManagerGetter is an interface to get a channel by name. +type ChannelManagerGetter interface { + GetChannel(name string) (any, bool) +} + +// WhatsAppTool allows the agent to fetch message history from WhatsApp. +type WhatsAppTool struct { + manager ChannelManagerGetter +} + +// NewWhatsAppTool creates a new WhatsAppTool. +func NewWhatsAppTool(manager ChannelManagerGetter) *WhatsAppTool { + return &WhatsAppTool{ + manager: manager, + } +} + +func (t *WhatsAppTool) Name() string { + return "whatsapp" +} + +func (t *WhatsAppTool) Description() string { + return "Interact with WhatsApp. Actions: 'list_messages', 'sync'. Use 'list_messages' to fetch recent chat history, and 'sync' to save the latest messages from a chat into the agent's contextual memory." +} + +func (t *WhatsAppTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"list_messages", "sync"}, + "description": "Action to perform.", + }, + "chat_id": map[string]any{ + "type": "string", + "description": "The WhatsApp JID or phone number (e.g., '1234567890@s.whatsapp.net' or a group JID).", + }, + "limit": map[string]any{ + "type": "integer", + "default": 10, + "description": "Number of messages to retrieve (max 50).", + }, + }, + "required": []string{"action", "chat_id"}, + } +} + +func (t *WhatsAppTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + chatID, _ := args["chat_id"].(string) + if chatID == "" { + return ErrorResult("chat_id is required") + } + + limit := 10 + if l, ok := args["limit"].(float64); ok { + limit = int(l) + } + if limit > 50 { + limit = 50 + } + + // Try to find the whatsapp_native channel + var hp HistoryProvider + if ch, ok := t.manager.GetChannel("whatsapp_native"); ok { + if provider, ok := ch.(HistoryProvider); ok { + hp = provider + } + } + + if hp == nil { + return ErrorResult("WhatsApp history retrieval is not supported (requires whatsapp_native).") + } + + messages, err := hp.FetchHistory(ctx, chatID, limit) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to fetch WhatsApp history: %v", err)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("WhatsApp History for %s:\n\n", chatID)) + for _, m := range messages { + sender := "Me" + if m.SenderID == chatID { + sender = "Contact" + } + sb.WriteString(fmt.Sprintf("%s: %s\n", sender, m.Content)) + } + + resultText := sb.String() + + if action == "sync" { + if getter, ok := t.manager.(interface{ GetMemoryStore() any }); ok { + if ms := getter.GetMemoryStore(); ms != nil { + if writer, ok := ms.(interface{ AppendCommunications(string) error }); ok { + err := writer.AppendCommunications(fmt.Sprintf("WhatsApp Sync (%s) @ %s:\n%s", chatID, time.Now().Format("2006-01-02 15:04"), resultText)) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to sync to memory: %v", err)) + } + return SilentResult("WhatsApp chat history synced to contextual memory.") + } + } + } + return ErrorResult("Memory store not available for sync.") + } + + return SilentResult(resultText) +} diff --git a/pkg/utils/bm25.go b/pkg/utils/bm25.go new file mode 100644 index 000000000..95c63f0e3 --- /dev/null +++ b/pkg/utils/bm25.go @@ -0,0 +1,272 @@ +// Package utils provides shared, reusable algorithms. +// This file implements a generic BM25 search engine. +// +// Usage: +// +// type MyDoc struct { ID string; Body string } +// +// corpus := []MyDoc{...} +// engine := bm25.New(corpus, func(d MyDoc) string { +// return d.ID + " " + d.Body +// }) +// results := engine.Search("my query", 5) +package utils + +import ( + "math" + "sort" + "strings" +) + +// ── Tuning defaults ─────────────────────────────────────────────────────────── + +const ( + // DefaultBM25K1 is the term-frequency saturation factor (typical range 1.2–2.0). + // Higher values give more weight to repeated terms. + DefaultBM25K1 = 1.2 + + // DefaultBM25B is the document-length normalization factor (0 = none, 1 = full). + DefaultBM25B = 0.75 +) + +// BM25Engine is a query-time BM25 search engine over a generic corpus. +// T is the document type; the caller supplies a TextFunc that extracts the +// searchable text from each document. +// +// The engine is stateless between queries: no caching, no invalidation logic. +// All indexing work is performed inside Search() on every call, making it +// safe to use on corpora that change frequently. +type BM25Engine[T any] struct { + corpus []T + textFunc func(T) string + k1 float64 + b float64 +} + +// BM25Option is a functional option to configure a BM25Engine. +type BM25Option func(*bm25Config) + +type bm25Config struct { + k1 float64 + b float64 +} + +// WithK1 overrides the term-frequency saturation constant (default 1.2). +func WithK1(k1 float64) BM25Option { + return func(c *bm25Config) { c.k1 = k1 } +} + +// WithB overrides the document-length normalization factor (default 0.75). +func WithB(b float64) BM25Option { + return func(c *bm25Config) { c.b = b } +} + +// NewBM25Engine creates a BM25Engine for the given corpus. +// +// - corpus : slice of documents of any type T. +// - textFunc : function that returns the searchable text for a document. +// - opts : optional tuning (WithK1, WithB). +// +// The corpus slice is referenced, not copied. Callers must not mutate it +// concurrently with Search(). +func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Option) *BM25Engine[T] { + cfg := bm25Config{k1: DefaultBM25K1, b: DefaultBM25B} + for _, o := range opts { + o(&cfg) + } + return &BM25Engine[T]{ + corpus: corpus, + textFunc: textFunc, + k1: cfg.k1, + b: cfg.b, + } +} + +// BM25Result is a single ranked result from a Search call. +type BM25Result[T any] struct { + Document T + Score float32 +} + +// Search ranks the corpus against query and returns the top-k results. +// Returns an empty slice (not nil) when there are no matches. +// +// Complexity: O(NΓ—L) for indexing + O(|Q|Γ—avgPostingLen) for scoring, +// where N = corpus size, L = average document length, Q = query terms. +// Top-k extraction uses a fixed-size min-heap: O(candidates Γ— log k). +func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { + if topK <= 0 { + return []BM25Result[T]{} + } + + queryTerms := bm25Tokenize(query) + if len(queryTerms) == 0 { + return []BM25Result[T]{} + } + + N := len(e.corpus) + if N == 0 { + return []BM25Result[T]{} + } + + // Step 1: build per-document tf + raw doc lengths + type docEntry struct { + tf map[string]uint32 + rawLen int + } + + entries := make([]docEntry, N) + df := make(map[string]int, 64) + totalLen := 0 + + for i, doc := range e.corpus { + tokens := bm25Tokenize(e.textFunc(doc)) + totalLen += len(tokens) + + tf := make(map[string]uint32, len(tokens)) + for _, t := range tokens { + tf[t]++ + } + // df: each term counts once per document (iterate the map, keys are unique) + for t := range tf { + df[t]++ + } + + entries[i] = docEntry{tf: tf, rawLen: len(tokens)} + } + + avgDocLen := float64(totalLen) / float64(N) + + // Step 2: pre-compute IDF and per-doc length normalization + // IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 ) + idf := make(map[string]float32, len(df)) + for term, freq := range df { + idf[term] = float32(math.Log( + (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1, + )) + } + + // docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen) + // Stored as float32 β€” sufficient precision for ranking. + docLenNorm := make([]float32, N) + for i, entry := range entries { + docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen)) + } + + // Step 3: build inverted index (posting lists) + // Iterate the tf map directly β€” map keys are already unique, no seen-set needed. + posting := make(map[string][]int32, len(df)) + for i, entry := range entries { + for term := range entry.tf { + posting[term] = append(posting[term], int32(i)) + } + } + + // Step 4: score via posting lists + // Deduplicate query terms to avoid double-weighting the same term. + unique := bm25Dedupe(queryTerms) + + scores := make(map[int32]float32) + for _, term := range unique { + termIDF, ok := idf[term] + if !ok { + continue // term not in vocabulary β†’ zero contribution + } + for _, docID := range posting[term] { + freq := float32(entries[docID].tf[term]) + // TF_norm = freq * (k1+1) / (freq + docLenNorm) + tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID]) + scores[docID] += termIDF * tfNorm + } + } + + if len(scores) == 0 { + return []BM25Result[T]{} + } + + // Step 5: top-K via fixed-size min-heap + heap := make([]bm25ScoredDoc, 0, topK) + + for docID, sc := range scores { + switch { + case len(heap) < topK: + heap = append(heap, bm25ScoredDoc{docID: docID, score: sc}) + if len(heap) == topK { + bm25MinHeapify(heap) + } + case sc > heap[0].score: + heap[0] = bm25ScoredDoc{docID: docID, score: sc} + bm25SiftDown(heap, 0) + } + } + + sort.Slice(heap, func(i, j int) bool { return heap[i].score > heap[j].score }) + + out := make([]BM25Result[T], len(heap)) + for i, h := range heap { + out[i] = BM25Result[T]{ + Document: e.corpus[h.docID], + Score: h.score, + } + } + return out +} + +// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation. +func bm25Tokenize(s string) []string { + raw := strings.Fields(strings.ToLower(s)) + out := raw[:0] // reuse backing array to avoid extra allocation + for _, t := range raw { + t = strings.Trim(t, ".,;:!?\"'()/\\-_") + if t != "" { + out = append(out, t) + } + } + return out +} + +// bm25Dedupe returns a new slice with duplicate tokens removed, +// preserving first-occurrence order. +func bm25Dedupe(tokens []string) []string { + seen := make(map[string]struct{}, len(tokens)) + out := make([]string, 0, len(tokens)) + for _, t := range tokens { + if _, ok := seen[t]; !ok { + seen[t] = struct{}{} + out = append(out, t) + } + } + return out +} + +type bm25ScoredDoc struct { + docID int32 + score float32 +} + +// bm25MinHeapify builds a min-heap in-place using Floyd's algorithm: O(k). +func bm25MinHeapify(h []bm25ScoredDoc) { + for i := len(h)/2 - 1; i >= 0; i-- { + bm25SiftDown(h, i) + } +} + +// bm25SiftDown restores the min-heap property starting at node i: O(log k). +func bm25SiftDown(h []bm25ScoredDoc, i int) { + n := len(h) + for { + smallest := i + l, r := 2*i+1, 2*i+2 + if l < n && h[l].score < h[smallest].score { + smallest = l + } + if r < n && h[r].score < h[smallest].score { + smallest = r + } + if smallest == i { + break + } + h[i], h[smallest] = h[smallest], h[i] + i = smallest + } +} diff --git a/pkg/utils/bm25_test.go b/pkg/utils/bm25_test.go new file mode 100644 index 000000000..4bc85b246 --- /dev/null +++ b/pkg/utils/bm25_test.go @@ -0,0 +1,175 @@ +package utils + +import ( + "reflect" + "testing" +) + +// testDoc is a generic structure for use in tests. +type testDoc struct { + ID int + Text string +} + +func extractText(d testDoc) string { + return d.Text +} + +func TestBM25Search_EdgeCases(t *testing.T) { + corpus := []testDoc{ + {1, "hello world"}, + {2, "foo bar"}, + } + engine := NewBM25Engine(corpus, extractText) + + tests := []struct { + name string + query string + topK int + }{ + {"Zero topK", "hello", 0}, + {"Negative topK", "hello", -1}, + {"Empty query", "", 5}, + {"Query with only punctuation", "...,,,!!!", 5}, + {"No matches found", "golang", 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := engine.Search(tt.query, tt.topK) + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } + // Check that it never returns nil, but an empty slice + if results == nil { + t.Errorf("expected empty slice, got nil") + } + }) + } +} + +func TestBM25Search_EmptyCorpus(t *testing.T) { + engine := NewBM25Engine([]testDoc{}, extractText) + results := engine.Search("hello", 5) + if len(results) != 0 || results == nil { + t.Errorf("expected empty slice from empty corpus, got %v", results) + } +} + +func TestBM25Search_RankingLogic(t *testing.T) { + corpus := []testDoc{ + {1, "the quick brown fox jumps over the lazy dog"}, + {2, "quick fox"}, + {3, "quick quick quick fox"}, // High Term Frequency (TF) + {4, "completely irrelevant document here"}, + } + engine := NewBM25Engine(corpus, extractText) + + t.Run("Term Frequency (TF) boosts score", func(t *testing.T) { + results := engine.Search("quick", 5) + if len(results) < 3 { + t.Fatalf("expected at least 3 results, got %d", len(results)) + } + // Doc 3 has the word "quick" repeated 3 times, it should beat Doc 2 + if results[0].Document.ID != 3 { + t.Errorf("expected doc 3 to rank first due to high TF, got doc %d", results[0].Document.ID) + } + }) + + t.Run("Document Length penalty", func(t *testing.T) { + results := engine.Search("fox", 5) + if len(results) < 3 { + t.Fatalf("expected at least 3 results, got %d", len(results)) + } + // Doc 2 ("quick fox") is much shorter than Doc 1 ("the quick brown fox..."), + // so, with equal Term Frequency for the word "fox" (1 time), Doc 2 wins. + if results[0].Document.ID != 2 { + t.Errorf("expected doc 2 to rank first due to shorter length, got doc %d", results[0].Document.ID) + } + }) + + t.Run("TopK limits results", func(t *testing.T) { + results := engine.Search("quick", 2) + if len(results) != 2 { + t.Errorf("expected exactly 2 results, got %d", len(results)) + } + }) +} + +func TestBM25Tokenize(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"Hello World", []string{"hello", "world"}}, + {" spaces everywhere ", []string{"spaces", "everywhere"}}, + {"punctuation... test!!!", []string{"punctuation", "test"}}, + {"(parentheses) and-hyphens", []string{"parentheses", "and-hyphens"}}, // hyphens trimmed from edges + {"internal-hyphen is kept", []string{"internal-hyphen", "is", "kept"}}, + {".,;?!", []string{}}, // Becomes empty after trim + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := bm25Tokenize(tt.input) + if len(got) == 0 && len(tt.expected) == 0 { + return // Both empty + } + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("bm25Tokenize(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestBM25Dedupe(t *testing.T) { + input := []string{"apple", "banana", "apple", "orange", "banana"} + expected := []string{"apple", "banana", "orange"} + + got := bm25Dedupe(input) + if !reflect.DeepEqual(got, expected) { + t.Errorf("bm25Dedupe() = %v, want %v", got, expected) + } +} + +func TestBM25Options(t *testing.T) { + corpus := []testDoc{{1, "test"}} + + engine := NewBM25Engine( + corpus, + extractText, + WithK1(2.5), + WithB(0.9), + ) + + if engine.k1 != 2.5 { + t.Errorf("expected k1 to be 2.5, got %v", engine.k1) + } + if engine.b != 0.9 { + t.Errorf("expected b to be 0.9, got %v", engine.b) + } +} + +func TestBM25Search_SortingStability(t *testing.T) { + // Ensure that sorting by heap returns in correct descending order + corpus := []testDoc{ + {1, "golang is good"}, + {2, "golang golang"}, + {3, "golang golang golang"}, + {4, "golang golang golang golang"}, + } + engine := NewBM25Engine(corpus, extractText) + results := engine.Search("golang", 10) + + if len(results) != 4 { + t.Fatalf("expected 4 results, got %d", len(results)) + } + + // Score should be strictly decreasing + for i := 1; i < len(results); i++ { + if results[i].Score > results[i-1].Score { + t.Errorf("results not sorted correctly: result %d score (%v) > result %d score (%v)", + i, results[i].Score, i-1, results[i-1].Score) + } + } +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index c250724d1..5243b7485 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -63,4 +63,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Launcher service parameters (port/public) h.registerLauncherConfigRoutes(mux) + + // Voice Subsystem Webhooks + mux.HandleFunc("/webhook/voice", h.VoiceWebhookHandler) } diff --git a/web/backend/api/voice_webhook.go b/web/backend/api/voice_webhook.go new file mode 100644 index 000000000..3aab1fc6e --- /dev/null +++ b/web/backend/api/voice_webhook.go @@ -0,0 +1,26 @@ +package api + +import ( + "encoding/json" + "net/http" + "log" +) + +// VoiceWebhookHandler receives incoming Twilio TwiML or Telnyx Call Control events +// and routes them to the active Agent session if a call is in progress. +func (h *Handler) VoiceWebhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // This is a minimal scaffold for the reverse webhook. + // You would typically parse `r.ParseForm()` for Twilio or `json.NewDecoder` for Telnyx. + + // Mock responding with empty TwiML or 200 OK. + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>PicoClaw Voice Subsystem Active.</Say></Response>`)) + + log.Printf("Received voice webhook event from %s", r.RemoteAddr) +} diff --git a/web/backend/main.go b/web/backend/main.go index b8c4dc2bb..4ec748d9e 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -123,7 +123,7 @@ func main() { // Apply middleware stack handler := middleware.Recoverer( middleware.Logger( - middleware.JSONContentType(accessControlledMux), + middleware.JSONContentType(middleware.ProxyAuth(accessControlledMux)), ), ) diff --git a/web/backend/middleware/access_control.go b/web/backend/middleware/access_control.go index 159d60c3e..79c7021ff 100644 --- a/web/backend/middleware/access_control.go +++ b/web/backend/middleware/access_control.go @@ -62,3 +62,27 @@ func rejectByPolicy(w http.ResponseWriter, r *http.Request) { } http.Error(w, "Forbidden", http.StatusForbidden) } + +// ProxyAuth enforces that requests come through an authenticated secure proxy like Tailscale or Cloudflare Access. +// Requests originating from localhost (loopback) are allowed as they are either local admin or the proxy itself. +func ProxyAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIPFromRemoteAddr(r.RemoteAddr) + if ip != nil && ip.IsLoopback() { + next.ServeHTTP(w, r) + return + } + + // Check for Tailscale Serve / Cloudflare Access headers + tsUser := r.Header.Get("Tailscale-User-Login") + cfUser := r.Header.Get("Cf-Access-Authenticated-User-Email") + + if tsUser == "" && cfUser == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorized: Application must be accessed via Tailscale or Cloudflare Access.")) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/workspace/skills/google_sync/SKILL.md b/workspace/skills/google_sync/SKILL.md new file mode 100644 index 000000000..1095b126d --- /dev/null +++ b/workspace/skills/google_sync/SKILL.md @@ -0,0 +1,38 @@ +--- +name: google_sync +description: Synchronize Google services (Email, Calendar) with PicoClaw +--- + +# Google Sync Skill + +This skill allows the agent to synchronize and manage user's Google services. + +## Available Tools + +### `google` +Provides access to Gmail and Google Calendar. +- `action="list_emails"`: Fetches recent emails. +- `action="list_events"`: Fetches upcoming calendar events. + +## Periodic Synchronization + +To keep the agent's knowledge up-to-date, use the `cron` tool to schedule periodic sync tasks. + +### Example: Sync Every 4 Hours +Call `cron` tool: +```json +{ + "action": "add", + "message": "Update my knowledge of recent emails and calendar events using the google tool.", + "every_seconds": 14400, + "deliver": false +} +``` + +## Self-Syncing Implementation + +When triggered by cron, the agent should: +1. Call `google(action="list_emails")` +2. Call `google(action="list_events")` +3. Summarize the findings. +4. Update its long-term knowledge or session summary. diff --git a/workspace/skills/video_editor/SKILL.md b/workspace/skills/video_editor/SKILL.md new file mode 100644 index 000000000..0828c5902 --- /dev/null +++ b/workspace/skills/video_editor/SKILL.md @@ -0,0 +1,47 @@ +# Video Editor Skill + +This skill automates the process of retrieving video footage, generating subtitles, and performing logical edits based on audio and caption content. + +## Workflow + +1. **Retrieve Footage**: + - Use `vps_exec` to run `gws download <file_id>` on the VPS. + - Verify the file is downloaded to the VPS workspace. + +2. **Isolate Audio**: + - Use `vps_exec` with `ffmpeg` to extract audio: + `ffmpeg -i input.mp4 -vn -acodec pcm_s16le -ar 16000 -ac 1 output.wav` + +3. **Generate Subtitles (.srt)**: + - **For Long Gameplay (2-5 hours)**: Do NOT use managed APIs due to duration limits and costs. Use local offline processing. + - Run `whisper.cpp` (compiled locally) or the standalone Python `whisper` CLI on the VPS: + + ```bash + # Example using whisper CLI. Consider chunking the WAV if memory is constrained. + vps_exec "whisper output.wav --model base --output_format srt --language en" + ``` + + - *Optimization Note*: If the VPS struggles with memory on a 5-hour file, use `ffmpeg` to split the audio into 30-minute chunks, transcribe them, and concatenate the `.srt` files adjusting timestamps accordingly. + - Clean up subtitles if needed for Vegas 23 compatibility. + +4. **Logical Cutting**: + - Analyze the `.srt` and audio for silence or specific keywords (e.g., "boss", "death"). + - Generate complex `ffmpeg` filter scripts or concat files based on timestamps. + - Taxonomy for markers: + - Boss Fights: `start_time`, `end_time`, `boss_name`, `result`. + - Player Death: `timestamp`, `cause_of_death`. + - Progression: `timestamp`, `item_acquired / milestone_reached`. + - Transitions: `timestamp`, `biome_change`. + - Events: `timestamp`, `event_name`. + +5. **Execute Edits**: + - Run the final `ffmpeg` command on the VPS to produce the edited video and audio package. + +6. **Upload to Google Drive**: + - Use `vps_exec` to run `gws upload <edited_video>` to sync the results back to Google Drive. + +## Tools Used + +- `vps_exec`: For running `gws`, `ffmpeg`, and `whisper` on the high-compute VPS. +- `google`: For listing Drive files if needed (though `gws` is preferred on VPS). +- `read_file` / `write_file`: For local log/metadata management.