diff --git a/README.md b/README.md
index 81d155f..b11409f 100644
--- a/README.md
+++ b/README.md
@@ -65,12 +65,12 @@
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
-| | OpenClaw | NanoBot | **PicoClaw** |
-| --- | --- | --- |--- |
-| **Language** | TypeScript | Python | **Go** |
-| **RAM** | >1GB |>100MB| **< 10MB** |
-| **Startup**(0.8GHz core) | >500s | >30s | **<1s** |
-| **Cost** | Mac Mini 599$ | Most Linux SBC ~50$ |**Any Linux Board****As low as 10$** |
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Language** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **Startup**(0.8GHz core) | >500s | >30s | **<1s** |
+| **Cost** | Mac Mini 599$ | Most Linux SBC ~50$ | **Any Linux Board****As low as 10$** |
@@ -100,9 +100,9 @@
PicoClaw can be deployed on almost any Linux device!
-* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
-* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
-* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
@@ -177,7 +177,7 @@ docker compose --profile gateway up -d
> [!TIP]
> Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month)
+> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@@ -206,9 +206,14 @@ picoclaw onboard
},
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
}
@@ -236,12 +241,12 @@ That's it! You have a working AI assistant in 2 minutes.
Talk to your picoclaw through Telegram, Discord, or DingTalk
-| Channel | Setup |
-|---------|-------|
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
+| Channel | Setup |
+| ------------ | -------------------------- |
+| **Telegram** | Easy (just a token) |
+| **Discord** | Easy (bot token + intents) |
+| **QQ** | Easy (AppID + AppSecret) |
+| **DingTalk** | Medium (app credentials) |
Telegram (Recommended)
@@ -596,15 +601,15 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
-| Provider | Purpose | Get API Key |
-|----------|---------|-------------|
-| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
-| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| Provider | Purpose | Get API Key |
+| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
Zhipu
@@ -630,8 +635,8 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
"zhipu": {
"api_key": "Your API Key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
- },
- },
+ }
+ }
}
```
@@ -692,8 +697,14 @@ picoclaw agent -m "Hello"
},
"tools": {
"web": {
- "search": {
- "api_key": "BSA..."
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
},
@@ -708,15 +719,15 @@ picoclaw agent -m "Hello"
## CLI Reference
-| Command | Description |
-|---------|-------------|
-| `picoclaw onboard` | Initialize config & workspace |
-| `picoclaw agent -m "..."` | Chat with the agent |
-| `picoclaw agent` | Interactive chat mode |
-| `picoclaw gateway` | Start the gateway |
-| `picoclaw status` | Show status |
-| `picoclaw cron list` | List all scheduled jobs |
-| `picoclaw cron add ...` | Add a scheduled job |
+| Command | Description |
+| ------------------------- | ----------------------------- |
+| `picoclaw onboard` | Initialize config & workspace |
+| `picoclaw agent -m "..."` | Chat with the agent |
+| `picoclaw agent` | Interactive chat mode |
+| `picoclaw gateway` | Start the gateway |
+| `picoclaw status` | Show status |
+| `picoclaw cron list` | List all scheduled jobs |
+| `picoclaw cron add ...` | Add a scheduled job |
### Scheduled Tasks / Reminders
@@ -750,21 +761,28 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro
To enable web search:
-1. Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month)
-2. Add to `~/.picoclaw/config.json`:
+1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
+2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
- ```json
- {
- "tools": {
- "web": {
- "search": {
- "api_key": "YOUR_BRAVE_API_KEY",
- "max_results": 5
- }
- }
- }
- }
- ```
+Add the key to `~/.picoclaw/config.json` if using Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
### Getting content filtering errors
@@ -778,9 +796,9 @@ This happens when another instance of the bot is running. Make sure only one `pi
## 📝 API Key Comparison
-| Service | Free Tier | Use Case |
-|---------|-----------|-----------|
-| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
-| **Zhipu** | 200K tokens/month | Best for Chinese users |
-| **Brave Search** | 2000 queries/month | Web search functionality |
-| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
+| Service | Free Tier | Use Case |
+| ---------------- | ------------------- | ------------------------------------- |
+| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
+| **Zhipu** | 200K tokens/month | Best for Chinese users |
+| **Brave Search** | 2000 queries/month | Web search functionality |
+| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 1f08926..ac8da9f 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -70,9 +70,15 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
// Shell execution
registry.Register(tools.NewExecTool(workspace, restrict))
- // Web tools
- braveAPIKey := cfg.Tools.Web.Search.APIKey
- registry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
+ if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
+ BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
+ BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
+ BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
+ DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ }); searchTool != nil {
+ registry.Register(searchTool)
+ }
registry.Register(tools.NewWebFetchTool(50000))
// Message tool - available to both agent and subagent
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index fabec1a..8d2d9a6 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -59,7 +59,22 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
for _, allowed := range c.allowList {
// Strip leading "@" from allowed value for username matching
trimmed := strings.TrimPrefix(allowed, "@")
- if senderID == allowed || idPart == allowed || senderID == trimmed || idPart == trimmed || (userPart != "" && (userPart == allowed || userPart == trimmed)) {
+ allowedID := trimmed
+ allowedUser := ""
+ if idx := strings.Index(trimmed, "|"); idx > 0 {
+ allowedID = trimmed[:idx]
+ allowedUser = trimmed[idx+1:]
+ }
+
+ // Support either side using "id|username" compound form.
+ // This keeps backward compatibility with legacy Telegram allowlist entries.
+ if senderID == allowed ||
+ idPart == allowed ||
+ senderID == trimmed ||
+ idPart == trimmed ||
+ idPart == allowedID ||
+ (allowedUser != "" && senderID == allowedUser) ||
+ (userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) {
return true
}
}
diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go
new file mode 100644
index 0000000..f82b04c
--- /dev/null
+++ b/pkg/channels/base_test.go
@@ -0,0 +1,53 @@
+package channels
+
+import "testing"
+
+func TestBaseChannelIsAllowed(t *testing.T) {
+ tests := []struct {
+ name string
+ allowList []string
+ senderID string
+ want bool
+ }{
+ {
+ name: "empty allowlist allows all",
+ allowList: nil,
+ senderID: "anyone",
+ want: true,
+ },
+ {
+ name: "compound sender matches numeric allowlist",
+ allowList: []string{"123456"},
+ senderID: "123456|alice",
+ want: true,
+ },
+ {
+ name: "compound sender matches username allowlist",
+ allowList: []string{"@alice"},
+ senderID: "123456|alice",
+ want: true,
+ },
+ {
+ name: "numeric sender matches legacy compound allowlist",
+ allowList: []string{"123456|alice"},
+ senderID: "123456",
+ want: true,
+ },
+ {
+ name: "non matching sender is denied",
+ allowList: []string{"123456"},
+ senderID: "654321|bob",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ch := NewBaseChannel("test", nil, nil, tt.allowList)
+ if got := ch.IsAllowed(tt.senderID); got != tt.want {
+ t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want)
+ }
+ })
+ }
+}
+
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 3e1c40e..0934dbd 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -177,15 +177,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
return
}
- senderID := fmt.Sprintf("%d", user.ID)
+ userID := fmt.Sprintf("%d", user.ID)
+ senderID := userID
if user.Username != "" {
- senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
+ senderID = fmt.Sprintf("%s|%s", userID, user.Username)
}
// 检查白名单,避免为被拒绝的用户下载附件
- if !c.IsAllowed(senderID) {
+ if !c.IsAllowed(userID) && !c.IsAllowed(senderID) {
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
- "user_id": senderID,
+ "user_id": userID,
+ "username": user.Username,
})
return
}
@@ -359,7 +361,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
- c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
+ c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
}
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 391120e..374c6f8 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -164,13 +164,20 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
}
-type WebSearchConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"`
+type BraveConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
+}
+
+type DuckDuckGoConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type WebToolsConfig struct {
- Search WebSearchConfig `json:"search"`
+ Brave BraveConfig `json:"brave"`
+ DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
}
type ToolsConfig struct {
@@ -257,10 +264,15 @@ func DefaultConfig() *Config {
},
Tools: ToolsConfig{
Web: WebToolsConfig{
- Search: WebSearchConfig{
+ Brave: BraveConfig{
+ Enabled: false,
APIKey: "",
MaxResults: 5,
},
+ DuckDuckGo: DuckDuckGoConfig{
+ Enabled: true,
+ MaxResults: 5,
+ },
},
},
Heartbeat: HeartbeatConfig{
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 2a6f8f5..9c1e363 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -212,12 +212,17 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
if tools, ok := getMap(data, "tools"); ok {
if web, ok := getMap(tools, "web"); ok {
+ // Migrate old "search" config to "brave" if api_key is present
if search, ok := getMap(web, "search"); ok {
if v, ok := getString(search, "api_key"); ok {
- cfg.Tools.Web.Search.APIKey = v
+ cfg.Tools.Web.Brave.APIKey = v
+ if v != "" {
+ cfg.Tools.Web.Brave.Enabled = true
+ }
}
if v, ok := getFloat(search, "max_results"); ok {
- cfg.Tools.Web.Search.MaxResults = int(v)
+ cfg.Tools.Web.Brave.MaxResults = int(v)
+ cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
}
}
}
@@ -271,8 +276,8 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
existing.Channels.MaixCam = incoming.Channels.MaixCam
}
- if existing.Tools.Web.Search.APIKey == "" {
- existing.Tools.Web.Search = incoming.Tools.Web.Search
+ if existing.Tools.Web.Brave.APIKey == "" {
+ existing.Tools.Web.Brave = incoming.Tools.Web.Brave
}
return existing
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index b1d0c03..fc78a18 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -42,7 +42,7 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
return &HTTPProvider{
apiKey: apiKey,
- apiBase: apiBase,
+ apiBase: strings.TrimRight(apiBase, "/"),
httpClient: client,
}
}
@@ -116,7 +116,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
}
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("API error: %s", string(body))
+ return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
}
return p.parseResponse(body)
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 3e8b7e9..6fc89c9 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -13,20 +13,210 @@ import (
)
const (
- userAgent = "Mozilla/5.0 (compatible; picoclaw/1.0)"
+ userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
+type SearchProvider interface {
+ Search(ctx context.Context, query string, count int) (string, error)
+}
+
+type BraveSearchProvider struct {
+ apiKey string
+}
+
+func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
+ url.QueryEscape(query), count)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("X-Subscription-Token", p.apiKey)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var searchResp struct {
+ Web struct {
+ Results []struct {
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Description string `json:"description"`
+ } `json:"results"`
+ } `json:"web"`
+ }
+
+ if err := json.Unmarshal(body, &searchResp); err != nil {
+ // Log error body for debugging
+ fmt.Printf("Brave API Error Body: %s\n", string(body))
+ return "", fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ results := searchResp.Web.Results
+ if len(results) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ var lines []string
+ lines = append(lines, fmt.Sprintf("Results for: %s", query))
+ for i, item := range results {
+ if i >= count {
+ break
+ }
+ lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
+ if item.Description != "" {
+ lines = append(lines, fmt.Sprintf(" %s", item.Description))
+ }
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+
+type DuckDuckGoSearchProvider struct{}
+
+func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
+
+ req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", userAgent)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ return p.extractResults(string(body), count, query)
+}
+
+func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
+ // Simple regex based extraction for DDG HTML
+ // Strategy: Find all result containers or key anchors directly
+
+ // Try finding the result links directly first, as they are the most critical
+ // Pattern: Title
+ // The previous regex was a bit strict. Let's make it more flexible for attributes order/content
+ reLink := regexp.MustCompile(`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`)
+ matches := reLink.FindAllStringSubmatch(html, count+5)
+
+ if len(matches) == 0 {
+ return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
+ }
+
+ var lines []string
+ lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query))
+
+ // Pre-compile snippet regex to run inside the loop
+ // We'll search for snippets relative to the link position or just globally if needed
+ // But simple global search for snippets might mismatch order.
+ // Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
+ // Or better: Let's assume the snippet follows the link in the HTML
+
+ // A better regex approach: iterate through text and find matches in order
+ // But for now, let's grab all snippets too
+ reSnippet := regexp.MustCompile(`([\s\S]*?)`)
+ snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
+
+ maxItems := min(len(matches), count)
+
+ for i := 0; i < maxItems; i++ {
+ urlStr := matches[i][1]
+ title := stripTags(matches[i][2])
+ title = strings.TrimSpace(title)
+
+ // URL decoding if needed
+ if strings.Contains(urlStr, "uddg=") {
+ if u, err := url.QueryUnescape(urlStr); err == nil {
+ idx := strings.Index(u, "uddg=")
+ if idx != -1 {
+ urlStr = u[idx+5:]
+ }
+ }
+ }
+
+ lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr))
+
+ // Attempt to attach snippet if available and index aligns
+ if i < len(snippetMatches) {
+ snippet := stripTags(snippetMatches[i][1])
+ snippet = strings.TrimSpace(snippet)
+ if snippet != "" {
+ lines = append(lines, fmt.Sprintf(" %s", snippet))
+ }
+ }
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func stripTags(content string) string {
+ re := regexp.MustCompile(`<[^>]+>`)
+ return re.ReplaceAllString(content, "")
+}
+
type WebSearchTool struct {
- apiKey string
+ provider SearchProvider
maxResults int
}
-func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool {
- if maxResults <= 0 || maxResults > 10 {
- maxResults = 5
+type WebSearchToolOptions struct {
+ BraveAPIKey string
+ BraveMaxResults int
+ BraveEnabled bool
+ DuckDuckGoMaxResults int
+ DuckDuckGoEnabled bool
+}
+
+func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
+ var provider SearchProvider
+ maxResults := 5
+
+ // Priority: Brave > DuckDuckGo
+ if opts.BraveEnabled && opts.BraveAPIKey != "" {
+ provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
+ if opts.BraveMaxResults > 0 {
+ maxResults = opts.BraveMaxResults
+ }
+ } else if opts.DuckDuckGoEnabled {
+ provider = &DuckDuckGoSearchProvider{}
+ if opts.DuckDuckGoMaxResults > 0 {
+ maxResults = opts.DuckDuckGoMaxResults
+ }
+ } else {
+ return nil
}
+
return &WebSearchTool{
- apiKey: apiKey,
+ provider: provider,
maxResults: maxResults,
}
}
@@ -59,10 +249,6 @@ func (t *WebSearchTool) Parameters() map[string]interface{} {
}
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
- if t.apiKey == "" {
- return ErrorResult("BRAVE_API_KEY not configured")
- }
-
query, ok := args["query"].(string)
if !ok {
return ErrorResult("query is required")
@@ -75,68 +261,14 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
}
}
- searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
- url.QueryEscape(query), count)
-
- req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
+ result, err := t.provider.Search(ctx, query, count)
if err != nil {
- return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
+ return ErrorResult(fmt.Sprintf("search failed: %v", err))
}
- req.Header.Set("Accept", "application/json")
- req.Header.Set("X-Subscription-Token", t.apiKey)
-
- client := &http.Client{Timeout: 10 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return ErrorResult(fmt.Sprintf("request failed: %v", err))
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
- }
-
- var searchResp struct {
- Web struct {
- Results []struct {
- Title string `json:"title"`
- URL string `json:"url"`
- Description string `json:"description"`
- } `json:"results"`
- } `json:"web"`
- }
-
- if err := json.Unmarshal(body, &searchResp); err != nil {
- return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
- }
-
- results := searchResp.Web.Results
- if len(results) == 0 {
- msg := fmt.Sprintf("No results for: %s", query)
- return &ToolResult{
- ForLLM: msg,
- ForUser: msg,
- }
- }
-
- var lines []string
- lines = append(lines, fmt.Sprintf("Results for: %s", query))
- for i, item := range results {
- if i >= count {
- break
- }
- lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
- if item.Description != "" {
- lines = append(lines, fmt.Sprintf(" %s", item.Description))
- }
- }
-
- output := strings.Join(lines, "\n")
return &ToolResult{
- ForLLM: fmt.Sprintf("Found %d results for: %s", len(results), query),
- ForUser: output,
+ ForLLM: result,
+ ForUser: result,
}
}