Merge origin/main into Orange Pi backup branch and resolve conflicts
This commit is contained in:
commit
8654face02
63 changed files with 5971 additions and 403 deletions
205
.github/workflows/nightly.yml
vendored
205
.github/workflows/nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
10
debug_home.go
Normal 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"))
|
||||
}
|
||||
|
|
@ -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
20
go.mod
|
|
@ -7,12 +7,16 @@ require (
|
|||
github.com/anthropics/anthropic-sdk-go v1.22.1
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/chromedp/chromedp v0.10.0
|
||||
github.com/chzyer/readline v1.5.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/ergochat/irc-go v0.5.0
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.2
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||
|
|
@ -35,13 +39,24 @@ require (
|
|||
require (
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
|
|
@ -57,6 +72,7 @@ require (
|
|||
go.mau.fi/libsignal v0.2.1 // indirect
|
||||
go.mau.fi/util v0.9.6 // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
@ -89,8 +105,8 @@ require (
|
|||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
|
|
|
|||
41
go.sum
41
go.sum
|
|
@ -27,6 +27,12 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
|
|||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 h1:bATMoZLH2QGct1kzDxfmeBUQI/QhQvB0mBrOTct+YlQ=
|
||||
github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E=
|
||||
github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
|
|
@ -44,6 +50,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
|
|
@ -52,12 +60,22 @@ github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw
|
|||
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
|
|
@ -65,6 +83,12 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
|||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
|
|
@ -107,6 +131,10 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
|
|||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.2 h1:STdTJmpkm0u4wJRHoM/LWKftam+x66MfVk6cEs+fMvc=
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.2/go.mod h1:RF/RGAP0AS4rd9fVZ6gb7Lbw6178P/AdAxMRW8Kn/Vk=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
|
|
@ -123,8 +151,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
|
|
@ -154,6 +188,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
|
|||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
|
|
@ -250,6 +286,8 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
|||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
|
|
@ -269,8 +307,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
|
|
@ -306,6 +342,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
|
|
|||
78
pkg/acp/manager.go
Normal file
78
pkg/acp/manager.go
Normal 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
148
pkg/acp/session.go
Normal 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"
|
||||
}
|
||||
|
|
@ -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
68
pkg/agent/proactive.go
Normal 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
84
pkg/agent/tools_filter.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
63
pkg/commands/cmd_acp.go
Normal 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
181
pkg/commands/cmd_google.go
Normal 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
372
pkg/commands/cmd_gws.go
Normal 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] + "..."
|
||||
}
|
||||
|
|
@ -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
116
pkg/commands/cmd_system.go
Normal 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.")
|
||||
},
|
||||
}
|
||||
}
|
||||
48
pkg/commands/cmd_whatsapp.go
Normal file
48
pkg/commands/cmd_whatsapp.go
Normal 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.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
327
pkg/gws/client.go
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
343
pkg/providers/builtin_resolvers.go
Normal file
343
pkg/providers/builtin_resolvers.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
22
pkg/providers/registry.go
Normal 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.
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
172
pkg/tools/browser.go
Normal 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
391
pkg/tools/google.go
Normal 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
171
pkg/tools/image.go
Normal 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
186
pkg/tools/pdf.go
Normal 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),
|
||||
))
|
||||
}
|
||||
|
|
@ -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
304
pkg/tools/search_tool.go
Normal 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
|
||||
}
|
||||
339
pkg/tools/search_tools_test.go
Normal file
339
pkg/tools/search_tools_test.go
Normal 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
130
pkg/tools/skills_inspect.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
147
pkg/tools/speech.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
108
pkg/tools/voicecall.go
Normal 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
80
pkg/tools/vps.go
Normal 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
125
pkg/tools/whatsapp.go
Normal 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
272
pkg/utils/bm25.go
Normal 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.2–2.0).
|
||||
// Higher values give more weight to repeated terms.
|
||||
DefaultBM25K1 = 1.2
|
||||
|
||||
// DefaultBM25B is the document-length normalization factor (0 = none, 1 = full).
|
||||
DefaultBM25B = 0.75
|
||||
)
|
||||
|
||||
// BM25Engine is a query-time BM25 search engine over a generic corpus.
|
||||
// T is the document type; the caller supplies a TextFunc that extracts the
|
||||
// searchable text from each document.
|
||||
//
|
||||
// The engine is stateless between queries: no caching, no invalidation logic.
|
||||
// All indexing work is performed inside Search() on every call, making it
|
||||
// safe to use on corpora that change frequently.
|
||||
type BM25Engine[T any] struct {
|
||||
corpus []T
|
||||
textFunc func(T) string
|
||||
k1 float64
|
||||
b float64
|
||||
}
|
||||
|
||||
// BM25Option is a functional option to configure a BM25Engine.
|
||||
type BM25Option func(*bm25Config)
|
||||
|
||||
type bm25Config struct {
|
||||
k1 float64
|
||||
b float64
|
||||
}
|
||||
|
||||
// WithK1 overrides the term-frequency saturation constant (default 1.2).
|
||||
func WithK1(k1 float64) BM25Option {
|
||||
return func(c *bm25Config) { c.k1 = k1 }
|
||||
}
|
||||
|
||||
// WithB overrides the document-length normalization factor (default 0.75).
|
||||
func WithB(b float64) BM25Option {
|
||||
return func(c *bm25Config) { c.b = b }
|
||||
}
|
||||
|
||||
// NewBM25Engine creates a BM25Engine for the given corpus.
|
||||
//
|
||||
// - corpus : slice of documents of any type T.
|
||||
// - textFunc : function that returns the searchable text for a document.
|
||||
// - opts : optional tuning (WithK1, WithB).
|
||||
//
|
||||
// The corpus slice is referenced, not copied. Callers must not mutate it
|
||||
// concurrently with Search().
|
||||
func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Option) *BM25Engine[T] {
|
||||
cfg := bm25Config{k1: DefaultBM25K1, b: DefaultBM25B}
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return &BM25Engine[T]{
|
||||
corpus: corpus,
|
||||
textFunc: textFunc,
|
||||
k1: cfg.k1,
|
||||
b: cfg.b,
|
||||
}
|
||||
}
|
||||
|
||||
// BM25Result is a single ranked result from a Search call.
|
||||
type BM25Result[T any] struct {
|
||||
Document T
|
||||
Score float32
|
||||
}
|
||||
|
||||
// Search ranks the corpus against query and returns the top-k results.
|
||||
// Returns an empty slice (not nil) when there are no matches.
|
||||
//
|
||||
// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,
|
||||
// where N = corpus size, L = average document length, Q = query terms.
|
||||
// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).
|
||||
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
||||
if topK <= 0 {
|
||||
return []BM25Result[T]{}
|
||||
}
|
||||
|
||||
queryTerms := bm25Tokenize(query)
|
||||
if len(queryTerms) == 0 {
|
||||
return []BM25Result[T]{}
|
||||
}
|
||||
|
||||
N := len(e.corpus)
|
||||
if N == 0 {
|
||||
return []BM25Result[T]{}
|
||||
}
|
||||
|
||||
// Step 1: build per-document tf + raw doc lengths
|
||||
type docEntry struct {
|
||||
tf map[string]uint32
|
||||
rawLen int
|
||||
}
|
||||
|
||||
entries := make([]docEntry, N)
|
||||
df := make(map[string]int, 64)
|
||||
totalLen := 0
|
||||
|
||||
for i, doc := range e.corpus {
|
||||
tokens := bm25Tokenize(e.textFunc(doc))
|
||||
totalLen += len(tokens)
|
||||
|
||||
tf := make(map[string]uint32, len(tokens))
|
||||
for _, t := range tokens {
|
||||
tf[t]++
|
||||
}
|
||||
// df: each term counts once per document (iterate the map, keys are unique)
|
||||
for t := range tf {
|
||||
df[t]++
|
||||
}
|
||||
|
||||
entries[i] = docEntry{tf: tf, rawLen: len(tokens)}
|
||||
}
|
||||
|
||||
avgDocLen := float64(totalLen) / float64(N)
|
||||
|
||||
// Step 2: pre-compute IDF and per-doc length normalization
|
||||
// IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )
|
||||
idf := make(map[string]float32, len(df))
|
||||
for term, freq := range df {
|
||||
idf[term] = float32(math.Log(
|
||||
(float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
|
||||
))
|
||||
}
|
||||
|
||||
// docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen)
|
||||
// Stored as float32 — sufficient precision for ranking.
|
||||
docLenNorm := make([]float32, N)
|
||||
for i, entry := range entries {
|
||||
docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen))
|
||||
}
|
||||
|
||||
// Step 3: build inverted index (posting lists)
|
||||
// Iterate the tf map directly — map keys are already unique, no seen-set needed.
|
||||
posting := make(map[string][]int32, len(df))
|
||||
for i, entry := range entries {
|
||||
for term := range entry.tf {
|
||||
posting[term] = append(posting[term], int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: score via posting lists
|
||||
// Deduplicate query terms to avoid double-weighting the same term.
|
||||
unique := bm25Dedupe(queryTerms)
|
||||
|
||||
scores := make(map[int32]float32)
|
||||
for _, term := range unique {
|
||||
termIDF, ok := idf[term]
|
||||
if !ok {
|
||||
continue // term not in vocabulary → zero contribution
|
||||
}
|
||||
for _, docID := range posting[term] {
|
||||
freq := float32(entries[docID].tf[term])
|
||||
// TF_norm = freq * (k1+1) / (freq + docLenNorm)
|
||||
tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID])
|
||||
scores[docID] += termIDF * tfNorm
|
||||
}
|
||||
}
|
||||
|
||||
if len(scores) == 0 {
|
||||
return []BM25Result[T]{}
|
||||
}
|
||||
|
||||
// Step 5: top-K via fixed-size min-heap
|
||||
heap := make([]bm25ScoredDoc, 0, topK)
|
||||
|
||||
for docID, sc := range scores {
|
||||
switch {
|
||||
case len(heap) < topK:
|
||||
heap = append(heap, bm25ScoredDoc{docID: docID, score: sc})
|
||||
if len(heap) == topK {
|
||||
bm25MinHeapify(heap)
|
||||
}
|
||||
case sc > heap[0].score:
|
||||
heap[0] = bm25ScoredDoc{docID: docID, score: sc}
|
||||
bm25SiftDown(heap, 0)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(heap, func(i, j int) bool { return heap[i].score > heap[j].score })
|
||||
|
||||
out := make([]BM25Result[T], len(heap))
|
||||
for i, h := range heap {
|
||||
out[i] = BM25Result[T]{
|
||||
Document: e.corpus[h.docID],
|
||||
Score: h.score,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.
|
||||
func bm25Tokenize(s string) []string {
|
||||
raw := strings.Fields(strings.ToLower(s))
|
||||
out := raw[:0] // reuse backing array to avoid extra allocation
|
||||
for _, t := range raw {
|
||||
t = strings.Trim(t, ".,;:!?\"'()/\\-_")
|
||||
if t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bm25Dedupe returns a new slice with duplicate tokens removed,
|
||||
// preserving first-occurrence order.
|
||||
func bm25Dedupe(tokens []string) []string {
|
||||
seen := make(map[string]struct{}, len(tokens))
|
||||
out := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
if _, ok := seen[t]; !ok {
|
||||
seen[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type bm25ScoredDoc struct {
|
||||
docID int32
|
||||
score float32
|
||||
}
|
||||
|
||||
// bm25MinHeapify builds a min-heap in-place using Floyd's algorithm: O(k).
|
||||
func bm25MinHeapify(h []bm25ScoredDoc) {
|
||||
for i := len(h)/2 - 1; i >= 0; i-- {
|
||||
bm25SiftDown(h, i)
|
||||
}
|
||||
}
|
||||
|
||||
// bm25SiftDown restores the min-heap property starting at node i: O(log k).
|
||||
func bm25SiftDown(h []bm25ScoredDoc, i int) {
|
||||
n := len(h)
|
||||
for {
|
||||
smallest := i
|
||||
l, r := 2*i+1, 2*i+2
|
||||
if l < n && h[l].score < h[smallest].score {
|
||||
smallest = l
|
||||
}
|
||||
if r < n && h[r].score < h[smallest].score {
|
||||
smallest = r
|
||||
}
|
||||
if smallest == i {
|
||||
break
|
||||
}
|
||||
h[i], h[smallest] = h[smallest], h[i]
|
||||
i = smallest
|
||||
}
|
||||
}
|
||||
175
pkg/utils/bm25_test.go
Normal file
175
pkg/utils/bm25_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
26
web/backend/api/voice_webhook.go
Normal file
26
web/backend/api/voice_webhook.go
Normal 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)
|
||||
}
|
||||
|
|
@ -123,7 +123,7 @@ func main() {
|
|||
// Apply middleware stack
|
||||
handler := middleware.Recoverer(
|
||||
middleware.Logger(
|
||||
middleware.JSONContentType(accessControlledMux),
|
||||
middleware.JSONContentType(middleware.ProxyAuth(accessControlledMux)),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
38
workspace/skills/google_sync/SKILL.md
Normal file
38
workspace/skills/google_sync/SKILL.md
Normal 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.
|
||||
47
workspace/skills/video_editor/SKILL.md
Normal file
47
workspace/skills/video_editor/SKILL.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue