Merge origin/main into Orange Pi backup branch and resolve conflicts

This commit is contained in:
Bernardo 2026-03-14 14:21:40 +01:00
commit 8654face02
63 changed files with 5971 additions and 403 deletions

View file

@ -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 <<EOF
Nightly build for **${TITLE}**
This is an automated build and may be unstable. Use with caution.
${CHANGELOG}
EOF
)
# Download assets from the newly created release if it exists,
# otherwise fall back to using locally built dist/ artifacts.
mkdir -p build
if gh release view "$TAG" >/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

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View file

@ -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)

View file

@ -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)
}

View file

@ -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,

10
debug_home.go Normal file
View file

@ -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"))
}

View file

@ -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.<name>.*`) is configured in `config.json` rather than environment variables.
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
environment variables.

20
go.mod
View file

@ -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
)

41
go.sum
View file

@ -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=

78
pkg/acp/manager.go Normal file
View file

@ -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
}

148
pkg/acp/session.go Normal file
View file

@ -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:<agentId>:acp:<uuid>
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"
}

View file

@ -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")

68
pkg/agent/proactive.go Normal file
View file

@ -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
}

84
pkg/agent/tools_filter.go Normal file
View file

@ -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
}

View file

@ -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 {

View file

@ -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)

View file

@ -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().

View file

@ -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)
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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
}

63
pkg/commands/cmd_acp.go Normal file
View file

@ -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 <action> [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 <action> [args...]")
}
action := parts[1]
switch action {
case "spawn":
if len(parts) < 3 {
return req.Reply("usage: /acp spawn <harness_id>")
}
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 <session_key>")
}
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))
}
},
}
}

181
pkg/commands/cmd_google.go Normal file
View file

@ -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 <project-id>",
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 <project-id>")
}
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))
},
}
}

372
pkg/commands/cmd_gws.go Normal file
View file

@ -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 <query>|read <id>|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 <query>` — search by subject/sender/content\n" +
"• `/gmail unread` — unread messages only\n" +
"• `/gmail read <message-id>` — 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 <query>")
}
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 <message-id>")
}
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 <query>|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 <query>` — 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 <query>")
}
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 <title>|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] + "..."
}

View file

@ -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- "))
},
}
}

116
pkg/commands/cmd_system.go Normal file
View file

@ -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.")
},
}
}

View file

@ -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.")
},
}
}

View file

@ -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
}

View file

@ -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":

View file

@ -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,

View file

@ -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.

327
pkg/gws/client.go Normal file
View file

@ -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"
}
}

View file

@ -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")

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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,
},
})
}
}

22
pkg/providers/registry.go Normal file
View file

@ -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.
}

View file

@ -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
}

View file

@ -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,

View file

@ -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.

172
pkg/tools/browser.go Normal file
View file

@ -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()
}
}

391
pkg/tools/google.go Normal file
View file

@ -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
}

171
pkg/tools/image.go Normal file
View file

@ -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})
}

186
pkg/tools/pdf.go Normal file
View file

@ -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),
))
}

View file

@ -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
}

304
pkg/tools/search_tool.go Normal file
View file

@ -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
}

View file

@ -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
}

130
pkg/tools/skills_inspect.go Normal file
View file

@ -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()
}

View file

@ -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"

147
pkg/tools/speech.go Normal file
View file

@ -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))
}

View file

@ -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)"

108
pkg/tools/voicecall.go Normal file
View file

@ -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))
}
}

80
pkg/tools/vps.go Normal file
View file

@ -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))
}

125
pkg/tools/whatsapp.go Normal file
View file

@ -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)
}

272
pkg/utils/bm25.go Normal file
View file

@ -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.22.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
}
}

175
pkg/utils/bm25_test.go Normal file
View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -123,7 +123,7 @@ func main() {
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(accessControlledMux),
middleware.JSONContentType(middleware.ProxyAuth(accessControlledMux)),
),
)

View file

@ -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)
})
}

View file

@ -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.

View file

@ -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.