diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..7910cb1e2
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,37 @@
+## 📝 Description
+## 🗣️ Type of Change
+- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
+- [ ] ✨ New feature (non-breaking change which adds functionality)
+- [ ] 📖 Documentation update
+- [ ] ⚡ Code refactoring (no functional changes, no api changes)
+
+## 🤖 AI Code Generation
+- [ ] 🤖 Fully AI-generated (100% AI, 0% Human)
+- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
+- [ ] 👨💻 Mostly Human-written (Human lead, AI assisted or none)
+
+
+## 🔗 Linked Issue
+## 📚 Technical Context (Skip for Docs)
+* **Reference:** [URL]
+* **Reasoning:** ...
+
+
+## 🧪 Test Environment & Hardware
+- **Hardware:** [e.g. Raspberry Pi 5, Orange Pi, PC]
+- **OS:** [e.g. Debian 12, Ubuntu 22.04]
+- **Model/Provider:** [e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3]
+- **Channels:** [e.g. Discord, Telegram, Feishu, ...]
+
+
+## 📸 Proof of Work (Optional for Docs)
+Click to view Logs/Screenshots
+
+
+
### 🐜 Innovative Low-Footprint Deploy
PicoClaw can be deployed on almost any Linux device!
@@ -760,6 +774,9 @@ picoclaw agent -m "Hello"
"enabled": true,
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
diff --git a/README.zh.md b/README.zh.md
index 2ca2987bb..e7dc8d769 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -50,7 +50,7 @@
## 📢 新闻 (News)
-2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](doc/picoclaw_community_roadmap_260216.md), 期待你的参与!
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
@@ -100,6 +100,23 @@
+### 📱 在手机上轻松运行
+picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+1. 先去应用商店下载安装Termux
+2. 打开后执行指令
+```bash
+# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+
+
+
+
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
@@ -219,6 +236,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
}
}
@@ -627,6 +647,9 @@ picoclaw agent -m "你好"
"search": {
"api_key": "BSA..."
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..8c5c0e252
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,116 @@
+
+# 🦐 PicoClaw Roadmap
+
+> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity
+
+---
+
+## 🚀 1. Core Optimization: Extreme Lightweight
+
+*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*
+
+* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346)
+ * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.
+ * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.
+ * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.
+
+
+## 🛡️ 2. Security Hardening: Defense in Depth
+
+*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.*
+
+* **Input Defense & Permission Control**
+ * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.
+ * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.
+ * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).
+
+
+* **Sandboxing & Isolation**
+ * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.
+ * **Context Isolation**: Prevent data leakage between different user sessions or channels.
+ * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.
+
+
+* **Authentication & Secrets**
+ * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.
+ * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.
+
+
+
+## 🔌 3. Connectivity: Protocol-First Architecture
+
+*Connect every model, reach every platform.*
+
+* **Provider**
+ * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*
+ * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).
+ * **Online Models**: Continued support for frontier closed-source models.
+
+
+* **Channel**
+ * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
+ * **Standards**: Support for the **OneBot** protocol.
+ * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
+
+
+* **Skill Marketplace**
+ * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.
+
+
+
+## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI
+
+*Beyond conversation—focusing on action and collaboration.*
+
+* **Operations**
+ * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.
+ * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.
+ * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).
+
+
+* **Multi-Agent Collaboration**
+ * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement
+ * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).
+ * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.
+ * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.
+
+
+
+## 📚 5. Developer Experience (DevEx) & Documentation
+
+*Lowering the barrier to entry so anyone can deploy in minutes.*
+
+* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)
+ * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.
+
+
+* **Comprehensive Documentation**
+ * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.
+ * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels.
+ * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).
+
+
+
+## 🤖 6. Engineering: AI-Powered Open Source
+
+*Born from Vibe Coding, we continue to use AI to accelerate development.*
+
+* **AI-Enhanced CI/CD**
+ * Integrate AI for automated Code Review, Linting, and PR Labeling.
+ * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.
+ * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.
+
+
+
+## 🎨 7. Brand & Community
+
+* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!
+ * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes."
+
+
+
+---
+
+### 🤝 Call for Contributions
+
+We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!
\ No newline at end of file
diff --git a/assets/termux.jpg b/assets/termux.jpg
new file mode 100644
index 000000000..30c724a20
Binary files /dev/null and b/assets/termux.jpg differ
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 10b53948b..fd7ec484a 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -562,7 +562,8 @@ func gatewayCmd() {
})
// Setup cron tool and service
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace)
+ execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
+ cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout)
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
@@ -987,14 +988,14 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool) *cron.CronService {
+func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool
- cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict)
+ cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout)
agentLoop.RegisterTool(cronTool)
// Set the onJob handler
diff --git a/config/config.example.json b/config/config.example.json
index 3c9158e9c..7cd0ab8c6 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -14,7 +14,9 @@
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": [
+ "YOUR_USER_ID"
+ ]
},
"discord": {
"enabled": false,
@@ -115,10 +117,19 @@
},
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "pplx-xxx",
+ "max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
@@ -133,4 +144,4 @@
"host": "0.0.0.0",
"port": 18790
}
-}
+}
\ No newline at end of file
diff --git a/doc/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
similarity index 100%
rename from doc/picoclaw_community_roadmap_260216.md
rename to docs/picoclaw_community_roadmap_260216.md
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index cd4276155..d3afa298e 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -79,6 +79,9 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
+ PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
}); searchTool != nil {
registry.Register(searchTool)
}
diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go
index 5fc19adbe..01e570b25 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam.go
@@ -18,7 +18,6 @@ type MaixCamChannel struct {
listener net.Listener
clients map[net.Conn]bool
clientsMux sync.RWMutex
- running bool
}
type MaixCamMessage struct {
@@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
BaseChannel: base,
config: cfg,
clients: make(map[net.Conn]bool),
- running: false,
}, nil
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index d189ff00b..1d34f56f3 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -206,13 +206,25 @@ type DuckDuckGoConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
+type PerplexityConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+}
+
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
+ Perplexity PerplexityConfig `json:"perplexity"`
+}
+
+type CronToolsConfig struct {
+ ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
}
type ToolsConfig struct {
- Web WebToolsConfig `json:"web"`
+ Web WebToolsConfig `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
}
func DefaultConfig() *Config {
@@ -321,6 +333,14 @@ func DefaultConfig() *Config {
Enabled: true,
MaxResults: 5,
},
+ Perplexity: PerplexityConfig{
+ Enabled: false,
+ APIKey: "",
+ MaxResults: 5,
+ },
+ },
+ Cron: CronToolsConfig{
+ ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
},
},
Heartbeat: HeartbeatConfig{
diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go
index 6dff3a52e..7617bf716 100644
--- a/pkg/providers/codex_provider.go
+++ b/pkg/providers/codex_provider.go
@@ -217,12 +217,18 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
})
}
for _, tc := range msg.ToolCalls {
- argsJSON, _ := json.Marshal(tc.Arguments)
+ name, args, ok := resolveCodexToolCall(tc)
+ if !ok {
+ logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{
+ "call_id": tc.ID,
+ })
+ continue
+ }
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCall: &responses.ResponseFunctionToolCallParam{
CallID: tc.ID,
- Name: tc.Name,
- Arguments: string(argsJSON),
+ Name: name,
+ Arguments: args,
},
})
}
@@ -260,10 +266,6 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
params.Instructions = openai.Opt(defaultCodexInstructions)
}
- if maxTokens, ok := options["max_tokens"].(int); ok {
- params.MaxOutputTokens = openai.Opt(int64(maxTokens))
- }
-
if len(tools) > 0 {
params.Tools = translateToolsForCodex(tools)
}
@@ -271,6 +273,30 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
return params
}
+func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) {
+ name = tc.Name
+ if name == "" && tc.Function != nil {
+ name = tc.Function.Name
+ }
+ if name == "" {
+ return "", "", false
+ }
+
+ if len(tc.Arguments) > 0 {
+ argsJSON, err := json.Marshal(tc.Arguments)
+ if err != nil {
+ return "", "", false
+ }
+ return name, string(argsJSON), true
+ }
+
+ if tc.Function != nil && tc.Function.Arguments != "" {
+ return name, tc.Function.Arguments, true
+ }
+
+ return name, "{}", true
+}
+
func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam {
result := make([]responses.ToolUnionParam, 0, len(tools))
for _, t := range tools {
diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go
index 317b1a5de..8406760c4 100644
--- a/pkg/providers/codex_provider_test.go
+++ b/pkg/providers/codex_provider_test.go
@@ -29,6 +29,9 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
if params.Instructions.Or("") != defaultCodexInstructions {
t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions)
}
+ if params.MaxOutputTokens.Valid() {
+ t.Fatalf("MaxOutputTokens should not be set for Codex backend")
+ }
}
func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
@@ -65,6 +68,45 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
}
}
+func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
+ messages := []Message{
+ {Role: "user", Content: "Read a file"},
+ {
+ Role: "assistant",
+ ToolCalls: []ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "ok", ToolCallID: "call_1"},
+ }
+
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{})
+ if params.Input.OfInputItemList == nil {
+ t.Fatal("Input.OfInputItemList should not be nil")
+ }
+ if len(params.Input.OfInputItemList) != 3 {
+ t.Fatalf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList))
+ }
+
+ fc := params.Input.OfInputItemList[1].OfFunctionCall
+ if fc == nil {
+ t.Fatal("assistant tool call should be converted to function_call input item")
+ }
+ if fc.Name != "read_file" {
+ t.Errorf("Function call name = %q, want %q", fc.Name, "read_file")
+ }
+ if fc.Arguments != `{"path":"README.md"}` {
+ t.Errorf("Function call arguments = %q, want %q", fc.Arguments, `{"path":"README.md"}`)
+ }
+}
+
func TestBuildCodexParams_WithTools(t *testing.T) {
tools := []ToolDefinition{
{
@@ -214,6 +256,10 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
http.Error(w, "stream must be true", http.StatusBadRequest)
return
}
+ if _, ok := reqBody["max_output_tokens"]; ok {
+ http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
+ return
+ }
resp := map[string]interface{}{
"id": "resp_test",
@@ -293,6 +339,10 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
http.Error(w, "temperature is not supported", http.StatusBadRequest)
return
}
+ if _, ok := reqBody["max_output_tokens"]; ok {
+ http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
+ return
+ }
if reqBody["stream"] != true {
http.Error(w, "stream must be true", http.StatusBadRequest)
return
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 4b6f973d8..21bee42ef 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -28,12 +28,15 @@ type CronTool struct {
}
// NewCronTool creates a new CronTool
-func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool) *CronTool {
+// execTimeout: 0 means no timeout, >0 sets the timeout duration
+func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool {
+ execTool := NewExecTool(workspace, restrict)
+ execTool.SetTimeout(execTimeout) // 0 means no timeout
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
- execTool: NewExecTool(workspace, restrict),
+ execTool: execTool,
}
}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 1ca3fc35a..713850f97 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -89,7 +89,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
return ErrorResult(guardError)
}
- cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
+ // timeout == 0 means no timeout
+ var cmdCtx context.Context
+ var cancel context.CancelFunc
+ if t.timeout > 0 {
+ cmdCtx, cancel = context.WithTimeout(ctx, t.timeout)
+ } else {
+ cmdCtx, cancel = context.WithCancel(ctx)
+ }
defer cancel()
var cmd *exec.Cmd
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index ccd995842..6a6d40ecf 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -176,6 +176,71 @@ func stripTags(content string) string {
return re.ReplaceAllString(content, "")
}
+type PerplexitySearchProvider struct {
+ apiKey string
+}
+
+func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := "https://api.perplexity.ai/chat/completions"
+
+ payload := map[string]interface{}{
+ "model": "sonar",
+ "messages": []map[string]string{
+ {"role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary."},
+ {"role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count)},
+ },
+ "max_tokens": 1000,
+ }
+
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes)))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ req.Header.Set("User-Agent", userAgent)
+
+ client := &http.Client{Timeout: 30 * 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)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Perplexity API error: %s", string(body))
+ }
+
+ var searchResp struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+
+ if err := json.Unmarshal(body, &searchResp); err != nil {
+ return "", fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if len(searchResp.Choices) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
+}
+
type WebSearchTool struct {
provider SearchProvider
maxResults int
@@ -187,14 +252,22 @@ type WebSearchToolOptions struct {
BraveEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
+ PerplexityAPIKey string
+ PerplexityMaxResults int
+ PerplexityEnabled bool
}
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5
- // Priority: Brave > DuckDuckGo
- if opts.BraveEnabled && opts.BraveAPIKey != "" {
+ // Priority: Perplexity > Brave > DuckDuckGo
+ if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
+ provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
+ if opts.PerplexityMaxResults > 0 {
+ maxResults = opts.PerplexityMaxResults
+ }
+ } else if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults