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
201
.github/workflows/nightly.yml
vendored
201
.github/workflows/nightly.yml
vendored
|
|
@ -9,67 +9,78 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-version:
|
create-tag:
|
||||||
name: Generate Version
|
name: Create Git Tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
changelog: ${{ steps.version.outputs.changelog }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate version
|
- name: Generate and push tag
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
DATE=$(date -u +%Y%m%d)
|
DATE=$(date -u +%Y%m%d)
|
||||||
SHA=$(git rev-parse --short=8 HEAD)
|
SHA=$(git rev-parse --short=8 HEAD)
|
||||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
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
|
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||||
VERSION="nightly-${DATE}-${SHA}"
|
TAG="v0.0.0-nightly.${DATE}.${SHA}"
|
||||||
else
|
else
|
||||||
VERSION="${BASE_VERSION}-nightly-${DATE}-${SHA}"
|
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||||
fi
|
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 "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
build:
|
release:
|
||||||
name: Build
|
name: GoReleaser Release
|
||||||
|
needs: create-tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: generate-version
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout tag
|
||||||
uses: actions/checkout@v6
|
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
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Build
|
- name: Setup Node.js
|
||||||
env:
|
uses: actions/setup-node@v4
|
||||||
VERSION: ${{ needs.generate-version.outputs.version }}
|
|
||||||
run: make build-all VERSION="$VERSION"
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
with:
|
||||||
name: picoclaw-binaries
|
node-version: 22
|
||||||
path: build
|
|
||||||
|
|
||||||
build-docker:
|
- name: Setup pnpm
|
||||||
name: Build Docker
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: generate-version
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
@ -77,80 +88,79 @@ jobs:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Run GoReleaser
|
||||||
id: meta
|
uses: goreleaser/goreleaser-action@v6
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}
|
distribution: goreleaser
|
||||||
tags: |
|
version: ~> v2
|
||||||
type=raw,value=${{ needs.generate-version.outputs.tag }}
|
args: release --clean
|
||||||
type=raw,value=${{ needs.generate-version.outputs.version }}
|
env:
|
||||||
type=raw,value=nightly
|
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
|
update-rolling:
|
||||||
uses: docker/build-push-action@v6
|
name: Update Rolling Nightly
|
||||||
with:
|
needs: [create-tag, release]
|
||||||
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
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [generate-version, build, build-docker]
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download artifacts
|
- name: Update nightly release
|
||||||
uses: actions/download-artifact@v6
|
|
||||||
with:
|
|
||||||
name: picoclaw-binaries
|
|
||||||
path: ./build
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.create-tag.outputs.tag }}
|
||||||
|
TITLE: ${{ needs.create-tag.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ needs.generate-version.outputs.tag }}"
|
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
|
||||||
TITLE="${{ needs.generate-version.outputs.version }}"
|
NOTES=$(cat <<EOF
|
||||||
NOTES=$'Nightly build for **${{ needs.generate-version.outputs.version }}**\n\nThis is an automated build and may be unstable. Use with caution.'
|
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
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG already exists, updating metadata and assets..."
|
echo "Downloading assets from GitHub release for $TAG..."
|
||||||
gh release edit "$TAG" \
|
gh release download "$TAG" --dir build
|
||||||
--title "$TITLE" \
|
|
||||||
--notes "$NOTES" \
|
|
||||||
--prerelease
|
|
||||||
gh release upload "$TAG" build/* --clobber
|
|
||||||
else
|
else
|
||||||
echo "Creating new release $TAG..."
|
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
|
||||||
gh release create "$TAG" \
|
if [ -d "dist" ]; then
|
||||||
--title "$TITLE" \
|
cp -R dist/* build/
|
||||||
--notes "$NOTES" \
|
else
|
||||||
--target "${{ github.sha }}" \
|
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
|
||||||
--prerelease \
|
exit 1
|
||||||
build/*
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Updating rolling 'nightly' release..."
|
# Delete existing nightly release and tag to avoid conflicts
|
||||||
gh release delete nightly --cleanup-tag -y >/dev/null 2>&1 || true
|
echo "Deleting existing nightly release and tag..."
|
||||||
sleep 2
|
gh release delete nightly --cleanup-tag -y || true
|
||||||
|
git push origin :refs/tags/nightly || true
|
||||||
|
|
||||||
gh release create nightly \
|
gh release create nightly \
|
||||||
--title "Nightly Build" \
|
--title "Nightly Build" \
|
||||||
--notes "$NOTES" \
|
--notes "$NOTES" \
|
||||||
|
|
@ -159,9 +169,36 @@ jobs:
|
||||||
build/*
|
build/*
|
||||||
|
|
||||||
echo "Cleaning up old nightly releases (keeping only the most recent)..."
|
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
|
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
|
||||||
echo "Deleting old nightly release: $old_tag"
|
echo "Deleting old nightly release: $old_tag"
|
||||||
gh release delete "$old_tag" --cleanup-tag -y || true
|
gh release delete "$old_tag" --cleanup-tag -y || true
|
||||||
fi
|
fi
|
||||||
done
|
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
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
- "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:
|
tags:
|
||||||
- "{{ .Tag }}"
|
- "{{ .Tag }}"
|
||||||
- "latest"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||||
platforms:
|
platforms:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
- linux/arm64
|
- linux/arm64
|
||||||
|
|
@ -159,7 +159,7 @@ archives:
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
builds:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
- picoclaw-launcher-tui
|
- 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 (
|
const (
|
||||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, gemini-cli"
|
||||||
defaultAnthropicModel = "claude-sonnet-4.6"
|
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,6 +29,8 @@ func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
||||||
return authLoginAnthropic(useOauth)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
|
case "gemini-cli":
|
||||||
|
return authLoginGeminiCLI()
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +169,26 @@ func authLoginGoogleAntigravity() error {
|
||||||
return nil
|
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 {
|
func authLoginAnthropic(useOauth bool) error {
|
||||||
if useOauth {
|
if useOauth {
|
||||||
return authLoginAnthropicSetupToken()
|
return authLoginAnthropicSetupToken()
|
||||||
|
|
@ -363,6 +385,10 @@ func authLogoutCmd(provider string) error {
|
||||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
|
case "gemini-cli":
|
||||||
|
if appCfg.ModelList[i].Model == "gemini-cli" {
|
||||||
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clear AuthMethod in Providers (legacy)
|
// Clear AuthMethod in Providers (legacy)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ func gatewayCmd(debug bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, internal.FormatVersion())
|
||||||
|
|
||||||
// Print agent startup info
|
// Print agent startup info
|
||||||
fmt.Println("\n📦 Agent Status:")
|
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()})
|
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()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||||
|
|
@ -180,6 +187,14 @@ func gatewayCmd(debug bool) error {
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Cron service started")
|
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 {
|
if err := heartbeatService.Start(); err != nil {
|
||||||
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,13 @@
|
||||||
"nickserv_password": "",
|
"nickserv_password": "",
|
||||||
"sasl_user": "",
|
"sasl_user": "",
|
||||||
"sasl_password": "",
|
"sasl_password": "",
|
||||||
"channels": ["#mychannel"],
|
"channels": [
|
||||||
"request_caps": ["server-time", "message-tags"],
|
"#mychannel"
|
||||||
|
],
|
||||||
|
"request_caps": [
|
||||||
|
"server-time",
|
||||||
|
"message-tags"
|
||||||
|
],
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": true
|
"mention_only": true
|
||||||
|
|
@ -316,6 +321,13 @@
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"discovery": {
|
||||||
|
"enabled": false,
|
||||||
|
"ttl": 5,
|
||||||
|
"max_search_results": 5,
|
||||||
|
"use_bm25": true,
|
||||||
|
"use_regex": false
|
||||||
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"context7": {
|
"context7": {
|
||||||
"enabled": false,
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": { ... },
|
"web": {
|
||||||
"mcp": { ... },
|
...
|
||||||
"exec": { ... },
|
},
|
||||||
"cron": { ... },
|
"mcp": {
|
||||||
"skills": { ... }
|
...
|
||||||
|
},
|
||||||
|
"exec": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
...
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
|
||||||
### Brave
|
### Brave
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ------ | ------- | ------------------------- |
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Brave search |
|
| `enabled` | bool | false | Enable Brave search |
|
||||||
| `api_key` | string | - | Brave Search API key |
|
| `api_key` | string | - | Brave Search API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
|
||||||
### DuckDuckGo
|
### DuckDuckGo
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ---- | ------- | ------------------------- |
|
|---------------|------|---------|---------------------------|
|
||||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
### Perplexity
|
### Perplexity
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ------ | ------- | ------------------------- |
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Perplexity search |
|
| `enabled` | bool | false | Enable Perplexity search |
|
||||||
| `api_key` | string | - | Perplexity API key |
|
| `api_key` | string | - | Perplexity API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `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.
|
The exec tool is used to execute shell commands.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------- | ----- | ------- | ------------------------------------------ |
|
|------------------------|-------|---------|--------------------------------------------|
|
||||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||||
|
|
||||||
|
|
@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
"tools": {
|
"tools": {
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": true,
|
"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.
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------- | ---- | ------- | ---------------------------------------------- |
|
|------------------------|------|---------|------------------------------------------------|
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
## MCP Tool
|
## MCP Tool
|
||||||
|
|
||||||
The MCP tool enables integration with external Model Context Protocol servers.
|
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
|
### Global Config
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| --------- | ------ | ------- | ----------------------------------- |
|
|-------------|--------|---------|----------------------------------------------|
|
||||||
| `enabled` | bool | false | Enable MCP integration globally |
|
| `enabled` | bool | false | Enable MCP integration globally |
|
||||||
|
| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) |
|
||||||
| `servers` | object | `{}` | Map of server name to server config |
|
| `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
|
### Per-Server Config
|
||||||
|
|
||||||
| Config | Type | Required | Description |
|
| Config | Type | Required | Description |
|
||||||
| ---------- | ------ | -------- | ------------------------------------------ |
|
|------------|--------|----------|--------------------------------------------|
|
||||||
| `enabled` | bool | yes | Enable this MCP server |
|
| `enabled` | bool | yes | Enable this MCP server |
|
||||||
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
||||||
| `command` | string | stdio | Executable command for stdio transport |
|
| `command` | string | stdio | Executable command for stdio transport |
|
||||||
|
|
@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
"filesystem": {
|
"filesystem": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/tmp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +210,62 @@ 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
|
## Skills Tool
|
||||||
|
|
||||||
The skills tool configures skill discovery and installation via registries like ClawHub.
|
The skills tool configures skill discovery and installation via registries like ClawHub.
|
||||||
|
|
@ -177,7 +273,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
### Registries
|
### Registries
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
|------------------------------------|--------|----------------------|----------------------------------------------|
|
||||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
| `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.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
|
|
@ -217,4 +313,5 @@ For example:
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `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/anthropics/anthropic-sdk-go v1.22.1
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/chromedp/chromedp v0.10.0
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/ergochat/irc-go v0.5.0
|
github.com/ergochat/irc-go v0.5.0
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
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/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.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/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.1
|
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||||
|
|
@ -35,13 +39,24 @@ require (
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.1 // indirect
|
filippo.io/edwards25519 v1.1.1 // indirect
|
||||||
github.com/beeper/argo-go v1.1.2 // 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/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // 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/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/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/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-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // 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/libsignal v0.2.1 // indirect
|
||||||
go.mau.fi/util v0.9.6 // indirect
|
go.mau.fi/util v0.9.6 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // 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/term v0.40.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|
@ -89,8 +105,8 @@ require (
|
||||||
github.com/valyala/fastjson v1.6.7 // indirect
|
github.com/valyala/fastjson v1.6.7 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // 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/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
|
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/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.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/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 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
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 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
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 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
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 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
github.com/github/copilot-sdk/go v0.1.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-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.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
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-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 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
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/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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
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=
|
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/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 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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.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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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/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 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
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 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
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=
|
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/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 h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.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.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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
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 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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=
|
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"
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ type MemoryStore struct {
|
||||||
workspace string
|
workspace string
|
||||||
memoryDir string
|
memoryDir string
|
||||||
memoryFile string
|
memoryFile string
|
||||||
|
commsFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemoryStore creates a new MemoryStore with the given workspace path.
|
// NewMemoryStore creates a new MemoryStore with the given workspace path.
|
||||||
|
|
@ -30,6 +31,7 @@ type MemoryStore struct {
|
||||||
func NewMemoryStore(workspace string) *MemoryStore {
|
func NewMemoryStore(workspace string) *MemoryStore {
|
||||||
memoryDir := filepath.Join(workspace, "memory")
|
memoryDir := filepath.Join(workspace, "memory")
|
||||||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||||
|
commsFile := filepath.Join(memoryDir, "COMMUNICATIONS.md")
|
||||||
|
|
||||||
// Ensure memory directory exists
|
// Ensure memory directory exists
|
||||||
os.MkdirAll(memoryDir, 0o755)
|
os.MkdirAll(memoryDir, 0o755)
|
||||||
|
|
@ -38,6 +40,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
memoryDir: memoryDir,
|
memoryDir: memoryDir,
|
||||||
memoryFile: memoryFile,
|
memoryFile: memoryFile,
|
||||||
|
commsFile: commsFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +68,38 @@ func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||||
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
|
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.
|
// ReadToday reads today's daily note.
|
||||||
// Returns empty string if the file doesn't exist.
|
// Returns empty string if the file doesn't exist.
|
||||||
func (ms *MemoryStore) ReadToday() string {
|
func (ms *MemoryStore) ReadToday() string {
|
||||||
|
|
@ -134,8 +169,9 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||||
func (ms *MemoryStore) GetMemoryContext() string {
|
func (ms *MemoryStore) GetMemoryContext() string {
|
||||||
longTerm := ms.ReadLongTerm()
|
longTerm := ms.ReadLongTerm()
|
||||||
recentNotes := ms.GetRecentDailyNotes(3)
|
recentNotes := ms.GetRecentDailyNotes(3)
|
||||||
|
comms := ms.ReadCommunications()
|
||||||
|
|
||||||
if longTerm == "" && recentNotes == "" {
|
if longTerm == "" && recentNotes == "" && comms == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,8 +182,16 @@ func (ms *MemoryStore) GetMemoryContext() string {
|
||||||
sb.WriteString(longTerm)
|
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 recentNotes != "" {
|
||||||
if longTerm != "" {
|
if sb.Len() > 0 {
|
||||||
sb.WriteString("\n\n---\n\n")
|
sb.WriteString("\n\n---\n\n")
|
||||||
}
|
}
|
||||||
sb.WriteString("## Recent Daily Notes\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",
|
TokenURL: "https://oauth2.googleapis.com/token",
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
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,
|
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 {
|
func decodeBase64(s string) string {
|
||||||
data, err := base64.StdEncoding.DecodeString(s)
|
data, err := base64.StdEncoding.DecodeString(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ import (
|
||||||
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
||||||
var ErrBusClosed = errors.New("message bus closed")
|
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
|
const defaultBusBufferSize = 64
|
||||||
|
|
||||||
type MessageBus struct {
|
type MessageBus struct {
|
||||||
|
|
@ -19,6 +25,9 @@ type MessageBus struct {
|
||||||
outboundMedia chan OutboundMediaMessage
|
outboundMedia chan OutboundMediaMessage
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
|
|
||||||
|
inboundMiddleware []InboundMiddleware
|
||||||
|
outboundMiddleware []OutboundMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageBus() *MessageBus {
|
func NewMessageBus() *MessageBus {
|
||||||
|
|
@ -37,8 +46,15 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply middleware
|
||||||
|
processed := msg
|
||||||
|
for _, mw := range mb.inboundMiddleware {
|
||||||
|
processed = mw(processed)
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case mb.inbound <- msg:
|
case mb.inbound <- processed:
|
||||||
return nil
|
return nil
|
||||||
case <-mb.done:
|
case <-mb.done:
|
||||||
return ErrBusClosed
|
return ErrBusClosed
|
||||||
|
|
@ -65,8 +81,15 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply middleware
|
||||||
|
processed := msg
|
||||||
|
for _, mw := range mb.outboundMiddleware {
|
||||||
|
processed = mw(processed)
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case mb.outbound <- msg:
|
case mb.outbound <- processed:
|
||||||
return nil
|
return nil
|
||||||
case <-mb.done:
|
case <-mb.done:
|
||||||
return ErrBusClosed
|
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() {
|
func (mb *MessageBus) Close() {
|
||||||
if mb.closed.CompareAndSwap(false, true) {
|
if mb.closed.CompareAndSwap(false, true) {
|
||||||
close(mb.done)
|
close(mb.done)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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) {
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
// Get emoji list from config
|
// Get emoji list from config
|
||||||
emojiList := c.config.RandomReactionEmoji
|
emojiList := c.config.RandomReactionEmoji
|
||||||
|
var chosenEmoji string
|
||||||
if len(emojiList) == 0 {
|
if len(emojiList) == 0 {
|
||||||
// Default to "Pin" if no config
|
// Default to "Pin" if no config
|
||||||
emojiList = []string{"Pin"}
|
chosenEmoji = "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]
|
|
||||||
} else {
|
} else {
|
||||||
chosenEmoji = emojiList[idx.Int64()]
|
idx := rand.Intn(len(emojiList))
|
||||||
|
chosenEmoji = emojiList[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package channels
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/commands"
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -50,3 +51,14 @@ type PlaceholderRecorder interface {
|
||||||
type CommandRegistrarCapable interface {
|
type CommandRegistrarCapable interface {
|
||||||
RegisterCommands(ctx context.Context, defs []commands.Definition) error
|
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,9 +340,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
|
mediaPaths = append(mediaPaths, storeMedia(localPath, 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)
|
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,11 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
end := start + effectiveLimit
|
end := start + effectiveLimit
|
||||||
|
|
||||||
// Find natural split point within the effective limit
|
// 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 {
|
if msgEnd <= start {
|
||||||
msgEnd = findLastSpaceInRange(runes, start, end, 100)
|
msgEnd = findLastSpaceInRange(runes, start, end, 100)
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +54,9 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
msgEnd = end
|
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)
|
unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd)
|
||||||
|
inTable := isInsideTableInRange(runes, start, msgEnd)
|
||||||
|
|
||||||
if unclosedIdx >= 0 {
|
if unclosedIdx >= 0 {
|
||||||
// Message would end with incomplete code block
|
// 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 {
|
if msgEnd <= start {
|
||||||
|
|
@ -206,3 +223,71 @@ func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {
|
||||||
}
|
}
|
||||||
return start - 1
|
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/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ type WhatsAppNativeChannel struct {
|
||||||
reconnecting bool
|
reconnecting bool
|
||||||
stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
|
stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
|
||||||
wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect)
|
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.
|
// 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" {
|
if evt.Event == "code" {
|
||||||
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
|
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{
|
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
|
||||||
Level: qrterminal.L,
|
Level: qrterminal.L,
|
||||||
Writer: os.Stdout,
|
Writer: os.Stdout,
|
||||||
|
|
@ -352,12 +357,50 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
}
|
}
|
||||||
content = utils.SanitizeMessageContent(content)
|
content = utils.SanitizeMessageContent(content)
|
||||||
|
|
||||||
if content == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaPaths []string
|
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 := make(map[string]string)
|
||||||
metadata["message_id"] = evt.Info.ID
|
metadata["message_id"] = evt.Info.ID
|
||||||
if evt.Info.PushName != "" {
|
if evt.Info.PushName != "" {
|
||||||
|
|
@ -456,3 +499,69 @@ func parseJID(s string) (types.JID, error) {
|
||||||
}
|
}
|
||||||
return types.NewJID(clean, types.DefaultUserServer), nil
|
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,14 +6,24 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// listCommand is kept as a no-op stub for backward compat.
|
||||||
|
// All functionality moved to /models and /channels.
|
||||||
func listCommand() Definition {
|
func listCommand() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List available options",
|
Description: "Alias: use /models or /channels",
|
||||||
SubCommands: []SubCommand{
|
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",
|
Name: "models",
|
||||||
Description: "Configured models",
|
Description: "Show the currently configured model",
|
||||||
|
Usage: "/models",
|
||||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
if rt == nil || rt.GetModelInfo == nil {
|
if rt == nil || rt.GetModelInfo == nil {
|
||||||
return req.Reply(unavailableMsg)
|
return req.Reply(unavailableMsg)
|
||||||
|
|
@ -22,31 +32,25 @@ func listCommand() Definition {
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
provider = "configured default"
|
provider = "configured default"
|
||||||
}
|
}
|
||||||
return req.Reply(fmt.Sprintf(
|
return req.Reply(fmt.Sprintf("Model: %s\nProvider: %s", name, provider))
|
||||||
"Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
|
||||||
name, provider,
|
|
||||||
))
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
{
|
}
|
||||||
|
|
||||||
|
func channelsCommand() Definition {
|
||||||
|
return Definition{
|
||||||
Name: "channels",
|
Name: "channels",
|
||||||
Description: "Enabled channels",
|
Description: "List enabled channels",
|
||||||
|
Usage: "/channels",
|
||||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
if rt == nil || rt.GetEnabledChannels == nil {
|
if rt == nil || rt.GetEnabledChannels == nil {
|
||||||
return req.Reply(unavailableMsg)
|
return req.Reply(unavailableMsg)
|
||||||
}
|
}
|
||||||
enabled := rt.GetEnabledChannels()
|
enabled := rt.GetEnabledChannels()
|
||||||
if len(enabled) == 0 {
|
if len(enabled) == 0 {
|
||||||
return req.Reply("No channels enabled")
|
return req.Reply("No channels enabled.")
|
||||||
}
|
}
|
||||||
return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")))
|
return req.Reply("Enabled channels:\n- " + strings.Join(enabled, "\n- "))
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "agents",
|
|
||||||
Description: "Registered agents",
|
|
||||||
Handler: agentsHandler(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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)
|
ExecuteShell func(ctx context.Context, command string) (string, error)
|
||||||
GetRecentPreviews func() []PreviewInfo
|
GetRecentPreviews func() []PreviewInfo
|
||||||
ClearHistory func() error
|
ClearHistory func() error
|
||||||
|
GetChannel func(name string) (any, bool)
|
||||||
|
GetVersion func() string
|
||||||
|
ListTools func() []string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
@ -130,11 +131,11 @@ func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id" validate:"required"`
|
||||||
Default bool `json:"default,omitempty"`
|
Default bool `json:"default,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Workspace string `json:"workspace,omitempty"`
|
Workspace string `json:"workspace,omitempty"`
|
||||||
Model *AgentModelConfig `json:"model,omitempty"`
|
Model *AgentModelConfig `json:"model,omitempty" validate:"required"`
|
||||||
Skills []string `json:"skills,omitempty"`
|
Skills []string `json:"skills,omitempty"`
|
||||||
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -189,9 +190,9 @@ type AgentDefaults struct {
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS" validate:"gt=0"`
|
||||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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 {
|
type ToolConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"ENABLED"`
|
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadFileToolConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
MaxReadFileSize int
|
||||||
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
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"`
|
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 {
|
type DuckDuckGoConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
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)
|
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 {
|
type SkillsToolsConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||||
Registries SkillsRegistriesConfig ` json:"registries"`
|
Registries SkillsRegistriesConfig ` json:"registries"`
|
||||||
|
|
@ -663,6 +695,12 @@ type MediaCleanupConfig struct {
|
||||||
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
|
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 {
|
type ToolsConfig struct {
|
||||||
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||||
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_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_"`
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
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_"`
|
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_"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
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_"`
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
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_"`
|
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 {
|
type SearchCacheConfig struct {
|
||||||
|
|
@ -731,7 +778,8 @@ type MCPServerConfig struct {
|
||||||
|
|
||||||
// MCPConfig defines configuration for all MCP servers
|
// MCPConfig defines configuration for all MCP servers
|
||||||
type MCPConfig struct {
|
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 is a map of server name to server configuration
|
||||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -769,6 +817,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, err
|
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
|
// Migrate legacy channel config fields to new unified structures
|
||||||
cfg.migrateChannelConfigs()
|
cfg.migrateChannelConfigs()
|
||||||
|
|
||||||
|
|
@ -938,14 +991,24 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.FindSkills.Enabled
|
return t.FindSkills.Enabled
|
||||||
case "i2c":
|
case "i2c":
|
||||||
return t.I2C.Enabled
|
return t.I2C.Enabled
|
||||||
|
case "browser":
|
||||||
|
return t.Browser.Enabled
|
||||||
|
case "image":
|
||||||
|
return t.Image.Enabled
|
||||||
case "install_skill":
|
case "install_skill":
|
||||||
return t.InstallSkill.Enabled
|
return t.InstallSkill.Enabled
|
||||||
|
case "google":
|
||||||
|
return t.Google.Enabled
|
||||||
|
case "vps":
|
||||||
|
return t.VPS.Enabled
|
||||||
case "list_dir":
|
case "list_dir":
|
||||||
return t.ListDir.Enabled
|
return t.ListDir.Enabled
|
||||||
case "message":
|
case "message":
|
||||||
return t.Message.Enabled
|
return t.Message.Enabled
|
||||||
case "read_file":
|
case "read_file":
|
||||||
return t.ReadFile.Enabled
|
return t.ReadFile.Enabled
|
||||||
|
case "pdf":
|
||||||
|
return t.PDF.Enabled
|
||||||
case "spawn":
|
case "spawn":
|
||||||
return t.Spawn.Enabled
|
return t.Spawn.Enabled
|
||||||
case "spi":
|
case "spi":
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,27 @@ func DefaultConfig() *Config {
|
||||||
Workspace: workspacePath,
|
Workspace: workspacePath,
|
||||||
RestrictToWorkspace: true,
|
RestrictToWorkspace: true,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
Model: "",
|
ModelName: "openrouter-free",
|
||||||
|
Model: "openrouter/free",
|
||||||
MaxTokens: 32768,
|
MaxTokens: 32768,
|
||||||
Temperature: nil, // nil means use provider default
|
Temperature: nil, // nil means use provider default
|
||||||
MaxToolIterations: 50,
|
MaxToolIterations: 10,
|
||||||
SummarizeMessageThreshold: 20,
|
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{},
|
Bindings: []AgentBinding{},
|
||||||
|
|
@ -248,6 +263,12 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
|
|
||||||
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
||||||
|
{
|
||||||
|
ModelName: "openrouter-free",
|
||||||
|
Model: "openrouter/free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ModelName: "openrouter-auto",
|
ModelName: "openrouter-auto",
|
||||||
Model: "openrouter/auto",
|
Model: "openrouter/auto",
|
||||||
|
|
@ -443,6 +464,13 @@ func DefaultConfig() *Config {
|
||||||
ToolConfig: ToolConfig{
|
ToolConfig: ToolConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
|
Discovery: ToolDiscoveryConfig{
|
||||||
|
Enabled: false,
|
||||||
|
TTL: 5,
|
||||||
|
MaxSearchResults: 5,
|
||||||
|
UseBM25: true,
|
||||||
|
UseRegex: false,
|
||||||
|
},
|
||||||
Servers: map[string]MCPServerConfig{},
|
Servers: map[string]MCPServerConfig{},
|
||||||
},
|
},
|
||||||
AppendFile: ToolConfig{
|
AppendFile: ToolConfig{
|
||||||
|
|
@ -460,15 +488,28 @@ func DefaultConfig() *Config {
|
||||||
InstallSkill: ToolConfig{
|
InstallSkill: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
Google: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
ListDir: ToolConfig{
|
ListDir: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
Message: ToolConfig{
|
Message: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
ReadFile: ToolConfig{
|
PDF: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
Browser: ToolConfig{
|
||||||
|
Enabled: false, // Requires Chrome/Chromium installed
|
||||||
|
},
|
||||||
|
Image: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
ReadFile: ReadFileToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxReadFileSize: 64 * 1024, // 64KB
|
||||||
|
},
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
|
@ -481,9 +522,27 @@ func DefaultConfig() *Config {
|
||||||
WebFetch: ToolConfig{
|
WebFetch: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
VPS: VPSConfig{
|
||||||
|
ToolConfig: ToolConfig{Enabled: true},
|
||||||
|
Host: "187.77.75.173",
|
||||||
|
User: "root",
|
||||||
|
},
|
||||||
WriteFile: ToolConfig{
|
WriteFile: ToolConfig{
|
||||||
Enabled: true,
|
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{
|
Heartbeat: HeartbeatConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,32 @@ import (
|
||||||
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
|
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
|
||||||
// Otherwise, the protocol prefix is added.
|
// Otherwise, the protocol prefix is added.
|
||||||
func buildModelWithProtocol(protocol, model string) string {
|
func buildModelWithProtocol(protocol, model string) string {
|
||||||
if strings.Contains(model, "/") {
|
if model == "" {
|
||||||
// Model already has a protocol prefix, return as-is
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the model already starts with the protocol prefix, return as-is
|
||||||
|
prefix := protocol + "/"
|
||||||
|
if strings.HasPrefix(model, prefix) {
|
||||||
return model
|
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.
|
// 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
|
ready bool
|
||||||
checks map[string]Check
|
checks map[string]Check
|
||||||
startTime time.Time
|
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 {
|
type Check struct {
|
||||||
|
|
@ -41,6 +74,7 @@ func NewServer(host string, port int) *Server {
|
||||||
|
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
|
mux.HandleFunc("/metrics", s.metricsHandler)
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
|
|
@ -117,6 +151,16 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(resp)
|
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) {
|
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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.
|
// First, prefer explicit provider configuration.
|
||||||
if providerName != "" {
|
if providerName != "" {
|
||||||
switch providerName {
|
if resolver, ok := registry[providerName]; ok {
|
||||||
case "groq":
|
s, resolved, err := resolver(cfg, providerName, model)
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
if err != nil {
|
||||||
sel.apiKey = cfg.Providers.Groq.APIKey
|
return providerSelection{}, err
|
||||||
sel.apiBase = cfg.Providers.Groq.APIBase
|
|
||||||
sel.proxy = cfg.Providers.Groq.Proxy
|
|
||||||
if sel.apiBase == "" {
|
|
||||||
sel.apiBase = "https://api.groq.com/openai/v1"
|
|
||||||
}
|
}
|
||||||
|
if resolved {
|
||||||
|
return s, nil
|
||||||
}
|
}
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,
|
"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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"hash/crc32"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -15,8 +16,10 @@ type Session struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Messages []providers.Message `json:"messages"`
|
Messages []providers.Message `json:"messages"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
Updated time.Time `json:"updated"`
|
Updated time.Time `json:"updated"`
|
||||||
|
Checksum uint32 `json:"checksum,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionManager struct {
|
type SessionManager struct {
|
||||||
|
|
@ -145,6 +148,41 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||||
session.Updated = time.Now()
|
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.
|
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
||||||
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
|
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
|
||||||
// volume separator on Windows, so filepath.Base would misinterpret the key.
|
// 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()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
|
// Calculate checksum of JSON without checksum field
|
||||||
|
snapshot.Checksum = 0
|
||||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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")
|
sessionPath := filepath.Join(sm.storage, filename+".json")
|
||||||
|
bakPath := sessionPath + ".bak"
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp")
|
tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -226,7 +275,13 @@ func (sm *SessionManager) Save(key string) error {
|
||||||
return err
|
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 {
|
if err := os.Rename(tmpPath, sessionPath); err != nil {
|
||||||
|
_ = os.Rename(bakPath, sessionPath)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cleanup = false
|
cleanup = false
|
||||||
|
|
@ -249,6 +304,8 @@ func (sm *SessionManager) loadSessions() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionPath := filepath.Join(sm.storage, file.Name())
|
sessionPath := filepath.Join(sm.storage, file.Name())
|
||||||
|
bakPath := sessionPath + ".bak"
|
||||||
|
|
||||||
data, err := os.ReadFile(sessionPath)
|
data, err := os.ReadFile(sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
@ -256,6 +313,29 @@ func (sm *SessionManager) loadSessions() error {
|
||||||
|
|
||||||
var session Session
|
var session Session
|
||||||
if err := json.Unmarshal(data, &session); err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,3 +360,18 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
|
||||||
session.Updated = time.Now()
|
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"`
|
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) {
|
func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) {
|
||||||
if err := utils.ValidateSkillIdentifier(slug); err != nil {
|
if err := utils.ValidateSkillIdentifier(slug); err != nil {
|
||||||
return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error())
|
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
|
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 ---
|
||||||
|
|
||||||
// DownloadAndInstall fetches metadata (with fallback), resolves version,
|
// DownloadAndInstall fetches metadata (with fallback), resolves version,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,25 @@ type SkillMeta struct {
|
||||||
RegistryName string `json:"registry_name"`
|
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
|
// InstallResult is returned by DownloadAndInstall to carry metadata
|
||||||
// back to the caller for moderation and user messaging.
|
// back to the caller for moderation and user messaging.
|
||||||
type InstallResult struct {
|
type InstallResult struct {
|
||||||
|
|
@ -55,6 +74,8 @@ type SkillRegistry interface {
|
||||||
// installs the skill to targetDir. Returns an InstallResult with metadata
|
// installs the skill to targetDir. Returns an InstallResult with metadata
|
||||||
// for the caller to use for moderation and user messaging.
|
// for the caller to use for moderation and user messaging.
|
||||||
DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)
|
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.
|
// 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"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ToolEntry struct {
|
||||||
|
Tool Tool
|
||||||
|
IsCore bool
|
||||||
|
TTL int
|
||||||
|
}
|
||||||
|
|
||||||
type ToolRegistry struct {
|
type ToolRegistry struct {
|
||||||
tools map[string]Tool
|
tools map[string]*ToolEntry
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewToolRegistry() *ToolRegistry {
|
func NewToolRegistry() *ToolRegistry {
|
||||||
return &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",
|
logger.WarnCF("tools", "Tool registration overwrites existing tool",
|
||||||
map[string]any{"name": name})
|
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) {
|
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
tool, ok := r.tools[name]
|
entry, ok := r.tools[name]
|
||||||
return tool, ok
|
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 {
|
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()
|
sorted := r.sortedToolNames()
|
||||||
definitions := make([]map[string]any, 0, len(sorted))
|
definitions := make([]map[string]any, 0, len(sorted))
|
||||||
for _, name := range 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
|
return definitions
|
||||||
}
|
}
|
||||||
|
|
@ -149,8 +265,13 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
||||||
sorted := r.sortedToolNames()
|
sorted := r.sortedToolNames()
|
||||||
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
||||||
for _, name := range sorted {
|
for _, name := range sorted {
|
||||||
tool := r.tools[name]
|
entry := r.tools[name]
|
||||||
schema := ToolToSchema(tool)
|
|
||||||
|
if !entry.IsCore && entry.TTL <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := ToolToSchema(entry.Tool)
|
||||||
|
|
||||||
// Safely extract nested values with type checks
|
// Safely extract nested values with type checks
|
||||||
fn, ok := schema["function"].(map[string]any)
|
fn, ok := schema["function"].(map[string]any)
|
||||||
|
|
@ -198,8 +319,13 @@ func (r *ToolRegistry) GetSummaries() []string {
|
||||||
sorted := r.sortedToolNames()
|
sorted := r.sortedToolNames()
|
||||||
summaries := make([]string, 0, len(sorted))
|
summaries := make([]string, 0, len(sorted))
|
||||||
for _, name := range sorted {
|
for _, name := range sorted {
|
||||||
tool := r.tools[name]
|
entry := r.tools[name]
|
||||||
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
|
|
||||||
|
if !entry.IsCore && entry.TTL <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description()))
|
||||||
}
|
}
|
||||||
return summaries
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/acp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SpawnTool struct {
|
type SpawnTool struct {
|
||||||
|
|
@ -42,7 +44,19 @@ func (t *SpawnTool) Parameters() map[string]any {
|
||||||
},
|
},
|
||||||
"agent_id": map[string]any{
|
"agent_id": map[string]any{
|
||||||
"type": "string",
|
"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"},
|
"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 {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured")
|
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)
|
channel := ToolChannel(ctx)
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = "cli"
|
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
|
Status string
|
||||||
Result string
|
Result string
|
||||||
Created int64
|
Created int64
|
||||||
|
Type string // "default", "web_dev", "ui_designer", "researcher"
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubagentManager struct {
|
type SubagentManager struct {
|
||||||
|
|
@ -112,10 +113,28 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
|
||||||
task.Status = "running"
|
task.Status = "running"
|
||||||
task.Created = time.Now().UnixMilli()
|
task.Created = time.Now().UnixMilli()
|
||||||
|
|
||||||
// Build system prompt for subagent
|
// Build system prompt for subagent based on type
|
||||||
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
|
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.`
|
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{
|
messages := []providers.Message{
|
||||||
{
|
{
|
||||||
|
|
@ -149,6 +168,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
hasTemperature := sm.hasTemperature
|
hasTemperature := sm.hasTemperature
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
|
|
||||||
var llmOptions map[string]any
|
var llmOptions map[string]any
|
||||||
if hasMaxTokens || hasTemperature {
|
if hasMaxTokens || hasTemperature {
|
||||||
llmOptions = map[string]any{}
|
llmOptions = map[string]any{}
|
||||||
|
|
@ -263,6 +283,11 @@ func (t *SubagentTool) Parameters() map[string]any {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional short label for the task (for display)",
|
"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"},
|
"required": []string{"task"},
|
||||||
}
|
}
|
||||||
|
|
@ -275,16 +300,29 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
agentType, _ := args["type"].(string)
|
||||||
|
if agentType == "" {
|
||||||
|
agentType = "default"
|
||||||
|
}
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is 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{
|
messages := []providers.Message{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.",
|
Content: systemPrompt,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Role: "user",
|
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 := t.manager
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
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)
|
channel := ToolChannel(ctx)
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = "cli"
|
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)
|
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForUser: Brief summary for user (truncated if too long)
|
|
||||||
userContent := loopResult.Content
|
userContent := loopResult.Content
|
||||||
maxUserLen := 500
|
maxUserLen := 500
|
||||||
if len(userContent) > maxUserLen {
|
if len(userContent) > maxUserLen {
|
||||||
userContent = userContent[:maxUserLen] + "..."
|
userContent = userContent[:maxUserLen] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForLLM: Full execution details
|
|
||||||
labelStr := label
|
labelStr := label
|
||||||
if labelStr == "" {
|
if labelStr == "" {
|
||||||
labelStr = "(unnamed)"
|
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)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
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
|
// Apply middleware stack
|
||||||
handler := middleware.Recoverer(
|
handler := middleware.Recoverer(
|
||||||
middleware.Logger(
|
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)
|
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