From acac1972e60323607a57d74aaa9e1a767414f138 Mon Sep 17 00:00:00 2001 From: Luna Reed Date: Wed, 18 Feb 2026 02:01:29 +0800 Subject: [PATCH 01/21] fix(exec): terminate process tree on timeout --- pkg/tools/shell.go | 30 +++++++++++++- pkg/tools/shell_process_unix.go | 32 +++++++++++++++ pkg/tools/shell_process_windows.go | 27 ++++++++++++ pkg/tools/shell_timeout_unix_test.go | 61 ++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 pkg/tools/shell_process_unix.go create mode 100644 pkg/tools/shell_process_windows.go create mode 100644 pkg/tools/shell_timeout_unix_test.go diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 713850f97..11a1d59da 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -3,6 +3,7 @@ package tools import ( "bytes" "context" + "errors" "fmt" "os" "os/exec" @@ -109,18 +110,43 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To cmd.Dir = cwd } + prepareCommandForTermination(cmd) + var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - err := cmd.Run() + if err := cmd.Start(); err != nil { + return ErrorResult(fmt.Sprintf("failed to start command: %v", err)) + } + + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + var err error + select { + case err = <-done: + case <-cmdCtx.Done(): + _ = terminateProcessTree(cmd) + select { + case err = <-done: + case <-time.After(2 * time.Second): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + err = <-done + } + } + output := stdout.String() if stderr.Len() > 0 { output += "\nSTDERR:\n" + stderr.String() } if err != nil { - if cmdCtx.Err() == context.DeadlineExceeded { + if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { msg := fmt.Sprintf("Command timed out after %v", t.timeout) return &ToolResult{ ForLLM: msg, diff --git a/pkg/tools/shell_process_unix.go b/pkg/tools/shell_process_unix.go new file mode 100644 index 000000000..7b29a81bf --- /dev/null +++ b/pkg/tools/shell_process_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +package tools + +import ( + "os/exec" + "syscall" +) + +func prepareCommandForTermination(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func terminateProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + + pid := cmd.Process.Pid + if pid <= 0 { + return nil + } + + // Kill the entire process group spawned by the shell command. + _ = syscall.Kill(-pid, syscall.SIGKILL) + // Fallback kill on the shell process itself. + _ = cmd.Process.Kill() + return nil +} diff --git a/pkg/tools/shell_process_windows.go b/pkg/tools/shell_process_windows.go new file mode 100644 index 000000000..fe23b5c96 --- /dev/null +++ b/pkg/tools/shell_process_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package tools + +import ( + "os/exec" + "strconv" +) + +func prepareCommandForTermination(cmd *exec.Cmd) { + // no-op on Windows +} + +func terminateProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + + pid := cmd.Process.Pid + if pid <= 0 { + return nil + } + + _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run() + _ = cmd.Process.Kill() + return nil +} diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go new file mode 100644 index 000000000..4c6388b9b --- /dev/null +++ b/pkg/tools/shell_timeout_unix_test.go @@ -0,0 +1,61 @@ +//go:build !windows + +package tools + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func processExists(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} + +func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { + tool := NewExecTool(t.TempDir(), false) + tool.SetTimeout(500 * time.Millisecond) + + args := map[string]interface{}{ + // Spawn a child process that would outlive the shell unless process-group kill is used. + "command": "sleep 60 & echo $! > child.pid; wait", + } + + result := tool.Execute(context.Background(), args) + if !result.IsError { + t.Fatalf("expected timeout error, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "timed out") { + t.Fatalf("expected timeout message, got: %s", result.ForLLM) + } + + childPIDPath := filepath.Join(tool.workingDir, "child.pid") + data, err := os.ReadFile(childPIDPath) + if err != nil { + t.Fatalf("failed to read child pid file: %v", err) + } + + childPID, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + t.Fatalf("failed to parse child pid: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if !processExists(childPID) { + return + } + time.Sleep(50 * time.Millisecond) + } + + t.Fatalf("child process %d is still running after timeout", childPID) +} From 9e120f90ea4dcda6a4323850b6e169514e71a3ca Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Wed, 18 Feb 2026 21:48:23 +0200 Subject: [PATCH 02/21] feat(fmt): Run formatters --- .github/workflows/pr.yml | 20 -- .golangci.yaml | 9 +- Makefile | 11 +- cmd/picoclaw/main.go | 40 ++-- pkg/agent/context.go | 32 ++-- pkg/agent/instance.go | 2 +- pkg/agent/loop.go | 112 ++++++++---- pkg/agent/loop_test.go | 46 +++-- pkg/agent/memory.go | 8 +- pkg/agent/registry.go | 2 +- pkg/agent/registry_test.go | 8 +- pkg/auth/oauth.go | 17 +- pkg/auth/oauth_test.go | 26 +-- pkg/auth/store.go | 4 +- pkg/auth/store_test.go | 2 +- pkg/channels/base.go | 4 +- pkg/channels/dingtalk.go | 12 +- pkg/channels/discord.go | 5 +- pkg/channels/feishu_32.go | 4 +- pkg/channels/feishu_64.go | 6 +- pkg/channels/line.go | 36 ++-- pkg/channels/maixcam.go | 26 +-- pkg/channels/manager.go | 40 ++-- pkg/channels/onebot.go | 67 +++---- pkg/channels/qq.go | 10 +- pkg/channels/slack.go | 22 +-- pkg/channels/telegram.go | 32 ++-- pkg/channels/telegram_commands.go | 3 + pkg/channels/whatsapp.go | 8 +- pkg/config/config.go | 112 ++++++------ pkg/config/config_test.go | 4 +- pkg/cron/service.go | 16 +- pkg/cron/service_test.go | 2 +- pkg/devices/service.go | 8 +- pkg/devices/sources/usb_linux.go | 2 +- pkg/heartbeat/service.go | 6 +- pkg/heartbeat/service_test.go | 8 +- pkg/logger/logger.go | 38 ++-- pkg/logger/logger_test.go | 10 +- pkg/migrate/config.go | 38 ++-- pkg/migrate/migrate.go | 14 +- pkg/migrate/migrate_test.go | 172 +++++++++--------- pkg/providers/anthropic/provider.go | 38 ++-- pkg/providers/anthropic/provider_test.go | 55 ++++-- pkg/providers/claude_cli_provider.go | 8 +- .../claude_cli_provider_integration_test.go | 2 - pkg/providers/claude_cli_provider_test.go | 19 +- pkg/providers/claude_provider.go | 8 +- pkg/providers/claude_provider_test.go | 13 +- pkg/providers/codex_cli_credentials.go | 4 +- pkg/providers/codex_cli_credentials_test.go | 16 +- pkg/providers/codex_cli_provider.go | 8 +- .../codex_cli_provider_integration_test.go | 2 - pkg/providers/codex_cli_provider_test.go | 12 +- pkg/providers/codex_provider.go | 63 ++++--- pkg/providers/codex_provider_test.go | 111 +++++------ pkg/providers/fallback.go | 6 +- pkg/providers/fallback_test.go | 8 +- pkg/providers/github_copilot_provider.go | 16 +- pkg/providers/http_provider.go | 4 +- pkg/providers/openai_compat/provider.go | 32 ++-- pkg/providers/openai_compat/provider_test.go | 56 +++--- pkg/providers/protocoltypes/types.go | 16 +- pkg/providers/tool_call_extract.go | 2 +- pkg/providers/types.go | 24 ++- pkg/session/manager.go | 4 +- pkg/skills/installer.go | 4 +- pkg/state/state.go | 4 +- pkg/state/state_test.go | 2 +- pkg/tools/base.go | 10 +- pkg/tools/cron.go | 38 ++-- pkg/tools/edit.go | 34 ++-- pkg/tools/edit_test.go | 32 ++-- pkg/tools/filesystem.go | 36 ++-- pkg/tools/filesystem_test.go | 38 ++-- pkg/tools/i2c.go | 32 ++-- pkg/tools/i2c_linux.go | 20 +- pkg/tools/i2c_other.go | 6 +- pkg/tools/message.go | 14 +- pkg/tools/message_test.go | 20 +- pkg/tools/registry.go | 30 +-- pkg/tools/result_test.go | 2 +- pkg/tools/shell.go | 12 +- pkg/tools/shell_test.go | 26 +-- pkg/tools/spawn.go | 14 +- pkg/tools/spi.go | 32 ++-- pkg/tools/spi_linux.go | 14 +- pkg/tools/spi_other.go | 4 +- pkg/tools/subagent.go | 32 +++- pkg/tools/subagent_tool_test.go | 26 ++- pkg/tools/toolloop.go | 7 +- pkg/tools/types.go | 24 ++- pkg/tools/web.go | 48 +++-- pkg/tools/web_test.go | 26 +-- pkg/utils/media.go | 17 +- pkg/voice/transcriber.go | 40 ++-- 96 files changed, 1239 insertions(+), 976 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 55bf77e00..27782ced2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,29 +24,10 @@ jobs: with: version: v2.10.1 - # TODO: Remove once linter is properly configured - fmt-check: - name: Formatting - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Check formatting - run: | - make fmt - git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1) - # TODO: Remove once linter is properly configured vet: name: Vet runs-on: ubuntu-latest - needs: fmt-check steps: - name: Checkout uses: actions/checkout@v6 @@ -65,7 +46,6 @@ jobs: test: name: Tests runs-on: ubuntu-latest - needs: fmt-check steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.golangci.yaml b/.golangci.yaml index 80e54ac1c..6dafb6b56 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -160,12 +160,11 @@ issues: formatters: enable: + - gci + - gofmt + - gofumpt - goimports - # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step) - # - gci - # - gofmt - # - gofumpt - # - golines + - golines settings: gci: sections: diff --git a/Makefile b/Makefile index ff280e3e4..a5ad4a02d 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X GO?=go GOFLAGS?=-v -tags stdjson +# Golangci-lint +GOLANGCI_LINT?=golangci-lint + # Installation INSTALL_PREFIX?=$(HOME)/.local INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin @@ -126,13 +129,17 @@ clean: vet: @$(GO) vet ./... -## fmt: Format Go code +## test: Test Go code test: @$(GO) test ./... ## fmt: Format Go code fmt: - @$(GO) fmt ./... + @$(GOLANGCI_LINT) fmt + +## lint: Run linters +lint: + @$(GOLANGCI_LINT) run ## deps: Download dependencies deps: diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 128f8c421..5cd8039dd 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -22,6 +22,7 @@ import ( "time" "github.com/chzyer/readline" + "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/bus" @@ -248,7 +249,7 @@ func onboard() { func copyEmbeddedToTarget(targetDir string) error { // Ensure target directory exists - if err := os.MkdirAll(targetDir, 0755); err != nil { + if err := os.MkdirAll(targetDir, 0o755); err != nil { return fmt.Errorf("Failed to create target directory: %w", err) } @@ -278,12 +279,12 @@ func copyEmbeddedToTarget(targetDir string) error { targetPath := filepath.Join(targetDir, new_path) // Ensure target file's directory exists - if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) } // Write file - if err := os.WriteFile(targetPath, data, 0644); err != nil { + if err := os.WriteFile(targetPath, data, 0o644); err != nil { return fmt.Errorf("Failed to write file %s: %w", targetPath, err) } @@ -411,10 +412,10 @@ func agentCmd() { // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ - "tools_count": startupInfo["tools"].(map[string]interface{})["count"], - "skills_total": startupInfo["skills"].(map[string]interface{})["total"], - "skills_available": startupInfo["skills"].(map[string]interface{})["available"], + map[string]any{ + "tools_count": startupInfo["tools"].(map[string]any)["count"], + "skills_total": startupInfo["skills"].(map[string]any)["total"], + "skills_available": startupInfo["skills"].(map[string]any)["available"], }) if message != "" { @@ -441,7 +442,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { InterruptPrompt: "^C", EOFPrompt: "exit", }) - if err != nil { fmt.Printf("Error initializing readline: %v\n", err) fmt.Println("Falling back to simple input mode...") @@ -546,8 +546,8 @@ func gatewayCmd() { // Print agent startup info fmt.Println("\nšŸ“¦ Agent Status:") startupInfo := agentLoop.GetStartupInfo() - toolsInfo := startupInfo["tools"].(map[string]interface{}) - skillsInfo := startupInfo["skills"].(map[string]interface{}) + toolsInfo := startupInfo["tools"].(map[string]any) + skillsInfo := startupInfo["skills"].(map[string]any) fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], @@ -555,7 +555,7 @@ func gatewayCmd() { // Log to file as well logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ + map[string]any{ "tools_count": toolsInfo["count"], "skills_total": skillsInfo["total"], "skills_available": skillsInfo["available"], @@ -563,7 +563,14 @@ func gatewayCmd() { // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg) + cronService := setupCronTool( + agentLoop, + msgBus, + cfg.WorkspacePath(), + cfg.Agents.Defaults.RestrictToWorkspace, + execTimeout, + cfg, + ) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -667,7 +674,7 @@ func gatewayCmd() { healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) go func() { if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()}) + logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) } }() fmt.Printf("āœ“ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) @@ -988,7 +995,10 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService { +func setupCronTool( + agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, + config *config.Config, +) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service @@ -1315,7 +1325,7 @@ func skillsInstallBuiltinCmd(workspace string) { continue } - if err := os.MkdirAll(workspacePath, 0755); err != nil { + if err := os.MkdirAll(workspacePath, 0o755); err != nil { fmt.Printf("āœ— Failed to create directory for %s: %v\n", skillName, err) continue } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index cf5ce2913..9abb3e5af 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string { var sb strings.Builder sb.WriteString("## Available Tools\n\n") - sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n") + sb.WriteString( + "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n", + ) sb.WriteString("You have access to the following tools:\n\n") for _, s := range summaries { sb.WriteString(s) @@ -157,7 +159,9 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { return result } -func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message { +func (cb *ContextBuilder) BuildMessages( + history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string, +) []providers.Message { messages := []providers.Message{} systemPrompt := cb.BuildSystemPrompt() @@ -169,7 +173,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str // Log system prompt summary for debugging (debug mode only) logger.DebugCF("agent", "System prompt built", - map[string]interface{}{ + map[string]any{ "total_chars": len(systemPrompt), "total_lines": strings.Count(systemPrompt, "\n") + 1, "section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1, @@ -181,7 +185,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str preview = preview[:500] + "... (truncated)" } logger.DebugCF("agent", "System prompt preview", - map[string]interface{}{ + map[string]any{ "preview": preview, }) @@ -189,15 +193,15 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary } - //This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM + // This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM // --- INICIO DEL FIX --- - //Diegox-17 + // Diegox-17 for len(history) > 0 && (history[0].Role == "tool") { logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error", - map[string]interface{}{"role": history[0].Role}) + map[string]any{"role": history[0].Role}) history = history[1:] } - //Diegox-17 + // Diegox-17 // --- FIN DEL FIX --- messages = append(messages, providers.Message{ @@ -215,7 +219,9 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str return messages } -func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message { +func (cb *ContextBuilder) AddToolResult( + messages []providers.Message, toolCallID, toolName, result string, +) []providers.Message { messages = append(messages, providers.Message{ Role: "tool", Content: result, @@ -224,7 +230,9 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID return messages } -func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message { +func (cb *ContextBuilder) AddAssistantMessage( + messages []providers.Message, content string, toolCalls []map[string]any, +) []providers.Message { msg := providers.Message{ Role: "assistant", Content: content, @@ -254,13 +262,13 @@ func (cb *ContextBuilder) loadSkills() string { } // GetSkillsInfo returns information about loaded skills. -func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { +func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() skillNames := make([]string, 0, len(allSkills)) for _, s := range allSkills { skillNames = append(skillNames, s.Name) } - return map[string]interface{}{ + return map[string]any{ "total": len(allSkills), "available": len(allSkills), "names": skillNames, diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 54a5396e7..4b380cbc5 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -39,7 +39,7 @@ func NewAgentInstance( provider providers.LLMProvider, ) *AgentInstance { workspace := resolveAgentWorkspace(agentCfg, defaults) - os.MkdirAll(workspace, 0755) + os.MkdirAll(workspace, 0o755) model := resolveAgentModel(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ed69712ff..9b0926e61 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -79,7 +79,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). -func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) { +func registerSharedTools( + cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider, +) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) if !ok { @@ -215,7 +217,9 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") } -func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) { +func (al *AgentLoop) ProcessDirectWithChannel( + ctx context.Context, content, sessionKey, channel, chatID string, +) (string, error) { msg := bus.InboundMessage{ Channel: channel, SenderID: "cron", @@ -252,7 +256,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) logContent = utils.Truncate(msg.Content, 80) } logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), - map[string]interface{}{ + map[string]any{ "channel": msg.Channel, "chat_id": msg.ChatID, "sender_id": msg.SenderID, @@ -291,7 +295,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } logger.InfoCF("agent", "Routed message", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "session_key": sessionKey, "matched_by": route.MatchedBy, @@ -314,7 +318,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe } logger.InfoCF("agent", "Processing system message", - map[string]interface{}{ + map[string]any{ "sender_id": msg.SenderID, "chat_id": msg.ChatID, }) @@ -339,7 +343,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe // Skip internal channels - only log, don't send to user if constants.IsInternalChannel(originChannel) { logger.InfoCF("agent", "Subagent completed (internal channel)", - map[string]interface{}{ + map[string]any{ "sender_id": msg.SenderID, "content_len": len(content), "channel": originChannel, @@ -372,7 +376,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()}) + logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) } } } @@ -434,7 +438,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 9. Log response responsePreview := utils.Truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "session_key": opts.SessionKey, "iterations": iteration, @@ -445,7 +449,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // runLLMIteration executes the LLM call loop with tool handling. -func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) { +func (al *AgentLoop) runLLMIteration( + ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions, +) (string, int, error) { iteration := 0 var finalContent string @@ -453,7 +459,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, iteration++ logger.DebugCF("agent", "LLM iteration", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "max": agent.MaxIterations, @@ -464,7 +470,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // Log LLM request details logger.DebugCF("agent", "LLM request", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "model": agent.Model, @@ -477,7 +483,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // Log full messages (detailed) logger.DebugCF("agent", "Full LLM request", - map[string]interface{}{ + map[string]any{ "iteration": iteration, "messages_json": formatMessagesForLog(messages), "tools_json": formatToolsForLog(providerToolDefs), @@ -491,7 +497,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if len(agent.Candidates) > 1 && al.fallback != nil { fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{ + return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ "max_tokens": 8192, "temperature": 0.7, }) @@ -503,11 +509,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]interface{}{"agent_id": agent.ID, "iteration": iteration}) + map[string]any{"agent_id": agent.ID, "iteration": iteration}) } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{ + return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{ "max_tokens": 8192, "temperature": 0.7, }) @@ -528,7 +534,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, strings.Contains(errMsg, "length") if isContextError && retry < maxRetries { - logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{ + logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ "error": err.Error(), "retry": retry, }) @@ -555,7 +561,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if err != nil { logger.ErrorCF("agent", "LLM call failed", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "error": err.Error(), @@ -567,7 +573,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if len(response.ToolCalls) == 0 { finalContent = response.Content logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "content_chars": len(finalContent), @@ -581,7 +587,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, toolNames = append(toolNames, tc.Name) } logger.InfoCF("agent", "LLM requested tool calls", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "tools": toolNames, "count": len(response.ToolCalls), @@ -614,7 +620,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, argsJSON, _ := json.Marshal(tc.Arguments) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "tool": tc.Name, "iteration": iteration, @@ -629,14 +635,16 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // The agent will handle user notification via processSystemMessage if !result.Silent && result.ForUser != "" { logger.InfoCF("agent", "Async tool completed, agent will handle notification", - map[string]interface{}{ + map[string]any{ "tool": tc.Name, "content_len": len(result.ForUser), }) } } - toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) + toolResult := agent.Tools.ExecuteWithContext( + ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback, + ) // Send ForUser content to user immediately if not Silent if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { @@ -646,7 +654,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, Content: toolResult.ForUser, }) logger.DebugCF("agent", "Sent tool result to user", - map[string]interface{}{ + map[string]any{ "tool": tc.Name, "content_len": len(toolResult.ForUser), }) @@ -752,7 +760,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { newHistory = append(newHistory, history[0]) // System prompt // Add a note about compression - compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount) + compressionNote := fmt.Sprintf( + "[System: Emergency compression dropped %d oldest messages due to context limit]", + droppedCount, + ) // If there was an existing summary, we might lose it if it was in the dropped part (which is just messages). // The summary is stored separately in session.Summary, so it persists! // We just need to ensure the user knows there's a gap. @@ -770,7 +781,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { agent.Sessions.SetHistory(sessionKey, newHistory) agent.Sessions.Save(sessionKey) - logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{ + logger.WarnCF("agent", "Forced compression executed", map[string]any{ "session_key": sessionKey, "dropped_msgs": droppedCount, "new_count": len(newHistory), @@ -778,8 +789,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { } // GetStartupInfo returns information about loaded tools and skills for logging. -func (al *AgentLoop) GetStartupInfo() map[string]interface{} { - info := make(map[string]interface{}) +func (al *AgentLoop) GetStartupInfo() map[string]any { + info := make(map[string]any) agent := al.registry.GetDefaultAgent() if agent == nil { @@ -788,7 +799,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { // Tools info toolsList := agent.Tools.List() - info["tools"] = map[string]interface{}{ + info["tools"] = map[string]any{ "count": len(toolsList), "names": toolsList, } @@ -797,7 +808,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { info["skills"] = agent.ContextBuilder.GetSkillsInfo() // Agents info - info["agents"] = map[string]interface{}{ + info["agents"] = map[string]any{ "count": len(al.registry.ListAgentIDs()), "ids": al.registry.ListAgentIDs(), } @@ -849,7 +860,10 @@ func formatToolsForLog(tools []providers.ToolDefinition) string { result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) result += fmt.Sprintf(" Description: %s\n", tool.Function.Description) if len(tool.Function.Parameters) > 0 { - result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) + result += fmt.Sprintf( + " Parameters: %s\n", + utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), + ) } } result += "]" @@ -902,11 +916,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { s1, _ := al.summarizeBatch(ctx, agent, part1, "") s2, _ := al.summarizeBatch(ctx, agent, part2, "") - mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2) - resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) + mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, + s2, + ) + resp, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: mergePrompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + }, + ) if err == nil { finalSummary = resp.Content } else { @@ -928,7 +952,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { } // summarizeBatch summarizes a batch of messages. -func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) { +func (al *AgentLoop) summarizeBatch( + ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string, +) (string, error) { prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" if existingSummary != "" { prompt += "Existing context: " + existingSummary + "\n" @@ -938,10 +964,16 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, b prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content) } - response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) + response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + }, + ) if err != nil { return "", err } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index f2257973c..fc026bef4 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -17,7 +17,10 @@ import ( // mockProvider is a simple mock LLM provider for testing type mockProvider struct{} -func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { +func (m *mockProvider) Chat( + ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, + opts map[string]any, +) (*providers.LLMResponse, error) { return &providers.LLMResponse{ Content: "Mock response", ToolCalls: []providers.ToolCall{}, @@ -185,7 +188,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { // Verify tool is registered by checking it doesn't panic on GetStartupInfo // (actual tool retrieval is tested in tools package tests) info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]interface{}) + toolsInfo := info["tools"].(map[string]any) toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list @@ -260,7 +263,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { al.RegisterTool(testTool) info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]interface{}) + toolsInfo := info["tools"].(map[string]any) toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list @@ -307,7 +310,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) { t.Fatal("Expected 'tools' key in startup info") } - toolsMap, ok := toolsInfo.(map[string]interface{}) + toolsMap, ok := toolsInfo.(map[string]any) if !ok { t.Fatal("Expected 'tools' to be a map") } @@ -363,7 +366,10 @@ type simpleMockProvider struct { response string } -func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { +func (m *simpleMockProvider) Chat( + ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, + opts map[string]any, +) (*providers.LLMResponse, error) { return &providers.LLMResponse{ Content: m.response, ToolCalls: []providers.ToolCall{}, @@ -385,14 +391,14 @@ func (m *mockCustomTool) Description() string { return "Mock custom tool for testing" } -func (m *mockCustomTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (m *mockCustomTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, } } -func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { +func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { return tools.SilentResult("Custom tool executed") } @@ -410,14 +416,14 @@ func (m *mockContextualTool) Description() string { return "Mock contextual tool" } -func (m *mockContextualTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (m *mockContextualTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, } } -func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { +func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { return tools.SilentResult("Contextual tool executed") } @@ -537,7 +543,10 @@ type failFirstMockProvider struct { successResp string } -func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { +func (m *failFirstMockProvider) Chat( + ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, + opts map[string]any, +) (*providers.LLMResponse, error) { m.currentCall++ if m.currentCall <= m.failures { return nil, m.failError @@ -602,8 +611,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { // Call ProcessDirectWithChannel // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration - response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat") - + response, err := al.ProcessDirectWithChannel( + context.Background(), + "Trigger message", + sessionKey, + "test", + "test-chat", + ) if err != nil { t.Fatalf("Expected success after retry, got error: %v", err) } diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 3f6896f91..076e822fe 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -29,7 +29,7 @@ func NewMemoryStore(workspace string) *MemoryStore { memoryFile := filepath.Join(memoryDir, "MEMORY.md") // Ensure memory directory exists - os.MkdirAll(memoryDir, 0755) + os.MkdirAll(memoryDir, 0o755) return &MemoryStore{ workspace: workspace, @@ -57,7 +57,7 @@ func (ms *MemoryStore) ReadLongTerm() string { // WriteLongTerm writes content to the long-term memory file (MEMORY.md). func (ms *MemoryStore) WriteLongTerm(content string) error { - return os.WriteFile(ms.memoryFile, []byte(content), 0644) + return os.WriteFile(ms.memoryFile, []byte(content), 0o644) } // ReadToday reads today's daily note. @@ -77,7 +77,7 @@ func (ms *MemoryStore) AppendToday(content string) error { // Ensure month directory exists monthDir := filepath.Dir(todayFile) - os.MkdirAll(monthDir, 0755) + os.MkdirAll(monthDir, 0o755) var existingContent string if data, err := os.ReadFile(todayFile); err == nil { @@ -94,7 +94,7 @@ func (ms *MemoryStore) AppendToday(content string) error { newContent = existingContent + "\n" + content } - return os.WriteFile(todayFile, []byte(newContent), 0644) + return os.WriteFile(todayFile, []byte(newContent), 0o644) } // GetRecentDailyNotes returns daily notes from the last N days. diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 4cf5a6fca..77b846832 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -42,7 +42,7 @@ func NewAgentRegistry( instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) registry.agents[id] = instance logger.InfoCF("agent", "Registered agent", - map[string]interface{}{ + map[string]any{ "agent_id": id, "name": ac.Name, "workspace": instance.Workspace, diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index f196d7fb7..518bb441f 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -10,7 +10,13 @@ import ( type mockRegistryProvider struct{} -func (m *mockRegistryProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { +func (m *mockRegistryProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil } diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index dcd91bebd..c01fc3b88 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -200,8 +200,11 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) { deviceResp.Interval = 5 } - fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n", - cfg.Issuer, deviceResp.UserCode) + fmt.Printf( + "\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n", + cfg.Issuer, + deviceResp.UserCode, + ) deadline := time.After(15 * time.Minute) ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second) @@ -396,15 +399,15 @@ func extractAccountID(token string) string { return accountID } - if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok { + if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok { if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" { return accountID } } - if orgs, ok := claims["organizations"].([]interface{}); ok { + if orgs, ok := claims["organizations"].([]any); ok { for _, org := range orgs { - if orgMap, ok := org.(map[string]interface{}); ok { + if orgMap, ok := org.(map[string]any); ok { if accountID, ok := orgMap["id"].(string); ok && accountID != "" { return accountID } @@ -415,7 +418,7 @@ func extractAccountID(token string) string { return "" } -func parseJWTClaims(token string) (map[string]interface{}, error) { +func parseJWTClaims(token string) (map[string]any, error) { parts := strings.Split(token, ".") if len(parts) < 2 { return nil, fmt.Errorf("token is not a JWT") @@ -434,7 +437,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) { return nil, err } - var claims map[string]interface{} + var claims map[string]any if err := json.Unmarshal(decoded, &claims); err != nil { return nil, err } diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 5deb17805..0cb589069 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -10,7 +10,7 @@ import ( "testing" ) -func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string { +func makeJWTForClaims(t *testing.T, claims map[string]any) string { t.Helper() header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) @@ -89,7 +89,7 @@ func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) { } func TestParseTokenResponse(t *testing.T) { - resp := map[string]interface{}{ + resp := map[string]any{ "access_token": "test-access-token", "refresh_token": "test-refresh-token", "expires_in": 3600, @@ -120,8 +120,8 @@ func TestParseTokenResponse(t *testing.T) { } func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) { - idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"}) - resp := map[string]interface{}{ + idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"}) + resp := map[string]any{ "access_token": "opaque-access-token", "refresh_token": "test-refresh-token", "expires_in": 3600, @@ -139,9 +139,9 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) { } func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) { - token := makeJWTForClaims(t, map[string]interface{}{ - "organizations": []interface{}{ - map[string]interface{}{"id": "org_from_orgs"}, + token := makeJWTForClaims(t, map[string]any{ + "organizations": []any{ + map[string]any{"id": "org_from_orgs"}, }, }) @@ -160,7 +160,7 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) { func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) { idToken := makeJWTWithAccountID("acc-from-id") - resp := map[string]interface{}{ + resp := map[string]any{ "access_token": "not-a-jwt", "refresh_token": "test-refresh-token", "expires_in": 3600, @@ -180,7 +180,9 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) { func makeJWTWithAccountID(accountID string) string { header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`)) + payload := base64.RawURLEncoding.EncodeToString( + []byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`), + ) return header + "." + payload + ".sig" } @@ -201,7 +203,7 @@ func TestExchangeCodeForTokens(t *testing.T) { return } - resp := map[string]interface{}{ + resp := map[string]any{ "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_in": 3600, @@ -240,7 +242,7 @@ func TestRefreshAccessToken(t *testing.T) { return } - resp := map[string]interface{}{ + resp := map[string]any{ "access_token": "refreshed-access-token", "refresh_token": "refreshed-refresh-token", "expires_in": 3600, @@ -290,7 +292,7 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) { func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := map[string]interface{}{ + resp := map[string]any{ "access_token": "new-access-token-only", "expires_in": 3600, } diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 20724929a..d32d4495a 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -62,7 +62,7 @@ func LoadStore() (*AuthStore, error) { func SaveStore(store *AuthStore) error { path := authFilePath() dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return err } @@ -70,7 +70,7 @@ func SaveStore(store *AuthStore) error { if err != nil { return err } - return os.WriteFile(path, data, 0600) + return os.WriteFile(path, data, 0o600) } func GetCredential(provider string) (*AuthCredential, error) { diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index d96b460a1..f6793cfce 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -108,7 +108,7 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() - if perm != 0600 { + if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 4925099a3..cd6419ebb 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -17,14 +17,14 @@ type Channel interface { } type BaseChannel struct { - config interface{} + config any bus *bus.MessageBus running bool name string allowList []string } -func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowList []string) *BaseChannel { +func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel { return &BaseChannel{ config: config, bus: bus, diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go index 263785c0c..4e3a5d4f3 100644 --- a/pkg/channels/dingtalk.go +++ b/pkg/channels/dingtalk.go @@ -10,6 +10,7 @@ import ( "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } - logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{ + logger.DebugCF("dingtalk", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) @@ -120,12 +121,14 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // onChatBotMessageReceived implements the IChatBotMessageHandler function signature // This is called by the Stream SDK when a new message arrives // IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) -func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { +func (c *DingTalkChannel) onChatBotMessageReceived( + ctx context.Context, data *chatbot.BotCallbackDataModel, +) ([]byte, error) { // Extract message content from Text field content := data.Text.Content if content == "" { // Try to extract from Content interface{} if Text is empty - if contentMap, ok := data.Content.(map[string]interface{}); ok { + if contentMap, ok := data.Content.(map[string]any); ok { if textContent, ok := contentMap["content"].(string); ok { content = textContent } @@ -155,7 +158,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch "session_webhook": data.SessionWebhook, } - logger.DebugCF("dingtalk", "Received message", map[string]interface{}{ + logger.DebugCF("dingtalk", "Received message", map[string]any{ "sender_nick": senderNick, "sender_id": senderID, "preview": utils.Truncate(content, 50), @@ -184,7 +187,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c titleBytes, contentBytes, ) - if err != nil { return fmt.Errorf("failed to send reply: %w", err) } diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index f360c75ef..74ae44412 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -8,6 +8,7 @@ import ( "time" "github.com/bwmarrin/discordgo" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -106,7 +107,9 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks + chunks := splitMessage( + msg.Content, 1500, + ) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks for _, chunk := range chunks { if err := c.sendChunk(ctx, channelID, chunk); err != nil { diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go index 4e60fbc11..5109b8195 100644 --- a/pkg/channels/feishu_32.go +++ b/pkg/channels/feishu_32.go @@ -17,7 +17,9 @@ type FeishuChannel struct { // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { - return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config") + return nil, errors.New( + "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config", + ) } // Start is a stub method to satisfy the Channel interface diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go index 39dc40ac1..29d4001cb 100644 --- a/pkg/channels/feishu_64.go +++ b/pkg/channels/feishu_64.go @@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { go func() { if err := wsClient.Start(runCtx); err != nil { - logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{ + logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{ "error": err.Error(), }) } @@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) } - logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{ + logger.DebugCF("feishu", "Feishu message sent", map[string]any{ "chat_id": msg.ChatID, }) @@ -165,7 +165,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 metadata["tenant_key"] = *sender.TenantKey } - logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{ + logger.InfoCF("feishu", "Feishu message received", map[string]any{ "sender_id": senderID, "chat_id": chatID, "preview": utils.Truncate(content, 80), diff --git a/pkg/channels/line.go b/pkg/channels/line.go index ffb5533e8..f7ca98c92 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error { // Fetch bot profile to get bot's userId for mention detection if err := c.fetchBotInfo(); err != nil { - logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{ + logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ "error": err.Error(), }) } else { - logger.InfoCF("line", "Bot info fetched", map[string]interface{}{ + logger.InfoCF("line", "Bot info fetched", map[string]any{ "bot_user_id": c.botUserID, "basic_id": c.botBasicID, "display_name": c.botDisplayName, @@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error { } go func() { - logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{ + logger.InfoCF("line", "LINE webhook server listening", map[string]any{ "addr": addr, "path": path, }) if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("line", "Webhook server error", map[string]interface{}{ + logger.ErrorCF("line", "Webhook server error", map[string]any{ "error": err.Error(), }) } @@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error { shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := c.httpServer.Shutdown(shutdownCtx); err != nil { - logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{ + logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{ "error": err.Error(), }) } @@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{ + logger.ErrorCF("line", "Failed to read request body", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) @@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { Events []lineEvent `json:"events"` } if err := json.Unmarshal(body, &payload); err != nil { - logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{ + logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) @@ -266,7 +266,7 @@ type lineMentionee struct { func (c *LINEChannel) processEvent(event lineEvent) { if event.Type != "message" { - logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{ + logger.DebugCF("line", "Ignoring non-message event", map[string]any{ "type": event.Type, }) return @@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { var msg lineMessage if err := json.Unmarshal(event.Message, &msg); err != nil { - logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{ + logger.ErrorCF("line", "Failed to parse message", map[string]any{ "error": err.Error(), }) return @@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { // In group chats, only respond when the bot is mentioned if isGroup && !c.isBotMentioned(msg) { - logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{ + logger.DebugCF("line", "Ignoring group message without mention", map[string]any{ "chat_id": chatID, }) return @@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{ + logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{ "file": file, "error": err.Error(), }) @@ -366,7 +366,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { "message_id": msg.ID, } - logger.DebugCF("line", "Received message", map[string]interface{}{ + logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "message_type": msg.Type, @@ -497,7 +497,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { - logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{ + logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, "quoted": quoteToken != "", }) @@ -525,7 +525,7 @@ func buildTextMessage(content, quoteToken string) map[string]string { // sendReply sends a message using the LINE Reply API. func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "replyToken": replyToken, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } @@ -535,7 +535,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT // sendPush sends a message using the LINE Push API. func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "to": to, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } @@ -545,19 +545,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(chatID string) { - payload := map[string]interface{}{ + payload := map[string]any{ "chatId": chatID, "loadingSeconds": 60, } if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { - logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{ + logger.DebugCF("line", "Failed to send loading indicator", map[string]any{ "error": err.Error(), }) } } // callAPI makes an authenticated POST request to the LINE API. -func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error { +func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal payload: %w", err) diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go index 01e570b25..6288a792c 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam.go @@ -21,10 +21,10 @@ type MaixCamChannel struct { } type MaixCamMessage struct { - Type string `json:"type"` - Tips string `json:"tips"` - Timestamp float64 `json:"timestamp"` - Data map[string]interface{} `json:"data"` + Type string `json:"type"` + Tips string `json:"tips"` + Timestamp float64 `json:"timestamp"` + Data map[string]any `json:"data"` } func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { @@ -49,7 +49,7 @@ func (c *MaixCamChannel) Start(ctx context.Context) error { c.listener = listener c.setRunning(true) - logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{ + logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{ "host": c.config.Host, "port": c.config.Port, }) @@ -71,14 +71,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) { conn, err := c.listener.Accept() if err != nil { if c.running { - logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{ + logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{ "error": err.Error(), }) } return } - logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{ + logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{ "remote_addr": conn.RemoteAddr().String(), }) @@ -112,7 +112,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { var msg MaixCamMessage if err := decoder.Decode(&msg); err != nil { if err.Error() != "EOF" { - logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{ + logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{ "error": err.Error(), }) } @@ -133,14 +133,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) { case "status": c.handleStatusUpdate(msg) default: - logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{ + logger.WarnCF("maixcam", "Unknown message type", map[string]any{ "type": msg.Type, }) } } func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { - logger.InfoCF("maixcam", "", map[string]interface{}{ + logger.InfoCF("maixcam", "", map[string]any{ "timestamp": msg.Timestamp, "data": msg.Data, }) @@ -176,7 +176,7 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { - logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{ + logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{ "status": msg.Data, }) } @@ -214,7 +214,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("no connected MaixCam devices") } - response := map[string]interface{}{ + response := map[string]any{ "type": "command", "timestamp": float64(0), "message": msg.Content, @@ -229,7 +229,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro var sendErr error for conn := range c.clients { if _, err := conn.Write(data); err != nil { - logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{ + logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{ "client": conn.RemoteAddr().String(), "error": err.Error(), }) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7f6abc4cb..3ffaf5fb7 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -50,7 +50,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Telegram channel") telegram, err := NewTelegramChannel(m.config, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{ "error": err.Error(), }) } else { @@ -63,7 +63,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize WhatsApp channel") whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{ "error": err.Error(), }) } else { @@ -76,7 +76,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Feishu channel") feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{ "error": err.Error(), }) } else { @@ -89,7 +89,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Discord channel") discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{ "error": err.Error(), }) } else { @@ -102,7 +102,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize MaixCam channel") maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{ "error": err.Error(), }) } else { @@ -115,7 +115,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize QQ channel") qq, err := NewQQChannel(m.config.Channels.QQ, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{ "error": err.Error(), }) } else { @@ -128,7 +128,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize DingTalk channel") dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{ "error": err.Error(), }) } else { @@ -141,7 +141,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Slack channel") slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{ "error": err.Error(), }) } else { @@ -154,7 +154,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize LINE channel") line, err := NewLINEChannel(m.config.Channels.LINE, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{ "error": err.Error(), }) } else { @@ -167,7 +167,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize OneBot channel") onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{ "error": err.Error(), }) } else { @@ -176,7 +176,7 @@ func (m *Manager) initChannels() error { } } - logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{ + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) @@ -200,11 +200,11 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.dispatchOutbound(dispatchCtx) for name, channel := range m.channels { - logger.InfoCF("channels", "Starting channel", map[string]interface{}{ + logger.InfoCF("channels", "Starting channel", map[string]any{ "channel": name, }) if err := channel.Start(ctx); err != nil { - logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to start channel", map[string]any{ "channel": name, "error": err.Error(), }) @@ -227,11 +227,11 @@ func (m *Manager) StopAll(ctx context.Context) error { } for name, channel := range m.channels { - logger.InfoCF("channels", "Stopping channel", map[string]interface{}{ + logger.InfoCF("channels", "Stopping channel", map[string]any{ "channel": name, }) if err := channel.Stop(ctx); err != nil { - logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{ + logger.ErrorCF("channels", "Error stopping channel", map[string]any{ "channel": name, "error": err.Error(), }) @@ -266,14 +266,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { m.mu.RUnlock() if !exists { - logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{ + logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ "channel": msg.Channel, }) continue } if err := channel.Send(ctx, msg); err != nil { - logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{ + logger.ErrorCF("channels", "Error sending message to channel", map[string]any{ "channel": msg.Channel, "error": err.Error(), }) @@ -289,13 +289,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) { return channel, ok } -func (m *Manager) GetStatus() map[string]interface{} { +func (m *Manager) GetStatus() map[string]any { m.mu.RLock() defer m.mu.RUnlock() - status := make(map[string]interface{}) + status := make(map[string]any) for name, channel := range m.channels { - status[name] = map[string]interface{}{ + status[name] = map[string]any{ "enabled": true, "running": channel.IsRunning(), } diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 5d97fab9c..607aaed2a 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -76,9 +76,9 @@ type oneBotEvent struct { } type oneBotAPIRequest struct { - Action string `json:"action"` - Params interface{} `json:"params"` - Echo string `json:"echo,omitempty"` + Action string `json:"action"` + Params any `json:"params"` + Echo string `json:"echo,omitempty"` } type oneBotSendPrivateMsgParams struct { @@ -109,14 +109,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error { return fmt.Errorf("OneBot ws_url not configured") } - logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{ + logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{ "ws_url": c.config.WSUrl, }) c.ctx, c.cancel = context.WithCancel(ctx) if err := c.connect(); err != nil { - logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{ + logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{ "error": err.Error(), }) } else { @@ -178,7 +178,7 @@ func (c *OneBotChannel) reconnectLoop() { if conn == nil { logger.InfoC("onebot", "Attempting to reconnect...") if err := c.connect(); err != nil { - logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{ + logger.ErrorCF("onebot", "Reconnect failed", map[string]any{ "error": err.Error(), }) } else { @@ -246,7 +246,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.writeMu.Unlock() if err != nil { - logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{ + logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) return err @@ -255,7 +255,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } -func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) { +func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) { chatID := msg.ChatID if len(chatID) > 6 && chatID[:6] == "group:" { @@ -308,7 +308,7 @@ func (c *OneBotChannel) listen() { _, message, err := conn.ReadMessage() if err != nil { - logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{ + logger.ErrorCF("onebot", "WebSocket read error", map[string]any{ "error": err.Error(), }) c.mu.Lock() @@ -320,14 +320,14 @@ func (c *OneBotChannel) listen() { return } - logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{ + logger.DebugCF("onebot", "Raw WebSocket message received", map[string]any{ "length": len(message), "payload": string(message), }) var raw oneBotRawEvent if err := json.Unmarshal(message, &raw); err != nil { - logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{ "error": err.Error(), "payload": string(message), }) @@ -335,14 +335,14 @@ func (c *OneBotChannel) listen() { } if raw.Echo != "" || raw.Status.Online || raw.Status.Good { - logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{ + logger.DebugCF("onebot", "Received API response, skipping", map[string]any{ "echo": raw.Echo, "status": raw.Status, }) continue } - logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{ + logger.DebugCF("onebot", "Parsed raw event", map[string]any{ "post_type": raw.PostType, "message_type": raw.MessageType, "sub_type": raw.SubType, @@ -407,14 +407,14 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult return parseMessageResult{Text: s, IsBotMentioned: mentioned} } - var segments []map[string]interface{} + var segments []map[string]any if err := json.Unmarshal(raw, &segments); err == nil { var text string mentioned := false selfIDStr := strconv.FormatInt(selfID, 10) for _, seg := range segments { segType, _ := seg["type"].(string) - data, _ := seg["data"].(map[string]interface{}) + data, _ := seg["data"].(map[string]any) switch segType { case "text": if data != nil { @@ -441,7 +441,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { case "message": evt, err := c.normalizeMessageEvent(raw) if err != nil { - logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to normalize message event", map[string]any{ "error": err.Error(), }) return @@ -450,20 +450,20 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { case "meta_event": c.handleMetaEvent(raw) case "notice": - logger.DebugCF("onebot", "Notice event received", map[string]interface{}{ + logger.DebugCF("onebot", "Notice event received", map[string]any{ "sub_type": raw.SubType, }) case "request": - logger.DebugCF("onebot", "Request event received", map[string]interface{}{ + logger.DebugCF("onebot", "Request event received", map[string]any{ "sub_type": raw.SubType, }) case "": - logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{ + logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{ "echo": raw.Echo, "status": raw.Status, }) default: - logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{ + logger.DebugCF("onebot", "Unknown post_type", map[string]any{ "post_type": raw.PostType, }) } @@ -498,14 +498,14 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent var sender oneBotSender if len(raw.Sender) > 0 { if err := json.Unmarshal(raw.Sender, &sender); err != nil { - logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to parse sender", map[string]any{ "error": err.Error(), "sender": string(raw.Sender), }) } } - logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{ + logger.DebugCF("onebot", "Normalized message event", map[string]any{ "message_type": raw.MessageType, "user_id": userID, "group_id": groupID, @@ -534,13 +534,13 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { switch raw.MetaEventType { case "lifecycle": - logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{ + logger.InfoCF("onebot", "Lifecycle event", map[string]any{ "sub_type": raw.SubType, }) case "heartbeat": logger.DebugC("onebot", "Heartbeat received") default: - logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{ + logger.DebugCF("onebot", "Unknown meta_event_type", map[string]any{ "meta_event_type": raw.MetaEventType, }) } @@ -548,7 +548,7 @@ func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { if c.isDuplicate(evt.MessageID) { - logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{ + logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ "message_id": evt.MessageID, }) return @@ -556,7 +556,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { content := evt.Content if content == "" { - logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{ + logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{ "message_id": evt.MessageID, }) return @@ -572,7 +572,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { switch evt.MessageType { case "private": chatID = "private:" + senderID - logger.InfoCF("onebot", "Received private message", map[string]interface{}{ + logger.InfoCF("onebot", "Received private message", map[string]any{ "sender": senderID, "message_id": evt.MessageID, "length": len(content), @@ -597,7 +597,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned) if !triggered { - logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{ + logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{ "sender": senderID, "group": groupIDStr, "is_mentioned": evt.IsBotMentioned, @@ -607,7 +607,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { } content = strippedContent - logger.InfoCF("onebot", "Received group message", map[string]interface{}{ + logger.InfoCF("onebot", "Received group message", map[string]any{ "sender": senderID, "group": groupIDStr, "message_id": evt.MessageID, @@ -617,7 +617,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { }) default: - logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{ + logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{ "type": evt.MessageType, "message_id": evt.MessageID, "user_id": evt.UserID, @@ -629,7 +629,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { metadata["nickname"] = evt.Sender.Nickname } - logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{ + logger.DebugCF("onebot", "Forwarding message to bus", map[string]any{ "sender_id": senderID, "chat_id": chatID, "content": truncate(content, 100), @@ -668,7 +668,10 @@ func truncate(s string, n int) string { return string(runes[:n]) + "..." } -func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) { +func (c *OneBotChannel) checkGroupTrigger( + content string, + isBotMentioned bool, +) (triggered bool, strippedContent string) { if isBotMentioned { return true, strings.TrimSpace(content) } diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go index 18b4ca0e0..055498797 100644 --- a/pkg/channels/qq.go +++ b/pkg/channels/qq.go @@ -77,7 +77,7 @@ func (c *QQChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to get websocket info: %w", err) } - logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{ + logger.InfoCF("qq", "Got WebSocket info", map[string]any{ "shards": wsInfo.Shards, }) @@ -87,7 +87,7 @@ func (c *QQChannel) Start(ctx context.Context) error { // 在 goroutine 中启动 WebSocket čæžęŽ„ļ¼Œéæå…é˜»å”ž go func() { if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { - logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{ + logger.ErrorCF("qq", "WebSocket session error", map[string]any{ "error": err.Error(), }) c.setRunning(false) @@ -124,7 +124,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { // C2C ę¶ˆęÆå‘é€ _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) if err != nil { - logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{ + logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ "error": err.Error(), }) return err @@ -157,7 +157,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - logger.InfoCF("qq", "Received C2C message", map[string]interface{}{ + logger.InfoCF("qq", "Received C2C message", map[string]any{ "sender": senderID, "length": len(content), }) @@ -197,7 +197,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } - logger.InfoCF("qq", "Received group AT message", map[string]interface{}{ + logger.InfoCF("qq", "Received group AT message", map[string]any{ "sender": senderID, "group": data.GroupID, "length": len(content), diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index 0060972ed..f7359cd6d 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -75,7 +75,7 @@ func (c *SlackChannel) Start(ctx context.Context) error { c.botUserID = authResp.UserID c.teamID = authResp.TeamID - logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{ + logger.InfoCF("slack", "Slack bot connected", map[string]any{ "bot_user_id": c.botUserID, "team": authResp.Team, }) @@ -85,7 +85,7 @@ func (c *SlackChannel) Start(ctx context.Context) error { go func() { if err := c.socketClient.RunContext(c.ctx); err != nil { if c.ctx.Err() == nil { - logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{ + logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{ "error": err.Error(), }) } @@ -140,7 +140,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error }) } - logger.DebugCF("slack", "Message sent", map[string]interface{}{ + logger.DebugCF("slack", "Message sent", map[string]any{ "channel_id": channelID, "thread_ts": threadTS, }) @@ -202,7 +202,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { // ę£€ęŸ„ē™½åå•ļ¼Œéæå…äøŗč¢«ę‹’ē»ēš„ē”Øęˆ·äø‹č½½é™„ä»¶ if !c.IsAllowed(ev.User) { - logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{ + logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{ "user_id": ev.User, }) return @@ -238,7 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { - logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{ + logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{ "file": file, "error": err.Error(), }) @@ -261,7 +261,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { result, err := c.transcriber.Transcribe(ctx, localPath) if err != nil { - logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()}) + logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()}) content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name) } else { content += fmt.Sprintf("\n[voice transcription: %s]", result.Text) @@ -293,7 +293,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "team_id": c.teamID, } - logger.DebugCF("slack", "Received message", map[string]interface{}{ + logger.DebugCF("slack", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "preview": utils.Truncate(content, 50), @@ -309,7 +309,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { } if !c.IsAllowed(ev.User) { - logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{ + logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{ "user_id": ev.User, }) return @@ -375,7 +375,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { } if !c.IsAllowed(cmd.UserID) { - logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{ + logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{ "user_id": cmd.UserID, }) return @@ -400,7 +400,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "team_id": c.teamID, } - logger.DebugCF("slack", "Slash command received", map[string]interface{}{ + logger.DebugCF("slack", "Slash command received", map[string]any{ "sender_id": senderID, "command": cmd.Command, "text": utils.Truncate(content, 50), @@ -415,7 +415,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string { downloadURL = file.URLPrivate } if downloadURL == "" { - logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID}) + logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID}) return "" } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 24b82b557..eb5bedaaf 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -11,10 +11,9 @@ import ( "sync" "time" - th "github.com/mymmrac/telego/telegohandler" - "github.com/mymmrac/telego" "github.com/mymmrac/telego/telegohandler" + th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" @@ -120,7 +119,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error { }, th.AnyMessage()) c.setRunning(true) - logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{ + logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ "username": c.bot.Username(), }) @@ -133,6 +132,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return nil } + func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.setRunning(false) @@ -175,7 +175,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err tgMsg.ParseMode = telego.ModeHTML if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ + logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ "error": err.Error(), }) tgMsg.ParseMode = "" @@ -203,7 +203,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // ę£€ęŸ„ē™½åå•ļ¼Œéæå…äøŗč¢«ę‹’ē»ēš„ē”Øęˆ·äø‹č½½é™„ä»¶ if !c.IsAllowed(senderID) { - logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{ + logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ "user_id": senderID, }) return nil @@ -220,7 +220,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{ + logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{ "file": file, "error": err.Error(), }) @@ -265,14 +265,14 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes result, err := c.transcriber.Transcribe(ctx, voicePath) if err != nil { - logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{ + logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ "error": err.Error(), "path": voicePath, }) transcribedText = "[voice (transcription failed)]" } else { transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{ + logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{ "text": result.Text, }) } @@ -315,7 +315,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = "[empty message]" } - logger.DebugCF("telegram", "Received message", map[string]interface{}{ + logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": senderID, "chat_id": fmt.Sprintf("%d", chatID), "preview": utils.Truncate(content, 50), @@ -324,7 +324,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // Thinking indicator err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ + logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{ "error": err.Error(), }) } @@ -371,7 +371,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { - logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{ + logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{ "error": err.Error(), }) return "" @@ -386,7 +386,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st } url := c.bot.FileDownloadURL(file.FilePath) - logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url}) + logger.DebugCF("telegram", "File URL", map[string]any{"url": url}) // Use FilePath as filename for better identification filename := file.FilePath + ext @@ -398,7 +398,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { - logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{ + logger.ErrorCF("telegram", "Failed to get file", map[string]any{ "error": err.Error(), }) return "" @@ -456,7 +456,11 @@ func markdownToTelegramHTML(text string) string { for i, code := range codeBlocks.codes { escaped := escapeHTML(code) - text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("
%s
", escaped)) + text = strings.ReplaceAll( + text, + fmt.Sprintf("\x00CB%d\x00", i), + fmt.Sprintf("
%s
", escaped), + ) } return text diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go index df245e156..a084b641b 100644 --- a/pkg/channels/telegram_commands.go +++ b/pkg/channels/telegram_commands.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/mymmrac/telego" + "github.com/sipeed/picoclaw/pkg/config" ) @@ -35,6 +36,7 @@ func commandArgs(text string) string { } return strings.TrimSpace(parts[1]) } + func (c *cmd) Help(ctx context.Context, message telego.Message) error { msg := `/start - Start the bot /help - Show this help message @@ -96,6 +98,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error { }) return err } + func (c *cmd) List(ctx context.Context, message telego.Message) error { args := commandArgs(message.Text) if args == "" { diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go index c95e59578..6634f2722 100644 --- a/pkg/channels/whatsapp.go +++ b/pkg/channels/whatsapp.go @@ -86,7 +86,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("whatsapp connection not established") } - payload := map[string]interface{}{ + payload := map[string]any{ "type": "message", "to": msg.ChatID, "content": msg.Content, @@ -126,7 +126,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) { continue } - var msg map[string]interface{} + var msg map[string]any if err := json.Unmarshal(message, &msg); err != nil { log.Printf("Failed to unmarshal WhatsApp message: %v", err) continue @@ -144,7 +144,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) { } } -func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) { +func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { senderID, ok := msg["from"].(string) if !ok { return @@ -161,7 +161,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) { } var mediaPaths []string - if mediaData, ok := msg["media"].([]interface{}); ok { + if mediaData, ok := msg["media"].([]any); ok { mediaPaths = make([]string, 0, len(mediaData)) for _, m := range mediaData { if path, ok := m.(string); ok { diff --git a/pkg/config/config.go b/pkg/config/config.go index 682996bd6..306fc1f34 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,7 +23,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { } // Try []interface{} to handle mixed types - var raw []interface{} + var raw []any if err := json.Unmarshal(data, &raw); err != nil { return err } @@ -139,16 +139,16 @@ type SessionConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` 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"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { @@ -165,87 +165,87 @@ type ChannelsConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` } type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` } type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } type HeartbeatConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } type DevicesConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } @@ -266,11 +266,11 @@ type ProvidersConfig struct { } type ProviderConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` - ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` + APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` + APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` + AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` } type OpenAIProviderConfig struct { @@ -284,19 +284,19 @@ type GatewayConfig struct { } 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"` + 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"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` 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"` + 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"` } @@ -483,11 +483,11 @@ func SaveConfig(path string, cfg *Config) error { } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return err } - return os.WriteFile(path, data, 0600) + return os.WriteFile(path, data, 0o600) } func (c *Config) WorkspacePath() string { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 47916d155..8da0d214f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -55,7 +55,7 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - var result map[string]interface{} + var result map[string]any json.Unmarshal(data, &result) if result["primary"] != "claude-opus" { t.Errorf("primary = %v", result["primary"]) @@ -319,7 +319,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) { } perm := info.Mode().Perm() - if perm != 0600 { + if perm != 0o600 { t.Errorf("config file has permission %04o, want 0600", perm) } } diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 9f62c743b..e699a44b5 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -331,7 +331,7 @@ func (cs *CronService) loadStore() error { func (cs *CronService) saveStoreUnsafe() error { dir := filepath.Dir(cs.storePath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return err } @@ -340,10 +340,16 @@ func (cs *CronService) saveStoreUnsafe() error { return err } - return os.WriteFile(cs.storePath, data, 0600) + return os.WriteFile(cs.storePath, data, 0o600) } -func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) { +func (cs *CronService) AddJob( + name string, + schedule CronSchedule, + message string, + deliver bool, + channel, to string, +) (*CronJob, error) { cs.mu.Lock() defer cs.mu.Unlock() @@ -465,7 +471,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob { return enabled } -func (cs *CronService) Status() map[string]interface{} { +func (cs *CronService) Status() map[string]any { cs.mu.RLock() defer cs.mu.RUnlock() @@ -476,7 +482,7 @@ func (cs *CronService) Status() map[string]interface{} { } } - return map[string]interface{}{ + return map[string]any{ "enabled": cs.running, "jobs": len(cs.store.Jobs), "nextWakeAtMS": cs.getNextWakeMS(), diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go index 53d69f6a9..1a0dd1829 100644 --- a/pkg/cron/service_test.go +++ b/pkg/cron/service_test.go @@ -28,7 +28,7 @@ func TestSaveStore_FilePermissions(t *testing.T) { } perm := info.Mode().Perm() - if perm != 0600 { + if perm != 0o600 { t.Errorf("cron store has permission %04o, want 0600", perm) } } diff --git a/pkg/devices/service.go b/pkg/devices/service.go index 05a254729..1541d3c57 100644 --- a/pkg/devices/service.go +++ b/pkg/devices/service.go @@ -63,14 +63,14 @@ func (s *Service) Start(ctx context.Context) error { for _, src := range s.sources { eventCh, err := src.Start(s.ctx) if err != nil { - logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{ + logger.ErrorCF("devices", "Failed to start source", map[string]any{ "kind": src.Kind(), "error": err.Error(), }) continue } go s.handleEvents(src.Kind(), eventCh) - logger.InfoCF("devices", "Device source started", map[string]interface{}{ + logger.InfoCF("devices", "Device source started", map[string]any{ "kind": src.Kind(), }) } @@ -115,7 +115,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) { lastChannel := s.state.GetLastChannel() if lastChannel == "" { - logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{ + logger.DebugCF("devices", "No last channel, skipping notification", map[string]any{ "event": ev.FormatMessage(), }) return @@ -133,7 +133,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) { Content: msg, }) - logger.InfoCF("devices", "Device notification sent", map[string]interface{}{ + logger.InfoCF("devices", "Device notification sent", map[string]any{ "kind": ev.Kind, "action": ev.Action, "to": platform, diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go index 1f6c068b3..be0193cfb 100644 --- a/pkg/devices/sources/usb_linux.go +++ b/pkg/devices/sources/usb_linux.go @@ -115,7 +115,7 @@ func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, err } if err := scanner.Err(); err != nil { - logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()}) + logger.ErrorCF("devices", "udevadm scan error", map[string]any{"error": err.Error()}) } cmd.Wait() }() diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index dfdaef58b..75d6248b9 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -193,7 +193,7 @@ func (hs *HeartbeatService) executeHeartbeat() { if result.Async { hs.logInfo("Async task started: %s", result.ForLLM) logger.InfoCF("heartbeat", "Async heartbeat task started", - map[string]interface{}{ + map[string]any{ "message": result.ForLLM, }) return @@ -275,7 +275,7 @@ This file contains tasks for the heartbeat service to check periodically. Add your heartbeat tasks below this line: ` - if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil { + if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil { hs.logError("Failed to create default HEARTBEAT.md: %v", err) } else { hs.logInfo("Created default HEARTBEAT.md template") @@ -354,7 +354,7 @@ func (hs *HeartbeatService) logError(format string, args ...any) { // log writes a message to the heartbeat log file func (hs *HeartbeatService) log(level, format string, args ...any) { logFile := filepath.Join(hs.workspace, "heartbeat.log") - f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return } diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go index a2b59e350..a4dfa7a72 100644 --- a/pkg/heartbeat/service_test.go +++ b/pkg/heartbeat/service_test.go @@ -37,7 +37,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) { }) // Create HEARTBEAT.md - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) // Execute heartbeat directly (internal method for testing) hs.executeHeartbeat() @@ -68,7 +68,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) { }) // Create HEARTBEAT.md - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) hs.executeHeartbeat() @@ -106,7 +106,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) { }) // Create HEARTBEAT.md - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) hs.executeHeartbeat() @@ -174,7 +174,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) { }) // Create HEARTBEAT.md - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) // Should not panic with nil result hs.executeHeartbeat() diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 22f66829f..54de66bf9 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -41,12 +41,12 @@ type Logger struct { } type LogEntry struct { - Level string `json:"level"` - Timestamp string `json:"timestamp"` - Component string `json:"component,omitempty"` - Message string `json:"message"` - Fields map[string]interface{} `json:"fields,omitempty"` - Caller string `json:"caller,omitempty"` + Level string `json:"level"` + Timestamp string `json:"timestamp"` + Component string `json:"component,omitempty"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` + Caller string `json:"caller,omitempty"` } func init() { @@ -71,7 +71,7 @@ func EnableFileLogging(filePath string) error { mu.Lock() defer mu.Unlock() - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } @@ -96,7 +96,7 @@ func DisableFileLogging() { } } -func logMessage(level LogLevel, component string, message string, fields map[string]interface{}) { +func logMessage(level LogLevel, component string, message string, fields map[string]any) { if level < currentLevel { return } @@ -150,7 +150,7 @@ func formatComponent(component string) string { return fmt.Sprintf(" %s:", component) } -func formatFields(fields map[string]interface{}) string { +func formatFields(fields map[string]any) string { var parts []string for k, v := range fields { parts = append(parts, fmt.Sprintf("%s=%v", k, v)) @@ -166,11 +166,11 @@ func DebugC(component string, message string) { logMessage(DEBUG, component, message, nil) } -func DebugF(message string, fields map[string]interface{}) { +func DebugF(message string, fields map[string]any) { logMessage(DEBUG, "", message, fields) } -func DebugCF(component string, message string, fields map[string]interface{}) { +func DebugCF(component string, message string, fields map[string]any) { logMessage(DEBUG, component, message, fields) } @@ -182,11 +182,11 @@ func InfoC(component string, message string) { logMessage(INFO, component, message, nil) } -func InfoF(message string, fields map[string]interface{}) { +func InfoF(message string, fields map[string]any) { logMessage(INFO, "", message, fields) } -func InfoCF(component string, message string, fields map[string]interface{}) { +func InfoCF(component string, message string, fields map[string]any) { logMessage(INFO, component, message, fields) } @@ -198,11 +198,11 @@ func WarnC(component string, message string) { logMessage(WARN, component, message, nil) } -func WarnF(message string, fields map[string]interface{}) { +func WarnF(message string, fields map[string]any) { logMessage(WARN, "", message, fields) } -func WarnCF(component string, message string, fields map[string]interface{}) { +func WarnCF(component string, message string, fields map[string]any) { logMessage(WARN, component, message, fields) } @@ -214,11 +214,11 @@ func ErrorC(component string, message string) { logMessage(ERROR, component, message, nil) } -func ErrorF(message string, fields map[string]interface{}) { +func ErrorF(message string, fields map[string]any) { logMessage(ERROR, "", message, fields) } -func ErrorCF(component string, message string, fields map[string]interface{}) { +func ErrorCF(component string, message string, fields map[string]any) { logMessage(ERROR, component, message, fields) } @@ -230,10 +230,10 @@ func FatalC(component string, message string) { logMessage(FATAL, component, message, nil) } -func FatalF(message string, fields map[string]interface{}) { +func FatalF(message string, fields map[string]any) { logMessage(FATAL, "", message, fields) } -func FatalCF(component string, message string, fields map[string]interface{}) { +func FatalCF(component string, message string, fields map[string]any) { logMessage(FATAL, component, message, fields) } diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 9b9c96820..6e6f8dfa8 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -54,11 +54,11 @@ func TestLoggerWithComponent(t *testing.T) { name string component string message string - fields map[string]interface{} + fields map[string]any }{ {"Simple message", "test", "Hello, world!", nil}, {"Message with component", "discord", "Discord message", nil}, - {"Message with fields", "telegram", "Telegram message", map[string]interface{}{ + {"Message with fields", "telegram", "Telegram message", map[string]any{ "user_id": "12345", "count": 42, }}, @@ -128,12 +128,12 @@ func TestLoggerHelperFunctions(t *testing.T) { Error("This should log") InfoC("test", "Component message") - InfoF("Fields message", map[string]interface{}{"key": "value"}) + InfoF("Fields message", map[string]any{"key": "value"}) WarnC("test", "Warning with component") - ErrorF("Error with fields", map[string]interface{}{"error": "test"}) + ErrorF("Error with fields", map[string]any{"error": "test"}) SetLevel(DEBUG) DebugC("test", "Debug with component") - WarnF("Warning with fields", map[string]interface{}{"key": "value"}) + WarnF("Warning with fields", map[string]any{"key": "value"}) } diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 57032e566..c7b1acb58 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -44,26 +44,26 @@ func findOpenClawConfig(openclawHome string) (string, error) { return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome) } -func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) { +func LoadOpenClawConfig(configPath string) (map[string]any, error) { data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("reading OpenClaw config: %w", err) } - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parsing OpenClaw config: %w", err) } converted := convertKeysToSnake(raw) - result, ok := converted.(map[string]interface{}) + result, ok := converted.(map[string]any) if !ok { return nil, fmt.Errorf("unexpected config format") } return result, nil } -func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) { +func ConvertConfig(data map[string]any) (*config.Config, []string, error) { cfg := config.DefaultConfig() var warnings []string @@ -89,7 +89,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error if providers, ok := getMap(data, "providers"); ok { for name, val := range providers { - pMap, ok := val.(map[string]interface{}) + pMap, ok := val.(map[string]any) if !ok { continue } @@ -128,7 +128,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error if channels, ok := getMap(data, "channels"); ok { for name, val := range channels { - cMap, ok := val.(map[string]interface{}) + cMap, ok := val.(map[string]any) if !ok { continue } @@ -306,16 +306,16 @@ func camelToSnake(s string) string { return result.String() } -func convertKeysToSnake(data interface{}) interface{} { +func convertKeysToSnake(data any) any { switch v := data.(type) { - case map[string]interface{}: - result := make(map[string]interface{}, len(v)) + case map[string]any: + result := make(map[string]any, len(v)) for key, val := range v { result[camelToSnake(key)] = convertKeysToSnake(val) } return result - case []interface{}: - result := make([]interface{}, len(v)) + case []any: + result := make([]any, len(v)) for i, val := range v { result[i] = convertKeysToSnake(val) } @@ -330,16 +330,16 @@ func rewriteWorkspacePath(path string) string { return path } -func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) { +func getMap(data map[string]any, key string) (map[string]any, bool) { v, ok := data[key] if !ok { return nil, false } - m, ok := v.(map[string]interface{}) + m, ok := v.(map[string]any) return m, ok } -func getString(data map[string]interface{}, key string) (string, bool) { +func getString(data map[string]any, key string) (string, bool) { v, ok := data[key] if !ok { return "", false @@ -348,7 +348,7 @@ func getString(data map[string]interface{}, key string) (string, bool) { return s, ok } -func getFloat(data map[string]interface{}, key string) (float64, bool) { +func getFloat(data map[string]any, key string) (float64, bool) { v, ok := data[key] if !ok { return 0, false @@ -357,7 +357,7 @@ func getFloat(data map[string]interface{}, key string) (float64, bool) { return f, ok } -func getBool(data map[string]interface{}, key string) (bool, bool) { +func getBool(data map[string]any, key string) (bool, bool) { v, ok := data[key] if !ok { return false, false @@ -366,19 +366,19 @@ func getBool(data map[string]interface{}, key string) (bool, bool) { return b, ok } -func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool { +func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool { if v, ok := getBool(data, key); ok { return v } return defaultVal } -func getStringSlice(data map[string]interface{}, key string) []string { +func getStringSlice(data map[string]any, key string) []string { v, ok := data[key] if !ok { return []string{} } - arr, ok := v.([]interface{}) + arr, ok := v.([]any) if !ok { return []string{} } diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go index 921f821cb..ab2635890 100644 --- a/pkg/migrate/migrate.go +++ b/pkg/migrate/migrate.go @@ -161,7 +161,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result { fmt.Printf(" āœ“ Converted config: %s\n", action.Destination) } case ActionCreateDir: - if err := os.MkdirAll(action.Destination, 0755); err != nil { + if err := os.MkdirAll(action.Destination, 0o755); err != nil { result.Errors = append(result.Errors, err) } else { result.DirsCreated++ @@ -174,9 +174,13 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result { continue } result.BackupsCreated++ - fmt.Printf(" āœ“ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination)) + fmt.Printf( + " āœ“ Backed up %s -> %s.bak\n", + filepath.Base(action.Destination), + filepath.Base(action.Destination), + ) - if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil { result.Errors = append(result.Errors, err) continue } @@ -188,7 +192,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result { fmt.Printf(" āœ“ Copied %s\n", relPath(action.Source, openclawHome)) } case ActionCopy: - if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil { result.Errors = append(result.Errors, err) continue } @@ -226,7 +230,7 @@ func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) e incoming = MergeConfig(existing, incoming) } - if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil { return err } return config.SaveConfig(dstConfigPath, incoming) diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index e930d45f4..a7c4b5337 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -40,20 +40,20 @@ func TestCamelToSnake(t *testing.T) { } func TestConvertKeysToSnake(t *testing.T) { - input := map[string]interface{}{ + input := map[string]any{ "apiKey": "test-key", "apiBase": "https://example.com", - "nested": map[string]interface{}{ + "nested": map[string]any{ "maxTokens": float64(8192), - "allowFrom": []interface{}{"user1", "user2"}, - "deeperLevel": map[string]interface{}{ + "allowFrom": []any{"user1", "user2"}, + "deeperLevel": map[string]any{ "clientId": "abc", }, }, } result := convertKeysToSnake(input) - m, ok := result.(map[string]interface{}) + m, ok := result.(map[string]any) if !ok { t.Fatal("expected map[string]interface{}") } @@ -65,7 +65,7 @@ func TestConvertKeysToSnake(t *testing.T) { t.Error("expected key 'api_base' after conversion") } - nested, ok := m["nested"].(map[string]interface{}) + nested, ok := m["nested"].(map[string]any) if !ok { t.Fatal("expected nested map") } @@ -76,7 +76,7 @@ func TestConvertKeysToSnake(t *testing.T) { t.Error("expected key 'allow_from' in nested map") } - deeper, ok := nested["deeper_level"].(map[string]interface{}) + deeper, ok := nested["deeper_level"].(map[string]any) if !ok { t.Fatal("expected deeper_level map") } @@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") - openclawConfig := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + openclawConfig := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-ant-test123", "apiBase": "https://api.anthropic.com", }, }, - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ + "agents": map[string]any{ + "defaults": map[string]any{ "maxTokens": float64(4096), "model": "claude-3-opus", }, @@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(configPath, data, 0644); err != nil { + if err := os.WriteFile(configPath, data, 0o644); err != nil { t.Fatal(err) } @@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) { t.Fatalf("LoadOpenClawConfig: %v", err) } - providers, ok := result["providers"].(map[string]interface{}) + providers, ok := result["providers"].(map[string]any) if !ok { t.Fatal("expected providers map") } - anthropic, ok := providers["anthropic"].(map[string]interface{}) + anthropic, ok := providers["anthropic"].(map[string]any) if !ok { t.Fatal("expected anthropic map") } @@ -129,11 +129,11 @@ func TestLoadOpenClawConfig(t *testing.T) { t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"]) } - agents, ok := result["agents"].(map[string]interface{}) + agents, ok := result["agents"].(map[string]any) if !ok { t.Fatal("expected agents map") } - defaults, ok := agents["defaults"].(map[string]interface{}) + defaults, ok := agents["defaults"].(map[string]any) if !ok { t.Fatal("expected defaults map") } @@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) { func TestConvertConfig(t *testing.T) { t.Run("providers mapping", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + data := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "api_key": "sk-ant-test", "api_base": "https://api.anthropic.com", }, - "openrouter": map[string]interface{}{ + "openrouter": map[string]any{ "api_key": "sk-or-test", }, - "groq": map[string]interface{}{ + "groq": map[string]any{ "api_key": "gsk-test", }, }, @@ -178,9 +178,9 @@ func TestConvertConfig(t *testing.T) { }) t.Run("unsupported provider warning", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "deepseek": map[string]interface{}{ + data := map[string]any{ + "providers": map[string]any{ + "deepseek": map[string]any{ "api_key": "sk-deep-test", }, }, @@ -199,14 +199,14 @@ func TestConvertConfig(t *testing.T) { }) t.Run("channels mapping", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ + data := map[string]any{ + "channels": map[string]any{ + "telegram": map[string]any{ "enabled": true, "token": "tg-token-123", - "allow_from": []interface{}{"user1"}, + "allow_from": []any{"user1"}, }, - "discord": map[string]interface{}{ + "discord": map[string]any{ "enabled": true, "token": "disc-token-456", }, @@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) { }) t.Run("unsupported channel warning", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "email": map[string]interface{}{ + data := map[string]any{ + "channels": map[string]any{ + "email": map[string]any{ "enabled": true, }, }, @@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) { }) t.Run("agent defaults", func(t *testing.T) { - data := map[string]interface{}{ - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ + data := map[string]any{ + "agents": map[string]any{ + "defaults": map[string]any{ "model": "claude-3-opus", "max_tokens": float64(4096), "temperature": 0.5, @@ -284,7 +284,7 @@ func TestConvertConfig(t *testing.T) { }) t.Run("empty config", func(t *testing.T) { - data := map[string]interface{}{} + data := map[string]any{} cfg, warnings, err := ConvertConfig(data) if err != nil { @@ -386,9 +386,9 @@ func TestPlanWorkspaceMigration(t *testing.T) { srcDir := t.TempDir() dstDir := t.TempDir() - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644) + os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644) + os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644) + os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -417,8 +417,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { srcDir := t.TempDir() dstDir := t.TempDir() - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644) + os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644) + os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -440,8 +440,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { srcDir := t.TempDir() dstDir := t.TempDir() - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644) + os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644) + os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, true) if err != nil { @@ -460,8 +460,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { dstDir := t.TempDir() memDir := filepath.Join(srcDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644) + os.MkdirAll(memDir, 0o755) + os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -491,8 +491,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { dstDir := t.TempDir() skillDir := filepath.Join(srcDir, "skills", "weather") - os.MkdirAll(skillDir, 0755) - os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644) + os.MkdirAll(skillDir, 0o755) + os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -515,7 +515,7 @@ func TestFindOpenClawConfig(t *testing.T) { t.Run("finds openclaw.json", func(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(configPath, []byte("{}"), 0644) + os.WriteFile(configPath, []byte("{}"), 0o644) found, err := findOpenClawConfig(tmpDir) if err != nil { @@ -529,7 +529,7 @@ func TestFindOpenClawConfig(t *testing.T) { t.Run("falls back to config.json", func(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") - os.WriteFile(configPath, []byte("{}"), 0644) + os.WriteFile(configPath, []byte("{}"), 0o644) found, err := findOpenClawConfig(tmpDir) if err != nil { @@ -543,8 +543,8 @@ func TestFindOpenClawConfig(t *testing.T) { t.Run("prefers openclaw.json over config.json", func(t *testing.T) { tmpDir := t.TempDir() openclawPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(openclawPath, []byte("{}"), 0644) - os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644) + os.WriteFile(openclawPath, []byte("{}"), 0o644) + os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644) found, err := findOpenClawConfig(tmpDir) if err != nil { @@ -590,19 +590,19 @@ func TestRunDryRun(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) + os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "test-key", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ DryRun: true, @@ -631,33 +631,33 @@ func TestRunFullMigration(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644) + os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644) + os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644) memDir := filepath.Join(wsDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644) + os.MkdirAll(memDir, 0o755) + os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-ant-migrate-test", }, - "openrouter": map[string]interface{}{ + "openrouter": map[string]any{ "apiKey": "sk-or-migrate-test", }, }, - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ + "channels": map[string]any{ + "telegram": map[string]any{ "enabled": true, "token": "tg-migrate-test", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ Force: true, @@ -751,7 +751,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) { func TestBackupFile(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "test.md") - os.WriteFile(filePath, []byte("original content"), 0644) + os.WriteFile(filePath, []byte("original content"), 0o644) if err := backupFile(filePath); err != nil { t.Fatalf("backupFile: %v", err) @@ -772,7 +772,7 @@ func TestCopyFile(t *testing.T) { srcPath := filepath.Join(tmpDir, "src.md") dstPath := filepath.Join(tmpDir, "dst.md") - os.WriteFile(srcPath, []byte("file content"), 0644) + os.WriteFile(srcPath, []byte("file content"), 0o644) if err := copyFile(srcPath, dstPath); err != nil { t.Fatalf("copyFile: %v", err) @@ -792,18 +792,18 @@ func TestRunConfigOnly(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-config-only", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ Force: true, @@ -832,18 +832,18 @@ func TestRunWorkspaceOnly(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-ws-only", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ Force: true, diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 8f46aa70c..28e04b506 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -9,16 +9,19 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -type ToolCall = protocoltypes.ToolCall -type FunctionCall = protocoltypes.FunctionCall -type LLMResponse = protocoltypes.LLMResponse -type UsageInfo = protocoltypes.UsageInfo -type Message = protocoltypes.Message -type ToolDefinition = protocoltypes.ToolDefinition -type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) const defaultBaseURL = "https://api.anthropic.com" @@ -61,7 +64,13 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri return p } -func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { var opts []option.RequestOption if p.tokenSource != nil { tok, err := p.tokenSource() @@ -92,7 +101,12 @@ func (p *Provider) BaseURL() string { return p.baseURL } -func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { +func buildParams( + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (anthropic.MessageNewParams, error) { var system []anthropic.TextBlockParam var anthropicMessages []anthropic.MessageParam @@ -170,7 +184,7 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { if desc := t.Function.Description; desc != "" { tool.Description = anthropic.String(desc) } - if req, ok := t.Function.Parameters["required"].([]interface{}); ok { + if req, ok := t.Function.Parameters["required"].([]any); ok { required := make([]string, 0, len(req)) for _, r := range req { if s, ok := r.(string); ok { @@ -195,10 +209,10 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { content += tb.Text case "tool_use": tu := block.AsToolUse() - var args map[string]interface{} + var args map[string]any if err := json.Unmarshal(tu.Input, &args); err != nil { log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err) - args = map[string]interface{}{"raw": string(tu.Input)} + args = map[string]any{"raw": string(tu.Input)} } toolCalls = append(toolCalls, ToolCall{ ID: tu.ID, diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 6a1dabafb..6cfb2948a 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -15,7 +15,7 @@ func TestBuildParams_BasicMessage(t *testing.T) { messages := []Message{ {Role: "user", Content: "Hello"}, } - params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{ + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]any{ "max_tokens": 1024, }) if err != nil { @@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) { {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } - params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } @@ -62,13 +62,13 @@ func TestBuildParams_ToolCallMessage(t *testing.T) { { ID: "call_1", Name: "get_weather", - Arguments: map[string]interface{}{"city": "SF"}, + Arguments: map[string]any{"city": "SF"}, }, }, }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } - params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } @@ -84,17 +84,22 @@ func TestBuildParams_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a city", - Parameters: map[string]interface{}{ + Parameters: map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, }, - "required": []interface{}{"city"}, + "required": []any{"city"}, }, }, }, } - params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + params, err := buildParams( + []Message{{Role: "user", Content: "Hi"}}, + tools, + "claude-sonnet-4-5-20250929", + map[string]any{}, + ) if err != nil { t.Fatalf("buildParams() error: %v", err) } @@ -154,19 +159,19 @@ func TestProvider_ChatRoundTrip(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) - resp := map[string]interface{}{ + resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "text", "text": "Hello! How can I help you?"}, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 15, "output_tokens": 8, }, @@ -178,7 +183,13 @@ func TestProvider_ChatRoundTrip(t *testing.T) { provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024}) + resp, err := provider.Chat( + t.Context(), + messages, + nil, + "claude-sonnet-4-5-20250929", + map[string]any{"max_tokens": 1024}, + ) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -221,19 +232,19 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) - resp := map[string]interface{}{ + resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "text", "text": "ok"}, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 1, "output_tokens": 1, }, @@ -247,7 +258,13 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) { return "refreshed-token", nil }, server.URL) - _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "claude-sonnet-4-5-20250929", + map[string]any{}, + ) if err != nil { t.Fatalf("Chat() error: %v", err) } diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 58ba3647d..74ec33b98 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -24,7 +24,9 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { } // Chat implements LLMProvider.Chat by executing the claude CLI. -func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *ClaudeCliProvider) Chat( + ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, +) (*LLMResponse, error) { systemPrompt := p.buildSystemPrompt(messages, tools) prompt := p.messagesToPrompt(messages) @@ -111,7 +113,9 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { sb.WriteString("## Available Tools\n\n") sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") sb.WriteString("```json\n") - sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) + sb.WriteString( + `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, + ) sb.WriteString("\n```\n\n") sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") sb.WriteString("### Tool Definitions:\n\n") diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/claude_cli_provider_integration_test.go index 9d1131ac4..f6e0d787a 100644 --- a/pkg/providers/claude_cli_provider_integration_test.go +++ b/pkg/providers/claude_cli_provider_integration_test.go @@ -28,7 +28,6 @@ func TestIntegration_RealClaudeCLI(t *testing.T) { resp, err := p.Chat(ctx, []Message{ {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, }, nil, "", nil) - if err != nil { t.Fatalf("Chat() with real CLI error = %v", err) } @@ -75,7 +74,6 @@ func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) { {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, {Role: "user", Content: "What is 2+2?"}, }, nil, "", nil) - if err != nil { t.Fatalf("Chat() error = %v", err) } diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 063530deb..5bfe33247 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -30,12 +30,12 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string { dir := t.TempDir() if stdout != "" { - if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0o644); err != nil { t.Fatal(err) } } if stderr != "" { - if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); err != nil { t.Fatal(err) } } @@ -51,7 +51,7 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string { sb.WriteString(fmt.Sprintf("exit %d\n", exitCode)) script := filepath.Join(dir, "claude") - if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil { + if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil { t.Fatal(err) } return script @@ -67,7 +67,7 @@ func createSlowMockCLI(t *testing.T, sleepSeconds int) string { dir := t.TempDir() script := filepath.Join(dir, "claude") content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { t.Fatal(err) } return script @@ -88,7 +88,7 @@ cat <<'EOFMOCK' {"type":"result","result":"ok","session_id":"test"} EOFMOCK `, argsFile) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { t.Fatal(err) } return script @@ -137,7 +137,6 @@ func TestChat_Success(t *testing.T) { resp, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) - if err != nil { t.Fatalf("Chat() error = %v", err) } @@ -193,7 +192,6 @@ func TestChat_WithToolCallsInResponse(t *testing.T) { resp, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "What's the weather?"}, }, nil, "", nil) - if err != nil { t.Fatalf("Chat() error = %v", err) } @@ -403,7 +401,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { resp, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) - if err != nil { t.Fatalf("Chat() with empty workspace error = %v", err) } @@ -611,10 +608,10 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a location", - Parameters: map[string]interface{}{ + Parameters: map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "location": map[string]interface{}{"type": "string"}, + "properties": map[string]any{ + "location": map[string]any{"type": "string"}, }, }, }, diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index 3ca54d5a3..60639ca18 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -29,7 +29,9 @@ func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, } } -func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider { +func NewClaudeProviderWithTokenSourceAndBaseURL( + token string, tokenSource func() (string, error), apiBase string, +) *ClaudeProvider { return &ClaudeProvider{ delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase), } @@ -39,7 +41,9 @@ func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *Claude return &ClaudeProvider{delegate: delegate} } -func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *ClaudeProvider) Chat( + ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, +) (*LLMResponse, error) { resp, err := p.delegate.Chat(ctx, messages, tools, model, options) if err != nil { return nil, err diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go index 13bbde1fc..1f15e2792 100644 --- a/pkg/providers/claude_provider_test.go +++ b/pkg/providers/claude_provider_test.go @@ -8,6 +8,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) @@ -22,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) - resp := map[string]interface{}{ + resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "text", "text": "Hello! How can I help you?"}, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 15, "output_tokens": 8, }, @@ -48,7 +49,9 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) { provider := newClaudeProviderWithDelegate(delegate) messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024}) + resp, err := provider.Chat( + t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]any{"max_tokens": 1024}, + ) if err != nil { t.Fatalf("Chat() error: %v", err) } diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go index 7ad39ce8e..46ba24b12 100644 --- a/pkg/providers/codex_cli_credentials.go +++ b/pkg/providers/codex_cli_credentials.go @@ -59,7 +59,9 @@ func CreateCodexCliTokenSource() func() (string, string, error) { } if time.Now().After(expiresAt) { - return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login") + return "", "", fmt.Errorf( + "codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login", + ) } return token, accountID, nil diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go index 3267f2d16..43b21700a 100644 --- a/pkg/providers/codex_cli_credentials_test.go +++ b/pkg/providers/codex_cli_credentials_test.go @@ -18,7 +18,7 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) { "account_id": "org-test123" } }` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } @@ -58,7 +58,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) { authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } @@ -74,7 +74,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) { tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "auth.json") - if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil { + if err := os.WriteFile(authPath, []byte("not json"), 0o600); err != nil { t.Fatal(err) } @@ -91,7 +91,7 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) { authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } @@ -112,12 +112,12 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) { func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) { tmpDir := t.TempDir() customDir := filepath.Join(tmpDir, "custom-codex") - if err := os.MkdirAll(customDir, 0755); err != nil { + if err := os.MkdirAll(customDir, 0o755); err != nil { t.Fatal(err) } authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}` - if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil { + if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0o600); err != nil { t.Fatal(err) } @@ -137,7 +137,7 @@ func TestCreateCodexCliTokenSource_Valid(t *testing.T) { authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } @@ -161,7 +161,7 @@ func TestCreateCodexCliTokenSource_Expired(t *testing.T) { authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 8886406b4..4c783ece5 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -25,7 +25,9 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider { } // Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode. -func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *CodexCliProvider) Chat( + ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, +) (*LLMResponse, error) { if p.command == "" { return nil, fmt.Errorf("codex command not configured") } @@ -133,7 +135,9 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { sb.WriteString("## Available Tools\n\n") sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") sb.WriteString("```json\n") - sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) + sb.WriteString( + `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, + ) sb.WriteString("\n```\n\n") sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") sb.WriteString("### Tool Definitions:\n\n") diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/codex_cli_provider_integration_test.go index 0267c730f..17a8305ad 100644 --- a/pkg/providers/codex_cli_provider_integration_test.go +++ b/pkg/providers/codex_cli_provider_integration_test.go @@ -27,7 +27,6 @@ func TestIntegration_RealCodexCLI(t *testing.T) { resp, err := p.Chat(ctx, []Message{ {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, }, nil, "", nil) - if err != nil { t.Fatalf("Chat() with real CLI error = %v", err) } @@ -64,7 +63,6 @@ func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) { {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, {Role: "user", Content: "What is 2+2?"}, }, nil, "", nil) - if err != nil { t.Fatalf("Chat() error = %v", err) } diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go index 7e4e1bc15..414e0844d 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/codex_cli_provider_test.go @@ -292,10 +292,10 @@ func TestBuildPrompt_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get current weather", - Parameters: map[string]interface{}{ + Parameters: map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, }, }, }, @@ -409,7 +409,7 @@ func createMockCodexCLI(t *testing.T, events []string) string { sb.WriteString(fmt.Sprintf("echo '%s'\n", event)) } - if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil { + if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil { t.Fatal(err) } return scriptPath @@ -480,7 +480,7 @@ echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' echo '{"type":"turn.completed"}'` - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { t.Fatal(err) } @@ -522,7 +522,7 @@ func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { scriptPath := filepath.Join(tmpDir, "codex") script := "#!/bin/bash\nsleep 60" - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { t.Fatal(err) } diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index e3526cfb5..ecc983642 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -10,12 +10,15 @@ import ( "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" ) -const codexDefaultModel = "gpt-5.2" -const codexDefaultInstructions = "You are Codex, a coding assistant." +const ( + codexDefaultModel = "gpt-5.2" + codexDefaultInstructions = "You are Codex, a coding assistant." +) type CodexProvider struct { client *openai.Client @@ -44,22 +47,30 @@ func NewCodexProvider(token, accountID string) *CodexProvider { } } -func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider { +func NewCodexProviderWithTokenSource( + token, accountID string, tokenSource func() (string, string, error), +) *CodexProvider { p := NewCodexProvider(token, accountID) p.tokenSource = tokenSource return p } -func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *CodexProvider) Chat( + ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, +) (*LLMResponse, error) { var opts []option.RequestOption accountID := p.accountID resolvedModel, fallbackReason := resolveCodexModel(model) if fallbackReason != "" { - logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{ - "requested_model": model, - "resolved_model": resolvedModel, - "reason": fallbackReason, - }) + logger.WarnCF( + "provider.codex", + "Requested model is not compatible with Codex backend, using fallback", + map[string]any{ + "requested_model": model, + "resolved_model": resolvedModel, + "reason": fallbackReason, + }, + ) } if p.tokenSource != nil { tok, accID, err := p.tokenSource() @@ -74,10 +85,14 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To if accountID != "" { opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) } else { - logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{ - "requested_model": model, - "resolved_model": resolvedModel, - }) + logger.WarnCF( + "provider.codex", + "No account id found for Codex request; backend may reject with 400", + map[string]any{ + "requested_model": model, + "resolved_model": resolvedModel, + }, + ) } params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch) @@ -98,7 +113,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To } err := stream.Err() if err != nil { - fields := map[string]interface{}{ + fields := map[string]any{ "requested_model": model, "resolved_model": resolvedModel, "messages_count": len(messages), @@ -124,7 +139,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To return nil, fmt.Errorf("codex API call: %w", err) } if resp == nil { - fields := map[string]interface{}{ + fields := map[string]any{ "requested_model": model, "resolved_model": resolvedModel, "messages_count": len(messages), @@ -184,7 +199,9 @@ func resolveCodexModel(model string) (string, string) { return codexDefaultModel, "unsupported model family" } -func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams { +func buildCodexParams( + messages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool, +) responses.ResponseNewParams { var inputItems responses.ResponseInputParam var instructions string @@ -197,7 +214,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, + Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ + OfString: openai.Opt(msg.Content), + }, }, }) } else { @@ -221,7 +240,7 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, for _, tc := range msg.ToolCalls { name, args, ok := resolveCodexToolCall(tc) if !ok { - logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{ + logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]any{ "call_id": tc.ID, }) continue @@ -246,7 +265,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, + Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ + OfString: openai.Opt(msg.Content), + }, }, }) } @@ -341,9 +362,9 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse { } } case "function_call": - var args map[string]interface{} + var args map[string]any if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil { - args = map[string]interface{}{"raw": item.Arguments} + args = map[string]any{"raw": item.Arguments} } toolCalls = append(toolCalls, ToolCall{ ID: item.CallID, diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 92e276165..4157e53e9 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -16,7 +16,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { messages := []Message{ {Role: "user", Content: "Hello"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{ + params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{ "max_tokens": 2048, "temperature": 0.7, }, true) @@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, true) if !params.Instructions.Valid() { t.Fatal("Instructions should be set") } @@ -54,12 +54,12 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { { Role: "assistant", ToolCalls: []ToolCall{ - {ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}}, + {ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "SF"}}, }, }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -87,7 +87,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { {Role: "tool", Content: "ok", ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -114,16 +114,16 @@ func TestBuildCodexParams_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather", - Parameters: map[string]interface{}{ + Parameters: map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, }, }, }, }, } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, false) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } @@ -136,14 +136,14 @@ func TestBuildCodexParams_WithTools(t *testing.T) { } func TestBuildCodexParams_StoreIsFalse(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, false) if !params.Store.Valid() || params.Store.Or(true) != false { t.Error("Store should be explicitly set to false") } } func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, true) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } @@ -151,7 +151,11 @@ func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) { t.Fatal("Tool should include built-in web_search") } if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch { - t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch) + t.Errorf( + "Web search tool type = %q, want %q", + params.Tools[0].OfWebSearch.Type, + responses.WebSearchToolTypeWebSearch, + ) } } @@ -162,7 +166,7 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { Function: ToolFunctionDefinition{ Name: "web_search", Description: "local web search", - Parameters: map[string]interface{}{ + Parameters: map[string]any{ "type": "object", }, }, @@ -172,14 +176,14 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { Function: ToolFunctionDefinition{ Name: "read_file", Description: "read file", - Parameters: map[string]interface{}{ + Parameters: map[string]any{ "type": "object", }, }, }, } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, true) if len(params.Tools) != 2 { t.Fatalf("len(Tools) = %d, want 2", len(params.Tools)) } @@ -296,7 +300,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return @@ -309,38 +313,38 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) return } - toolsAny, ok := reqBody["tools"].([]interface{}) + toolsAny, ok := reqBody["tools"].([]any) if !ok || len(toolsAny) != 1 { http.Error(w, "missing default web search tool", http.StatusBadRequest) return } - toolObj, ok := toolsAny[0].(map[string]interface{}) + toolObj, ok := toolsAny[0].(map[string]any) if !ok || toolObj["type"] != "web_search" { http.Error(w, "expected web_search tool", http.StatusBadRequest) return } - resp := map[string]interface{}{ + resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", - "output": []map[string]interface{}{ + "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 12, "output_tokens": 6, "total_tokens": 18, - "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, - "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) @@ -351,7 +355,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"max_tokens": 1024}) + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"max_tokens": 1024}) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -373,7 +377,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return @@ -383,27 +387,27 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { return } - resp := map[string]interface{}{ + resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", - "output": []map[string]interface{}{ + "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 4, "output_tokens": 3, "total_tokens": 7, - "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, - "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) @@ -415,7 +419,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{}) + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{}) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -439,7 +443,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) return } - var reqBody map[string]interface{} + var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return @@ -465,27 +469,27 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) return } - resp := map[string]interface{}{ + resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", - "output": []map[string]interface{}{ + "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 8, "output_tokens": 4, "total_tokens": 12, - "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, - "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) @@ -499,7 +503,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) } messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7}) + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"temperature": 0.7}) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -515,7 +519,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) return } - var reqBody map[string]interface{} + var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return @@ -533,27 +537,27 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) return } - resp := map[string]interface{}{ + resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", - "output": []map[string]interface{}{ + "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 8, "output_tokens": 4, "total_tokens": 12, - "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, - "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) @@ -588,7 +592,12 @@ func TestResolveCodexModel(t *testing.T) { wantFallback bool }{ {name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true}, - {name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true}, + { + name: "unsupported namespace", + input: "anthropic/claude-3.5", + wantModel: codexDefaultModel, + wantFallback: true, + }, {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, {name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false}, {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, @@ -622,8 +631,8 @@ func createOpenAITestClient(baseURL, token, accountID string) *openai.Client { return &c } -func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) { - event := map[string]interface{}{ +func writeCompletedSSE(w http.ResponseWriter, response map[string]any) { + event := map[string]any{ "type": "response.completed", "sequence_number": 1, "response": response, diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 9b07f9153..ecd451ec9 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -110,7 +110,11 @@ func (fc *FallbackChain) Execute( Model: candidate.Model, Skipped: true, Reason: FailoverRateLimit, - Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)), + Error: fmt.Errorf( + "provider %s in cooldown (%s remaining)", + candidate.Provider, + remaining.Round(time.Second), + ), }) continue } diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index ea81e0d48..e872c672e 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -462,7 +462,13 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) { func TestFallbackExhaustedError_Message(t *testing.T) { e := &FallbackExhaustedError{ Attempts: []FallbackAttempt{ - {Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond}, + { + Provider: "openai", + Model: "gpt-4", + Error: errors.New("rate limited"), + Reason: FailoverRateLimit, + Duration: 500 * time.Millisecond, + }, {Provider: "anthropic", Model: "claude", Skipped: true}, }, } diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 5058819f5..6124881f7 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -2,10 +2,9 @@ package providers import ( "context" + "encoding/json" "fmt" - json "encoding/json" - copilot "github.com/github/copilot-sdk/go" ) @@ -17,7 +16,6 @@ type GitHubCopilotProvider struct { } func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { - var session *copilot.Session if connectMode == "" { connectMode = "grpc" @@ -25,13 +23,15 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi switch connectMode { case "stdio": - //todo + // todo case "grpc": client := copilot.NewClient(&copilot.ClientOptions{ CLIUrl: uri, }) if err := client.Start(context.Background()); err != nil { - return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details") + return nil, fmt.Errorf( + "Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details", + ) } defer client.Stop() session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{ @@ -49,7 +49,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi } // Chat sends a chat request to GitHub Copilot -func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *GitHubCopilotProvider) Chat( + ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, +) (*LLMResponse, error) { type tempMessage struct { Role string `json:"role"` Content string `json:"content"` @@ -73,10 +75,8 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to FinishReason: "stop", Content: content, }, nil - } func (p *GitHubCopilotProvider) GetDefaultModel() string { - return "gpt-4.1" } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 967d089d5..05c6eed6c 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -22,7 +22,9 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } } -func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *HTTPProvider) Chat( + ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, +) (*LLMResponse, error) { return p.delegate.Chat(ctx, messages, tools, model, options) } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 9b404dd77..3a7fe4f39 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -15,13 +15,15 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -type ToolCall = protocoltypes.ToolCall -type FunctionCall = protocoltypes.FunctionCall -type LLMResponse = protocoltypes.LLMResponse -type UsageInfo = protocoltypes.UsageInfo -type Message = protocoltypes.Message -type ToolDefinition = protocoltypes.ToolDefinition -type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) type Provider struct { apiKey string @@ -52,14 +54,20 @@ func NewProvider(apiKey, apiBase, proxy string) *Provider { } } -func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { if p.apiBase == "" { return nil, fmt.Errorf("API base not configured") } model = normalizeModel(model, p.apiBase) - requestBody := map[string]interface{}{ + requestBody := map[string]any{ "model": model, "messages": messages, } @@ -154,7 +162,7 @@ func parseResponse(body []byte) (*LLMResponse, error) { choice := apiResponse.Choices[0] toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) for _, tc := range choice.Message.ToolCalls { - arguments := make(map[string]interface{}) + arguments := make(map[string]any) name := "" if tc.Function != nil { @@ -201,7 +209,7 @@ func normalizeModel(model, apiBase string) string { } } -func asInt(v interface{}) (int, bool) { +func asInt(v any) (int, bool) { switch val := v.(type) { case int: return val, true @@ -216,7 +224,7 @@ func asInt(v interface{}) (int, bool) { } } -func asFloat(v interface{}) (float64, bool) { +func asFloat(v any) (float64, bool) { switch val := v.(type) { case float64: return val, true diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 94779b39c..42f9d42ab 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -9,7 +9,7 @@ import ( ) func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { - var requestBody map[string]interface{} + var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { @@ -20,10 +20,10 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { http.Error(w, err.Error(), http.StatusBadRequest) return } - resp := map[string]interface{}{ - "choices": []map[string]interface{}{ + resp := map[string]any{ + "choices": []map[string]any{ { - "message": map[string]interface{}{"content": "ok"}, + "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, @@ -34,7 +34,13 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { defer server.Close() p := NewProvider("key", server.URL, "") - _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234}) + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "glm-4.7", + map[string]any{"max_tokens": 1234}, + ) if err != nil { t.Fatalf("Chat() error = %v", err) } @@ -49,16 +55,16 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { func TestProviderChat_ParsesToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := map[string]interface{}{ - "choices": []map[string]interface{}{ + resp := map[string]any{ + "choices": []map[string]any{ { - "message": map[string]interface{}{ + "message": map[string]any{ "content": "", - "tool_calls": []map[string]interface{}{ + "tool_calls": []map[string]any{ { "id": "call_1", "type": "function", - "function": map[string]interface{}{ + "function": map[string]any{ "name": "get_weather", "arguments": "{\"city\":\"SF\"}", }, @@ -68,7 +74,7 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) { "finish_reason": "tool_calls", }, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, @@ -109,17 +115,17 @@ func TestProviderChat_HTTPError(t *testing.T) { } func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { - var requestBody map[string]interface{} + var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - resp := map[string]interface{}{ - "choices": []map[string]interface{}{ + resp := map[string]any{ + "choices": []map[string]any{ { - "message": map[string]interface{}{"content": "ok"}, + "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, @@ -135,7 +141,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin []Message{{Role: "user", Content: "hi"}}, nil, "moonshot/kimi-k2.5", - map[string]interface{}{"temperature": 0.3}, + map[string]any{"temperature": 0.3}, ) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -174,17 +180,17 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var requestBody map[string]interface{} + var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - resp := map[string]interface{}{ - "choices": []map[string]interface{}{ + resp := map[string]any{ + "choices": []map[string]any{ { - "message": map[string]interface{}{"content": "ok"}, + "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, @@ -227,17 +233,17 @@ func TestProvider_ProxyConfigured(t *testing.T) { } func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) { - var requestBody map[string]interface{} + var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - resp := map[string]interface{}{ - "choices": []map[string]interface{}{ + resp := map[string]any{ + "choices": []map[string]any{ { - "message": map[string]interface{}{"content": "ok"}, + "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, @@ -253,7 +259,7 @@ func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) { []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", - map[string]interface{}{"max_tokens": float64(512), "temperature": 1}, + map[string]any{"max_tokens": float64(512), "temperature": 1}, ) if err != nil { t.Fatalf("Chat() error = %v", err) diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 6b33ae734..b5b4a2d39 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -1,11 +1,11 @@ package protocoltypes type ToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` } type FunctionCall struct { @@ -39,7 +39,7 @@ type ToolDefinition struct { } type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` } diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go index 97a219283..7ddea0e99 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/tool_call_extract.go @@ -38,7 +38,7 @@ func extractToolCallsFromText(text string) []ToolCall { var result []ToolCall for _, tc := range wrapper.ToolCalls { - var args map[string]interface{} + var args map[string]any json.Unmarshal([]byte(tc.Function.Arguments), &args) result = append(result, ToolCall{ diff --git a/pkg/providers/types.go b/pkg/providers/types.go index c4a9de58a..2fbddd686 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -7,16 +7,24 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -type ToolCall = protocoltypes.ToolCall -type FunctionCall = protocoltypes.FunctionCall -type LLMResponse = protocoltypes.LLMResponse -type UsageInfo = protocoltypes.UsageInfo -type Message = protocoltypes.Message -type ToolDefinition = protocoltypes.ToolDefinition -type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) type LLMProvider interface { - Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) GetDefaultModel() string } diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 12bf33df0..08f0b0ad2 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -32,7 +32,7 @@ func NewSessionManager(storage string) *SessionManager { } if storage != "" { - os.MkdirAll(storage, 0755) + os.MkdirAll(storage, 0o755) sm.loadSessions() } @@ -214,7 +214,7 @@ func (sm *SessionManager) Save(key string) error { _ = tmpFile.Close() return err } - if err := tmpFile.Chmod(0644); err != nil { + if err := tmpFile.Chmod(0o644); err != nil { _ = tmpFile.Close() return err } diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index a3263c525..5742a8f03 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -66,12 +66,12 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er return fmt.Errorf("failed to read response: %w", err) } - if err := os.MkdirAll(skillDir, 0755); err != nil { + if err := os.MkdirAll(skillDir, 0o755); err != nil { return fmt.Errorf("failed to create skill directory: %w", err) } skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, body, 0644); err != nil { + if err := os.WriteFile(skillPath, body, 0o644); err != nil { return fmt.Errorf("failed to write skill file: %w", err) } diff --git a/pkg/state/state.go b/pkg/state/state.go index 0bb9cd497..1a92f82ed 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -38,7 +38,7 @@ func NewManager(workspace string) *Manager { oldStateFile := filepath.Join(workspace, "state.json") // Create state directory if it doesn't exist - os.MkdirAll(stateDir, 0755) + os.MkdirAll(stateDir, 0o755) sm := &Manager{ workspace: workspace, @@ -139,7 +139,7 @@ func (sm *Manager) saveAtomic() error { } // Write to temp file - if err := os.WriteFile(tempFile, data, 0644); err != nil { + if err := os.WriteFile(tempFile, data, 0o644); err != nil { return fmt.Errorf("failed to write temp file: %w", err) } diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index ce3dd7215..f717a5bb4 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -98,7 +98,7 @@ func TestAtomicity_NoCorruptionOnInterrupt(t *testing.T) { // Simulate a crash scenario by manually creating a corrupted temp file tempFile := filepath.Join(tmpDir, "state", "state.json.tmp") - err = os.WriteFile(tempFile, []byte("corrupted data"), 0644) + err = os.WriteFile(tempFile, []byte("corrupted data"), 0o644) if err != nil { t.Fatalf("Failed to create temp file: %v", err) } diff --git a/pkg/tools/base.go b/pkg/tools/base.go index b13174633..770d8cb04 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -6,8 +6,8 @@ import "context" type Tool interface { Name() string Description() string - Parameters() map[string]interface{} - Execute(ctx context.Context, args map[string]interface{}) *ToolResult + Parameters() map[string]any + Execute(ctx context.Context, args map[string]any) *ToolResult } // ContextualTool is an optional interface that tools can implement @@ -69,10 +69,10 @@ type AsyncTool interface { SetCallback(cb AsyncCallback) } -func ToolToSchema(tool Tool) map[string]interface{} { - return map[string]interface{}{ +func ToolToSchema(tool Tool) map[string]any { + return map[string]any{ "type": "function", - "function": map[string]interface{}{ + "function": map[string]any{ "name": tool.Name(), "description": tool.Description(), "parameters": tool.Parameters(), diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index e2764d8ac..562fffc84 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -30,7 +30,10 @@ type CronTool struct { // NewCronTool creates a new 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, config *config.Config) *CronTool { +func NewCronTool( + cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, + execTimeout time.Duration, config *config.Config, +) *CronTool { execTool := NewExecToolWithConfig(workspace, restrict, config) execTool.SetTimeout(execTimeout) return &CronTool{ @@ -52,40 +55,40 @@ func (t *CronTool) Description() string { } // Parameters returns the tool parameters schema -func (t *CronTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *CronTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ + "properties": map[string]any{ + "action": map[string]any{ "type": "string", "enum": []string{"add", "list", "remove", "enable", "disable"}, "description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.", }, - "message": map[string]interface{}{ + "message": map[string]any{ "type": "string", "description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.", }, - "command": map[string]interface{}{ + "command": map[string]any{ "type": "string", "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.", }, - "at_seconds": map[string]interface{}{ + "at_seconds": map[string]any{ "type": "integer", "description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.", }, - "every_seconds": map[string]interface{}{ + "every_seconds": map[string]any{ "type": "integer", "description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.", }, - "cron_expr": map[string]interface{}{ + "cron_expr": map[string]any{ "type": "string", "description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.", }, - "job_id": map[string]interface{}{ + "job_id": map[string]any{ "type": "string", "description": "Job ID (for remove/enable/disable)", }, - "deliver": map[string]interface{}{ + "deliver": map[string]any{ "type": "boolean", "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true", }, @@ -103,7 +106,7 @@ func (t *CronTool) SetContext(channel, chatID string) { } // Execute runs the tool with the given arguments -func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult { action, ok := args["action"].(string) if !ok { return ErrorResult("action is required") @@ -125,7 +128,7 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *To } } -func (t *CronTool) addJob(args map[string]interface{}) *ToolResult { +func (t *CronTool) addJob(args map[string]any) *ToolResult { t.mu.RLock() channel := t.channel chatID := t.chatID @@ -233,7 +236,7 @@ func (t *CronTool) listJobs() *ToolResult { return SilentResult(result) } -func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult { +func (t *CronTool) removeJob(args map[string]any) *ToolResult { jobID, ok := args["job_id"].(string) if !ok || jobID == "" { return ErrorResult("job_id is required for remove") @@ -245,7 +248,7 @@ func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult { return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } -func (t *CronTool) enableJob(args map[string]interface{}, enable bool) *ToolResult { +func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult { jobID, ok := args["job_id"].(string) if !ok || jobID == "" { return ErrorResult("job_id is required for enable/disable") @@ -279,7 +282,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // Execute command if present if job.Payload.Command != "" { - args := map[string]interface{}{ + args := map[string]any{ "command": job.Payload.Command, } @@ -320,7 +323,6 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { channel, chatID, ) - if err != nil { return fmt.Sprintf("Error: %v", err) } diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 1e7c33b45..39d2642d4 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -30,19 +30,19 @@ func (t *EditFileTool) Description() string { return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." } -func (t *EditFileTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *EditFileTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ + "properties": map[string]any{ + "path": map[string]any{ "type": "string", "description": "The file path to edit", }, - "old_text": map[string]interface{}{ + "old_text": map[string]any{ "type": "string", "description": "The exact text to find and replace", }, - "new_text": map[string]interface{}{ + "new_text": map[string]any{ "type": "string", "description": "The text to replace with", }, @@ -51,7 +51,7 @@ func (t *EditFileTool) Parameters() map[string]interface{} { } } -func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") @@ -89,12 +89,14 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) count := strings.Count(contentStr, oldText) if count > 1 { - return ErrorResult(fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count)) + return ErrorResult( + fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count), + ) } newContent := strings.Replace(contentStr, oldText, newText, 1) - if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil { + if err := os.WriteFile(resolvedPath, []byte(newContent), 0o644); err != nil { return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) } @@ -118,15 +120,15 @@ func (t *AppendFileTool) Description() string { return "Append content to the end of a file" } -func (t *AppendFileTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *AppendFileTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ + "properties": map[string]any{ + "path": map[string]any{ "type": "string", "description": "The file path to append to", }, - "content": map[string]interface{}{ + "content": map[string]any{ "type": "string", "description": "The content to append", }, @@ -135,7 +137,7 @@ func (t *AppendFileTool) Parameters() map[string]interface{} { } } -func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") @@ -151,7 +153,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{ return ErrorResult(err.Error()) } - f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return ErrorResult(fmt.Sprintf("failed to open file: %v", err)) } diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go index c4c02772d..6780dd9f6 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/edit_test.go @@ -12,11 +12,11 @@ import ( func TestEditTool_EditFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0644) + os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644) tool := NewEditFileTool(tmpDir, true) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "old_text": "World", "new_text": "Universe", @@ -60,7 +60,7 @@ func TestEditTool_EditFile_NotFound(t *testing.T) { tool := NewEditFileTool(tmpDir, true) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "old_text": "old", "new_text": "new", @@ -83,11 +83,11 @@ func TestEditTool_EditFile_NotFound(t *testing.T) { func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("Hello World"), 0644) + os.WriteFile(testFile, []byte("Hello World"), 0o644) tool := NewEditFileTool(tmpDir, true) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "old_text": "Goodbye", "new_text": "Hello", @@ -110,11 +110,11 @@ func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { func TestEditTool_EditFile_MultipleMatches(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test test test"), 0644) + os.WriteFile(testFile, []byte("test test test"), 0o644) tool := NewEditFileTool(tmpDir, true) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "old_text": "test", "new_text": "done", @@ -138,11 +138,11 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { tmpDir := t.TempDir() otherDir := t.TempDir() testFile := filepath.Join(otherDir, "test.txt") - os.WriteFile(testFile, []byte("content"), 0644) + os.WriteFile(testFile, []byte("content"), 0o644) tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "old_text": "content", "new_text": "new", @@ -165,7 +165,7 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { func TestEditTool_EditFile_MissingPath(t *testing.T) { tool := NewEditFileTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "old_text": "old", "new_text": "new", } @@ -182,7 +182,7 @@ func TestEditTool_EditFile_MissingPath(t *testing.T) { func TestEditTool_EditFile_MissingOldText(t *testing.T) { tool := NewEditFileTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": "/tmp/test.txt", "new_text": "new", } @@ -199,7 +199,7 @@ func TestEditTool_EditFile_MissingOldText(t *testing.T) { func TestEditTool_EditFile_MissingNewText(t *testing.T) { tool := NewEditFileTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": "/tmp/test.txt", "old_text": "old", } @@ -216,11 +216,11 @@ func TestEditTool_EditFile_MissingNewText(t *testing.T) { func TestEditTool_AppendFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("Initial content"), 0644) + os.WriteFile(testFile, []byte("Initial content"), 0o644) tool := NewAppendFileTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "content": "\nAppended content", } @@ -260,7 +260,7 @@ func TestEditTool_AppendFile_Success(t *testing.T) { func TestEditTool_AppendFile_MissingPath(t *testing.T) { tool := NewAppendFileTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "content": "test", } @@ -276,7 +276,7 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) { func TestEditTool_AppendFile_MissingContent(t *testing.T) { tool := NewAppendFileTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": "/tmp/test.txt", } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 09063ea0a..dd996bc0d 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -94,11 +94,11 @@ func (t *ReadFileTool) Description() string { return "Read the contents of a file" } -func (t *ReadFileTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *ReadFileTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ + "properties": map[string]any{ + "path": map[string]any{ "type": "string", "description": "Path to the file to read", }, @@ -107,7 +107,7 @@ func (t *ReadFileTool) Parameters() map[string]interface{} { } } -func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") @@ -143,15 +143,15 @@ func (t *WriteFileTool) Description() string { return "Write content to a file" } -func (t *WriteFileTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *WriteFileTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ + "properties": map[string]any{ + "path": map[string]any{ "type": "string", "description": "Path to the file to write", }, - "content": map[string]interface{}{ + "content": map[string]any{ "type": "string", "description": "Content to write to the file", }, @@ -160,7 +160,7 @@ func (t *WriteFileTool) Parameters() map[string]interface{} { } } -func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") @@ -177,11 +177,11 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{} } dir := filepath.Dir(resolvedPath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return ErrorResult(fmt.Sprintf("failed to create directory: %v", err)) } - if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil { + if err := os.WriteFile(resolvedPath, []byte(content), 0o644); err != nil { return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) } @@ -205,11 +205,11 @@ func (t *ListDirTool) Description() string { return "List files and directories in a path" } -func (t *ListDirTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *ListDirTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ + "properties": map[string]any{ + "path": map[string]any{ "type": "string", "description": "Path to list", }, @@ -218,7 +218,7 @@ func (t *ListDirTool) Parameters() map[string]interface{} { } } -func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { path = "." diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 958036419..5daa3dcea 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -12,11 +12,11 @@ import ( func TestFilesystemTool_ReadFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test content"), 0644) + os.WriteFile(testFile, []byte("test content"), 0o644) tool := &ReadFileTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, } @@ -43,7 +43,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { tool := &ReadFileTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": "/nonexistent_file_12345.txt", } @@ -64,7 +64,7 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { tool := &ReadFileTool{} ctx := context.Background() - args := map[string]interface{}{} + args := map[string]any{} result := tool.Execute(ctx, args) @@ -86,7 +86,7 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) { tool := &WriteFileTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "content": "hello world", } @@ -125,7 +125,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { tool := &WriteFileTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": testFile, "content": "test", } @@ -151,7 +151,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { tool := &WriteFileTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "content": "test", } @@ -167,7 +167,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { tool := &WriteFileTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": "/tmp/test.txt", } @@ -179,7 +179,8 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { } // Should mention required parameter - if !strings.Contains(result.ForLLM, "content is required") && !strings.Contains(result.ForUser, "content is required") { + if !strings.Contains(result.ForLLM, "content is required") && + !strings.Contains(result.ForUser, "content is required") { t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM) } } @@ -187,13 +188,13 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { // TestFilesystemTool_ListDir_Success verifies successful directory listing func TestFilesystemTool_ListDir_Success(t *testing.T) { tmpDir := t.TempDir() - os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0644) - os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0644) - os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755) + os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644) + os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) + os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) tool := &ListDirTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": tmpDir, } @@ -217,7 +218,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { func TestFilesystemTool_ListDir_NotFound(t *testing.T) { tool := &ListDirTool{} ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "path": "/nonexistent_directory_12345", } @@ -238,7 +239,7 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { tool := &ListDirTool{} ctx := context.Background() - args := map[string]interface{}{} + args := map[string]any{} result := tool.Execute(ctx, args) @@ -250,15 +251,14 @@ func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { // Block paths that look inside workspace but point outside via symlink. func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { - root := t.TempDir() workspace := filepath.Join(root, "workspace") - if err := os.MkdirAll(workspace, 0755); err != nil { + if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } secret := filepath.Join(root, "secret.txt") - if err := os.WriteFile(secret, []byte("top secret"), 0644); err != nil { + if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil { t.Fatalf("failed to write secret file: %v", err) } @@ -268,7 +268,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { } tool := NewReadFileTool(workspace, true) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "path": link, }) diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go index abca5ec1e..0387a26d3 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/i2c.go @@ -24,37 +24,37 @@ func (t *I2CTool) Description() string { return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only." } -func (t *I2CTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *I2CTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ + "properties": map[string]any{ + "action": map[string]any{ "type": "string", "enum": []string{"detect", "scan", "read", "write"}, "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", }, - "bus": map[string]interface{}{ + "bus": map[string]any{ "type": "string", "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", }, - "address": map[string]interface{}{ + "address": map[string]any{ "type": "integer", "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", }, - "register": map[string]interface{}{ + "register": map[string]any{ "type": "integer", "description": "Register address to read from or write to. If set, sends register byte before read/write.", }, - "data": map[string]interface{}{ + "data": map[string]any{ "type": "array", - "items": map[string]interface{}{"type": "integer"}, + "items": map[string]any{"type": "integer"}, "description": "Bytes to write (0-255 each). Required for write action.", }, - "length": map[string]interface{}{ + "length": map[string]any{ "type": "integer", "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", }, - "confirm": map[string]interface{}{ + "confirm": map[string]any{ "type": "boolean", "description": "Must be true for write operations. Safety guard to prevent accidental writes.", }, @@ -63,7 +63,7 @@ func (t *I2CTool) Parameters() map[string]interface{} { } } -func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.") } @@ -95,7 +95,9 @@ func (t *I2CTool) detect() *ToolResult { } if len(matches) == 0 { - return SilentResult("No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)") + return SilentResult( + "No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)", + ) } type busInfo struct { @@ -122,7 +124,7 @@ func isValidBusID(id string) bool { } // parseI2CAddress extracts and validates an I2C address from args -func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) { +func parseI2CAddress(args map[string]any) (int, *ToolResult) { addrFloat, ok := args["address"].(float64) if !ok { return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") @@ -135,7 +137,7 @@ func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) { } // parseI2CBus extracts and validates an I2C bus from args -func parseI2CBus(args map[string]interface{}) (string, *ToolResult) { +func parseI2CBus(args map[string]any) (string, *ToolResult) { bus, ok := args["bus"].(string) if !ok || bus == "" { return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index 294f7ecbc..2a0626340 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -74,7 +74,7 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool { // scan probes valid 7-bit addresses on a bus for connected devices. // Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO: // SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges. -func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { +func (t *I2CTool) scan(args map[string]any) *ToolResult { bus, errResult := parseI2CBus(args) if errResult != nil { return errResult @@ -99,7 +99,9 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { hasReadByte := funcs&i2cFuncSmbusReadByte != 0 if !hasQuick && !hasReadByte { - return ErrorResult(fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath)) + return ErrorResult( + fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath), + ) } type deviceEntry struct { @@ -133,7 +135,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := json.MarshalIndent(map[string]any{ "bus": devPath, "devices": found, "count": len(found), @@ -142,7 +144,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { } // readDevice reads bytes from an I2C device, optionally at a specific register -func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { +func (t *I2CTool) readDevice(args map[string]any) *ToolResult { bus, errResult := parseI2CBus(args) if errResult != nil { return errResult @@ -201,7 +203,7 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { intBytes[i] = int(buf[i]) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := json.MarshalIndent(map[string]any{ "bus": devPath, "address": fmt.Sprintf("0x%02x", addr), "bytes": intBytes, @@ -212,10 +214,12 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { } // writeDevice writes bytes to an I2C device, optionally at a specific register -func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult { +func (t *I2CTool) writeDevice(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { - return ErrorResult("write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.") + return ErrorResult( + "write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.", + ) } bus, errResult := parseI2CBus(args) @@ -228,7 +232,7 @@ func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult { return errResult } - dataRaw, ok := args["data"].([]interface{}) + dataRaw, ok := args["data"].([]any) if !ok || len(dataRaw) == 0 { return ErrorResult("data is required for write (array of byte values 0-255)") } diff --git a/pkg/tools/i2c_other.go b/pkg/tools/i2c_other.go index d1d581348..7becf8339 100644 --- a/pkg/tools/i2c_other.go +++ b/pkg/tools/i2c_other.go @@ -3,16 +3,16 @@ package tools // scan is a stub for non-Linux platforms. -func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { +func (t *I2CTool) scan(args map[string]any) *ToolResult { return ErrorResult("I2C is only supported on Linux") } // readDevice is a stub for non-Linux platforms. -func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { +func (t *I2CTool) readDevice(args map[string]any) *ToolResult { return ErrorResult("I2C is only supported on Linux") } // writeDevice is a stub for non-Linux platforms. -func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult { +func (t *I2CTool) writeDevice(args map[string]any) *ToolResult { return ErrorResult("I2C is only supported on Linux") } diff --git a/pkg/tools/message.go b/pkg/tools/message.go index abedb1316..15ef4ff73 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -26,19 +26,19 @@ func (t *MessageTool) Description() string { return "Send a message to user on a chat channel. Use this when you want to communicate something." } -func (t *MessageTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *MessageTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "content": map[string]interface{}{ + "properties": map[string]any{ + "content": map[string]any{ "type": "string", "description": "The message content to send", }, - "channel": map[string]interface{}{ + "channel": map[string]any{ "type": "string", "description": "Optional: target channel (telegram, whatsapp, etc.)", }, - "chat_id": map[string]interface{}{ + "chat_id": map[string]any{ "type": "string", "description": "Optional: target chat/user ID", }, @@ -62,7 +62,7 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) { t.sendCallback = callback } -func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { content, ok := args["content"].(string) if !ok { return &ToolResult{ForLLM: "content is required", IsError: true} diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 4bedbe79b..717c1117b 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -19,7 +19,7 @@ func TestMessageTool_Execute_Success(t *testing.T) { }) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "content": "Hello, world!", } @@ -70,7 +70,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { }) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "content": "Test message", "channel": "custom-channel", "chat_id": "custom-chat-id", @@ -104,7 +104,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) { }) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "content": "Test message", } @@ -136,7 +136,7 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) { tool.SetContext("test-channel", "test-chat-id") ctx := context.Background() - args := map[string]interface{}{} // content missing + args := map[string]any{} // content missing result := tool.Execute(ctx, args) @@ -158,7 +158,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { }) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "content": "Test message", } @@ -179,7 +179,7 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) { // No SetSendCallback called ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "content": "Test message", } @@ -219,7 +219,7 @@ func TestMessageTool_Parameters(t *testing.T) { t.Error("Expected type 'object'") } - props, ok := params["properties"].(map[string]interface{}) + props, ok := params["properties"].(map[string]any) if !ok { t.Fatal("Expected properties to be a map") } @@ -231,7 +231,7 @@ func TestMessageTool_Parameters(t *testing.T) { } // Check content property - contentProp, ok := props["content"].(map[string]interface{}) + contentProp, ok := props["content"].(map[string]any) if !ok { t.Error("Expected 'content' property") } @@ -240,7 +240,7 @@ func TestMessageTool_Parameters(t *testing.T) { } // Check channel property (optional) - channelProp, ok := props["channel"].(map[string]interface{}) + channelProp, ok := props["channel"].(map[string]any) if !ok { t.Error("Expected 'channel' property") } @@ -249,7 +249,7 @@ func TestMessageTool_Parameters(t *testing.T) { } // Check chat_id property (optional) - chatIDProp, ok := props["chat_id"].(map[string]interface{}) + chatIDProp, ok := props["chat_id"].(map[string]any) if !ok { t.Error("Expected 'chat_id' property") } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index c8cf92863..6ecb8ae7c 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -34,16 +34,22 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) { return tool, ok } -func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) *ToolResult { +func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { return r.ExecuteWithContext(ctx, name, args, "", "", nil) } // ExecuteWithContext executes a tool with channel/chatID context and optional async callback. // If the tool implements AsyncTool and a non-nil callback is provided, // the callback will be set on the tool before execution. -func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}, channel, chatID string, asyncCallback AsyncCallback) *ToolResult { +func (r *ToolRegistry) ExecuteWithContext( + ctx context.Context, + name string, + args map[string]any, + channel, chatID string, + asyncCallback AsyncCallback, +) *ToolResult { logger.InfoCF("tool", "Tool execution started", - map[string]interface{}{ + map[string]any{ "tool": name, "args": args, }) @@ -51,7 +57,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args tool, ok := r.Get(name) if !ok { logger.ErrorCF("tool", "Tool not found", - map[string]interface{}{ + map[string]any{ "tool": name, }) return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) @@ -66,7 +72,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { asyncTool.SetCallback(asyncCallback) logger.DebugCF("tool", "Async callback injected", - map[string]interface{}{ + map[string]any{ "tool": name, }) } @@ -78,20 +84,20 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args // Log based on result type if result.IsError { logger.ErrorCF("tool", "Tool execution failed", - map[string]interface{}{ + map[string]any{ "tool": name, "duration": duration.Milliseconds(), "error": result.ForLLM, }) } else if result.Async { logger.InfoCF("tool", "Tool started (async)", - map[string]interface{}{ + map[string]any{ "tool": name, "duration": duration.Milliseconds(), }) } else { logger.InfoCF("tool", "Tool execution completed", - map[string]interface{}{ + map[string]any{ "tool": name, "duration_ms": duration.Milliseconds(), "result_length": len(result.ForLLM), @@ -101,11 +107,11 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args return result } -func (r *ToolRegistry) GetDefinitions() []map[string]interface{} { +func (r *ToolRegistry) GetDefinitions() []map[string]any { r.mu.RLock() defer r.mu.RUnlock() - definitions := make([]map[string]interface{}, 0, len(r.tools)) + definitions := make([]map[string]any, 0, len(r.tools)) for _, tool := range r.tools { definitions = append(definitions, ToolToSchema(tool)) } @@ -123,14 +129,14 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { schema := ToolToSchema(tool) // Safely extract nested values with type checks - fn, ok := schema["function"].(map[string]interface{}) + fn, ok := schema["function"].(map[string]any) if !ok { continue } name, _ := fn["name"].(string) desc, _ := fn["description"].(string) - params, _ := fn["parameters"].(map[string]interface{}) + params, _ := fn["parameters"].(map[string]any) definitions = append(definitions, providers.ToolDefinition{ Type: "function", diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go index bc798cd70..a234e33f3 100644 --- a/pkg/tools/result_test.go +++ b/pkg/tools/result_test.go @@ -192,7 +192,7 @@ func TestToolResultJSONStructure(t *testing.T) { } // Verify JSON structure - var parsed map[string]interface{} + var parsed map[string]any if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("Failed to parse JSON: %v", err) } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d9430672f..9c58df355 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -118,15 +118,15 @@ func (t *ExecTool) Description() string { return "Execute a shell command and return its output. Use with caution." } -func (t *ExecTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *ExecTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "command": map[string]interface{}{ + "properties": map[string]any{ + "command": map[string]any{ "type": "string", "description": "The shell command to execute", }, - "working_dir": map[string]interface{}{ + "working_dir": map[string]any{ "type": "string", "description": "Optional working directory for the command", }, @@ -135,7 +135,7 @@ func (t *ExecTool) Parameters() map[string]interface{} { } } -func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult { command, ok := args["command"].(string) if !ok { return ErrorResult("command is required") diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index c06468a39..f85b5a008 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -14,7 +14,7 @@ func TestShellTool_Success(t *testing.T) { tool := NewExecTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "command": "echo 'hello world'", } @@ -41,7 +41,7 @@ func TestShellTool_Failure(t *testing.T) { tool := NewExecTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "command": "ls /nonexistent_directory_12345", } @@ -69,7 +69,7 @@ func TestShellTool_Timeout(t *testing.T) { tool.SetTimeout(100 * time.Millisecond) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "command": "sleep 10", } @@ -91,12 +91,12 @@ func TestShellTool_WorkingDir(t *testing.T) { // Create temp directory tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test content"), 0644) + os.WriteFile(testFile, []byte("test content"), 0o644) tool := NewExecTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "command": "cat test.txt", "working_dir": tmpDir, } @@ -117,7 +117,7 @@ func TestShellTool_DangerousCommand(t *testing.T) { tool := NewExecTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "command": "rm -rf /", } @@ -138,7 +138,7 @@ func TestShellTool_MissingCommand(t *testing.T) { tool := NewExecTool("", false) ctx := context.Background() - args := map[string]interface{}{} + args := map[string]any{} result := tool.Execute(ctx, args) @@ -153,7 +153,7 @@ func TestShellTool_StderrCapture(t *testing.T) { tool := NewExecTool("", false) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "command": "sh -c 'echo stdout; echo stderr >&2'", } @@ -174,7 +174,7 @@ func TestShellTool_OutputTruncation(t *testing.T) { ctx := context.Background() // Generate long output (>10000 chars) - args := map[string]interface{}{ + args := map[string]any{ "command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000), } @@ -193,7 +193,7 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { tool.SetRestrictToWorkspace(true) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "command": "cat ../../etc/passwd", } @@ -205,6 +205,10 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { - t.Errorf("Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + t.Errorf( + "Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index f01372467..73d385cb0 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -34,19 +34,19 @@ func (t *SpawnTool) Description() string { return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done." } -func (t *SpawnTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *SpawnTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "task": map[string]interface{}{ + "properties": map[string]any{ + "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, - "label": map[string]interface{}{ + "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, - "agent_id": map[string]interface{}{ + "agent_id": map[string]any{ "type": "string", "description": "Optional target agent ID to delegate the task to", }, @@ -64,7 +64,7 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { t.allowlistCheck = check } -func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) if !ok { return ErrorResult("task is required") diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go index 4805d6a35..d6a88a5b0 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/spi.go @@ -24,41 +24,41 @@ func (t *SPITool) Description() string { return "Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only." } -func (t *SPITool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *SPITool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ + "properties": map[string]any{ + "action": map[string]any{ "type": "string", "enum": []string{"list", "transfer", "read"}, "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", }, - "device": map[string]interface{}{ + "device": map[string]any{ "type": "string", "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", }, - "speed": map[string]interface{}{ + "speed": map[string]any{ "type": "integer", "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", }, - "mode": map[string]interface{}{ + "mode": map[string]any{ "type": "integer", "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", }, - "bits": map[string]interface{}{ + "bits": map[string]any{ "type": "integer", "description": "Bits per word. Default: 8.", }, - "data": map[string]interface{}{ + "data": map[string]any{ "type": "array", - "items": map[string]interface{}{"type": "integer"}, + "items": map[string]any{"type": "integer"}, "description": "Bytes to send (0-255 each). Required for transfer action.", }, - "length": map[string]interface{}{ + "length": map[string]any{ "type": "integer", "description": "Number of bytes to read (1-4096). Required for read action.", }, - "confirm": map[string]interface{}{ + "confirm": map[string]any{ "type": "boolean", "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", }, @@ -67,7 +67,7 @@ func (t *SPITool) Parameters() map[string]interface{} { } } -func (t *SPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.") } @@ -97,7 +97,9 @@ func (t *SPITool) list() *ToolResult { } if len(matches) == 0 { - return SilentResult("No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded") + return SilentResult( + "No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded", + ) } type devInfo struct { @@ -118,7 +120,7 @@ func (t *SPITool) list() *ToolResult { } // parseSPIArgs extracts and validates common SPI parameters -func parseSPIArgs(args map[string]interface{}) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { +func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) if !ok || dev == "" { return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go index 12b696007..9def73662 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/spi_linux.go @@ -66,10 +66,12 @@ func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *T } // transfer performs a full-duplex SPI transfer -func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { +func (t *SPITool) transfer(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { - return ErrorResult("transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.") + return ErrorResult( + "transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.", + ) } dev, speed, mode, bits, errMsg := parseSPIArgs(args) @@ -77,7 +79,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { return ErrorResult(errMsg) } - dataRaw, ok := args["data"].([]interface{}) + dataRaw, ok := args["data"].([]any) if !ok || len(dataRaw) == 0 { return ErrorResult("data is required for transfer (array of byte values 0-255)") } @@ -130,7 +132,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { intBytes[i] = int(b) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := json.MarshalIndent(map[string]any{ "device": devPath, "sent": len(txBuf), "received": intBytes, @@ -140,7 +142,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { } // readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed) -func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { +func (t *SPITool) readDevice(args map[string]any) *ToolResult { dev, speed, mode, bits, errMsg := parseSPIArgs(args) if errMsg != "" { return ErrorResult(errMsg) @@ -186,7 +188,7 @@ func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { intBytes[i] = int(b) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := json.MarshalIndent(map[string]any{ "device": devPath, "bytes": intBytes, "hex": hexBytes, diff --git a/pkg/tools/spi_other.go b/pkg/tools/spi_other.go index 6dfc86fd1..5d078ac3f 100644 --- a/pkg/tools/spi_other.go +++ b/pkg/tools/spi_other.go @@ -3,11 +3,11 @@ package tools // transfer is a stub for non-Linux platforms. -func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { +func (t *SPITool) transfer(args map[string]any) *ToolResult { return ErrorResult("SPI is only supported on Linux") } // readDevice is a stub for non-Linux platforms. -func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { +func (t *SPITool) readDevice(args map[string]any) *ToolResult { return ErrorResult("SPI is only supported on Linux") } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 2fc7162d0..222137c89 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -34,7 +34,11 @@ type SubagentManager struct { nextID int } -func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager { +func NewSubagentManager( + provider providers.LLMProvider, + defaultModel, workspace string, + bus *bus.MessageBus, +) *SubagentManager { return &SubagentManager{ tasks: make(map[string]*SubagentTask), provider: provider, @@ -62,7 +66,11 @@ func (sm *SubagentManager) RegisterTool(tool Tool) { sm.tools.Register(tool) } -func (sm *SubagentManager) Spawn(ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback) (string, error) { +func (sm *SubagentManager) Spawn( + ctx context.Context, + task, label, agentID, originChannel, originChatID string, + callback AsyncCallback, +) (string, error) { sm.mu.Lock() defer sm.mu.Unlock() @@ -168,7 +176,12 @@ After completing the task, provide a clear summary of what was done.` task.Status = "completed" task.Result = loopResult.Content result = &ToolResult{ - ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content), + ForLLM: fmt.Sprintf( + "Subagent '%s' completed (iterations: %d): %s", + task.Label, + loopResult.Iterations, + loopResult.Content, + ), ForUser: loopResult.Content, Silent: false, IsError: false, @@ -232,15 +245,15 @@ func (t *SubagentTool) Description() string { return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM." } -func (t *SubagentTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *SubagentTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "task": map[string]interface{}{ + "properties": map[string]any{ + "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, - "label": map[string]interface{}{ + "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, @@ -254,7 +267,7 @@ func (t *SubagentTool) SetContext(channel, chatID string) { t.originChatID = chatID } -func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) if !ok { return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required")) @@ -295,7 +308,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{}) "temperature": 0.7, }, }, messages, t.originChannel, t.originChatID) - if err != nil { return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) } diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 8a7d22f24..8e4dc3953 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -12,7 +12,13 @@ import ( // MockLLMProvider is a test implementation of LLMProvider type MockLLMProvider struct{} -func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { +func (m *MockLLMProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { // Find the last user message to generate a response for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { @@ -79,13 +85,13 @@ func TestSubagentTool_Parameters(t *testing.T) { } // Check properties - props, ok := params["properties"].(map[string]interface{}) + props, ok := params["properties"].(map[string]any) if !ok { t.Fatal("Properties should be a map") } // Verify task parameter - task, ok := props["task"].(map[string]interface{}) + task, ok := props["task"].(map[string]any) if !ok { t.Fatal("Task parameter should exist") } @@ -94,7 +100,7 @@ func TestSubagentTool_Parameters(t *testing.T) { } // Verify label parameter - label, ok := props["label"].(map[string]interface{}) + label, ok := props["label"].(map[string]any) if !ok { t.Fatal("Label parameter should exist") } @@ -134,7 +140,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) { tool.SetContext("telegram", "chat-123") ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "Write a haiku about coding", "label": "haiku-task", } @@ -189,7 +195,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { tool := NewSubagentTool(manager) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "Test task without label", } @@ -212,7 +218,7 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) { tool := NewSubagentTool(manager) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "label": "test", } @@ -239,7 +245,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) { tool := NewSubagentTool(nil) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "test task", } @@ -268,7 +274,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) { tool.SetContext(channel, chatID) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "Test context passing", } @@ -295,7 +301,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { // Create a task that will generate long response longTask := strings.Repeat("This is a very long task description. ", 100) - args := map[string]interface{}{ + args := map[string]any{ "task": longTask, "label": "long-test", } diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 1302079b4..f0653e1f2 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -33,7 +33,12 @@ type ToolLoopResult struct { // RunToolLoop executes the LLM + tool call iteration loop. // This is the core agent logic that can be reused by both main agent and subagents. -func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []providers.Message, channel, chatID string) (*ToolLoopResult, error) { +func RunToolLoop( + ctx context.Context, + config ToolLoopConfig, + messages []providers.Message, + channel, chatID string, +) (*ToolLoopResult, error) { iteration := 0 var finalContent string diff --git a/pkg/tools/types.go b/pkg/tools/types.go index f8205b8bd..a6015cde3 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/types.go @@ -10,11 +10,11 @@ type Message struct { } type ToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` + ID string `json:"id"` + Type string `json:"type"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` } type FunctionCall struct { @@ -36,7 +36,13 @@ type UsageInfo struct { } type LLMProvider interface { - Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) GetDefaultModel() string } @@ -46,7 +52,7 @@ type ToolDefinition struct { } type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` } diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 6a6d40ecf..de8296816 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -183,11 +183,17 @@ type PerplexitySearchProvider struct { func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := "https://api.perplexity.ai/chat/completions" - payload := map[string]interface{}{ + payload := map[string]any{ "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)}, + { + "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, } @@ -295,15 +301,15 @@ func (t *WebSearchTool) Description() string { return "Search the web for current information. Returns titles, URLs, and snippets from search results." } -func (t *WebSearchTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *WebSearchTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ + "properties": map[string]any{ + "query": map[string]any{ "type": "string", "description": "Search query", }, - "count": map[string]interface{}{ + "count": map[string]any{ "type": "integer", "description": "Number of results (1-10)", "minimum": 1.0, @@ -314,7 +320,7 @@ func (t *WebSearchTool) Parameters() map[string]interface{} { } } -func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) if !ok { return ErrorResult("query is required") @@ -359,15 +365,15 @@ func (t *WebFetchTool) Description() string { return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content." } -func (t *WebFetchTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *WebFetchTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{ + "properties": map[string]any{ + "url": map[string]any{ "type": "string", "description": "URL to fetch", }, - "maxChars": map[string]interface{}{ + "maxChars": map[string]any{ "type": "integer", "description": "Maximum characters to extract", "minimum": 100.0, @@ -377,7 +383,7 @@ func (t *WebFetchTool) Parameters() map[string]interface{} { } } -func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { urlStr, ok := args["url"].(string) if !ok { return ErrorResult("url is required") @@ -442,7 +448,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) var text, extractor string if strings.Contains(contentType, "application/json") { - var jsonData interface{} + var jsonData any if err := json.Unmarshal(body, &jsonData); err == nil { formatted, _ := json.MarshalIndent(jsonData, "", " ") text = string(formatted) @@ -465,7 +471,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) text = text[:maxChars] } - result := map[string]interface{}{ + result := map[string]any{ "url": urlStr, "status": resp.StatusCode, "extractor": extractor, @@ -477,7 +483,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) resultJSON, _ := json.MarshalIndent(result, "", " ") return &ToolResult{ - ForLLM: fmt.Sprintf("Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated), + ForLLM: fmt.Sprintf( + "Fetched %d bytes from %s (extractor: %s, truncated: %v)", + len(text), + urlStr, + extractor, + truncated, + ), ForUser: string(resultJSON), } } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index a526ea34a..edb914f66 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -20,7 +20,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) { tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": server.URL, } @@ -56,7 +56,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": server.URL, } @@ -77,7 +77,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { func TestWebTool_WebFetch_InvalidURL(t *testing.T) { tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": "not-a-valid-url", } @@ -98,7 +98,7 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) { func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": "ftp://example.com/file.txt", } @@ -119,7 +119,7 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { func TestWebTool_WebFetch_MissingURL(t *testing.T) { tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{} + args := map[string]any{} result := tool.Execute(ctx, args) @@ -147,7 +147,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { tool := NewWebFetchTool(1000) // Limit to 1000 chars ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": server.URL, } @@ -159,7 +159,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { } // ForUser should contain truncated content (not the full 20000 chars) - resultMap := make(map[string]interface{}) + resultMap := make(map[string]any) json.Unmarshal([]byte(result.ForUser), &resultMap) if text, ok := resultMap["text"].(string); ok { if len(text) > 1100 { // Allow some margin @@ -191,7 +191,7 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) { func TestWebTool_WebSearch_MissingQuery(t *testing.T) { tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) ctx := context.Background() - args := map[string]interface{}{} + args := map[string]any{} result := tool.Execute(ctx, args) @@ -206,13 +206,17 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) - w.Write([]byte(`

Title

Content

`)) + w.Write( + []byte( + `

Title

Content

`, + ), + ) })) defer server.Close() tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": server.URL, } @@ -238,7 +242,7 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { func TestWebTool_WebFetch_MissingDomain(t *testing.T) { tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": "https://", } diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 2b184f2ec..a34889fb8 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/logger" ) @@ -65,8 +66,8 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { } mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") - if err := os.MkdirAll(mediaDir, 0700); err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]interface{}{ + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{ "error": err.Error(), }) return "" @@ -79,7 +80,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { // Create HTTP request req, err := http.NewRequest("GET", url, nil) if err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{ "error": err.Error(), }) return "" @@ -93,7 +94,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { client := &http.Client{Timeout: opts.Timeout} resp, err := client.Do(req) if err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{ "error": err.Error(), "url": url, }) @@ -102,7 +103,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]any{ "status": resp.StatusCode, "url": url, }) @@ -111,7 +112,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { out, err := os.Create(localPath) if err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]any{ "error": err.Error(), }) return "" @@ -121,13 +122,13 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { if _, err := io.Copy(out, resp.Body); err != nil { out.Close() os.Remove(localPath) - logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{ "error": err.Error(), }) return "" } - logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]interface{}{ + logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]any{ "path": localPath, }) diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index 9af2ea6bb..ad8767d40 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -29,7 +29,7 @@ type TranscriptionResponse struct { } func NewGroqTranscriber(apiKey string) *GroqTranscriber { - logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""}) + logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""}) apiBase := "https://api.groq.com/openai/v1" return &GroqTranscriber{ @@ -42,22 +42,22 @@ func NewGroqTranscriber(apiKey string) *GroqTranscriber { } func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { - logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath}) + logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath}) audioFile, err := os.Open(audioFilePath) if err != nil { - logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err}) + logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to open audio file: %w", err) } defer audioFile.Close() fileInfo, err := audioFile.Stat() if err != nil { - logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err}) + logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to get file info: %w", err) } - logger.DebugCF("voice", "Audio file details", map[string]interface{}{ + logger.DebugCF("voice", "Audio file details", map[string]any{ "size_bytes": fileInfo.Size(), "file_name": filepath.Base(audioFilePath), }) @@ -67,44 +67,44 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) if err != nil { - logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create form file: %w", err) } copied, err := io.Copy(part, audioFile) if err != nil { - logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) return nil, fmt.Errorf("failed to copy file content: %w", err) } - logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied}) + logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) if err := writer.WriteField("model", "whisper-large-v3"); err != nil { - logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write model field: %w", err) } if err := writer.WriteField("response_format", "json"); err != nil { - logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write response_format field: %w", err) } if err := writer.Close(); err != nil { - logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) return nil, fmt.Errorf("failed to close multipart writer: %w", err) } url := t.apiBase + "/audio/transcriptions" req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) if err != nil { - logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+t.apiKey) - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{ + logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{ "url": url, "request_size_bytes": requestBody.Len(), "file_size_bytes": fileInfo.Size(), @@ -112,37 +112,37 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) resp, err := t.httpClient.Do(req) if err != nil { - logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { - logger.ErrorCF("voice", "API error", map[string]interface{}{ + logger.ErrorCF("voice", "API error", map[string]any{ "status_code": resp.StatusCode, "response": string(body), }) return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } - logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{ + logger.DebugCF("voice", "Received response from Groq API", map[string]any{ "status_code": resp.StatusCode, "response_size_bytes": len(body), }) var result TranscriptionResponse if err := json.Unmarshal(body, &result); err != nil { - logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to unmarshal response: %w", err) } - logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{ + logger.InfoCF("voice", "Transcription completed successfully", map[string]any{ "text_length": len(result.Text), "language": result.Language, "duration_seconds": result.Duration, @@ -154,6 +154,6 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) func (t *GroqTranscriber) IsAvailable() bool { available := t.apiKey != "" - logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available}) + logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available}) return available } From d07ac54eef87fab754c25e3fded292ce6c957db3 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Wed, 18 Feb 2026 21:55:55 +0200 Subject: [PATCH 03/21] feat(fmt): Fix fmt --- pkg/skills/loader.go | 2 +- pkg/skills/loader_test.go | 34 ++++++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index bb0abbdcc..eb0d5f322 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -254,7 +254,7 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { content, err := os.ReadFile(skillPath) if err != nil { logger.WarnCF("skills", "Failed to read skill metadata", - map[string]interface{}{ + map[string]any{ "skill_path": skillPath, "error": err.Error(), }) diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index 539d24646..aca901d33 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -80,11 +80,11 @@ func TestExtractFrontmatter(t *testing.T) { sl := &SkillsLoader{} testcases := []struct { - name string - content string - expectedName string - expectedDesc string - lineEndingType string + name string + content string + expectedName string + expectedDesc string + lineEndingType string }{ { name: "unix-line-endings", @@ -117,8 +117,20 @@ func TestExtractFrontmatter(t *testing.T) { // Parse YAML to get name and description (parseSimpleYAML now handles all line ending types) yamlMeta := sl.parseSimpleYAML(frontmatter) - assert.Equal(t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType) - assert.Equal(t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType) + assert.Equal( + t, + tc.expectedName, + yamlMeta["name"], + "Name should be correctly parsed from frontmatter with %s line endings", + tc.lineEndingType, + ) + assert.Equal( + t, + tc.expectedDesc, + yamlMeta["description"], + "Description should be correctly parsed from frontmatter with %s line endings", + tc.lineEndingType, + ) }) } } @@ -173,7 +185,13 @@ func TestStripFrontmatter(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { result := sl.stripFrontmatter(tc.content) - assert.Equal(t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType) + assert.Equal( + t, + tc.expectedContent, + result, + "Frontmatter should be stripped correctly for %s", + tc.lineEndingType, + ) }) } } From 676bd6d222509591614db840c2d36e94b2fdc3a2 Mon Sep 17 00:00:00 2001 From: PixelTux Date: Thu, 19 Feb 2026 15:52:46 +0100 Subject: [PATCH 04/21] extra_hosts mapping to have enables container-to-host connectivity --- docker-compose.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 32e8ee339..c268b01cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,9 @@ services: container_name: picoclaw-agent profiles: - agent + # Uncomment to access host network; leave commented unless needed. + #extra_hosts: + # - "host.docker.internal:host-gateway" volumes: - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace @@ -29,6 +32,9 @@ services: restart: unless-stopped profiles: - gateway + # Uncomment to access host network; leave commented unless needed. + #extra_hosts: + # - "host.docker.internal:host-gateway" volumes: # Configuration file - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro From a89683190386c9da604fc22cf20ec594aa32214f Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Thu, 19 Feb 2026 22:05:15 +0200 Subject: [PATCH 05/21] feat(fmt): Fix formatting --- pkg/agent/loop.go | 127 ++++++++++++++++-------- pkg/agent/loop_test.go | 46 ++++++--- pkg/agent/mock_provider_test.go | 8 +- pkg/channels/discord.go | 5 +- pkg/channels/onebot.go | 91 +++++++++-------- pkg/config/config.go | 112 ++++++++++----------- pkg/providers/openai_compat/provider.go | 3 +- pkg/tools/subagent_tool_test.go | 30 +++--- pkg/tools/web_test.go | 3 +- 9 files changed, 254 insertions(+), 171 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0f1b26c5c..6772959b6 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -79,7 +79,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). -func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) { +func registerSharedTools( + cfg *config.Config, + msgBus *bus.MessageBus, + registry *AgentRegistry, + provider providers.LLMProvider, +) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) if !ok { @@ -216,7 +221,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") } -func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) { +func (al *AgentLoop) ProcessDirectWithChannel( + ctx context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { msg := bus.InboundMessage{ Channel: channel, SenderID: "cron", @@ -253,7 +261,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) logContent = utils.Truncate(msg.Content, 80) } logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), - map[string]interface{}{ + map[string]any{ "channel": msg.Channel, "chat_id": msg.ChatID, "sender_id": msg.SenderID, @@ -292,7 +300,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } logger.InfoCF("agent", "Routed message", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "session_key": sessionKey, "matched_by": route.MatchedBy, @@ -315,7 +323,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe } logger.InfoCF("agent", "Processing system message", - map[string]interface{}{ + map[string]any{ "sender_id": msg.SenderID, "chat_id": msg.ChatID, }) @@ -340,7 +348,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe // Skip internal channels - only log, don't send to user if constants.IsInternalChannel(originChannel) { logger.InfoCF("agent", "Subagent completed (internal channel)", - map[string]interface{}{ + map[string]any{ "sender_id": msg.SenderID, "content_len": len(content), "channel": originChannel, @@ -373,7 +381,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()}) + logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) } } } @@ -435,7 +443,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 9. Log response responsePreview := utils.Truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "session_key": opts.SessionKey, "iterations": iteration, @@ -446,7 +454,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // runLLMIteration executes the LLM call loop with tool handling. -func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) { +func (al *AgentLoop) runLLMIteration( + ctx context.Context, + agent *AgentInstance, + messages []providers.Message, + opts processOptions, +) (string, int, error) { iteration := 0 var finalContent string @@ -454,7 +467,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, iteration++ logger.DebugCF("agent", "LLM iteration", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "max": agent.MaxIterations, @@ -465,7 +478,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // Log LLM request details logger.DebugCF("agent", "LLM request", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "model": agent.Model, @@ -478,7 +491,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // Log full messages (detailed) logger.DebugCF("agent", "Full LLM request", - map[string]interface{}{ + map[string]any{ "iteration": iteration, "messages_json": formatMessagesForLog(messages), "tools_json": formatToolsForLog(providerToolDefs), @@ -492,7 +505,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if len(agent.Candidates) > 1 && al.fallback != nil { fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{ + return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, }) @@ -504,11 +517,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]interface{}{"agent_id": agent.ID, "iteration": iteration}) + map[string]any{"agent_id": agent.ID, "iteration": iteration}) } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{ + return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, }) @@ -529,7 +542,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, strings.Contains(errMsg, "length") if isContextError && retry < maxRetries { - logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{ + logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ "error": err.Error(), "retry": retry, }) @@ -556,7 +569,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if err != nil { logger.ErrorCF("agent", "LLM call failed", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "error": err.Error(), @@ -568,7 +581,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if len(response.ToolCalls) == 0 { finalContent = response.Content logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "content_chars": len(finalContent), @@ -582,7 +595,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, toolNames = append(toolNames, tc.Name) } logger.InfoCF("agent", "LLM requested tool calls", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "tools": toolNames, "count": len(response.ToolCalls), @@ -616,7 +629,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, argsJSON, _ := json.Marshal(tc.Arguments) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "tool": tc.Name, "iteration": iteration, @@ -631,14 +644,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // The agent will handle user notification via processSystemMessage if !result.Silent && result.ForUser != "" { logger.InfoCF("agent", "Async tool completed, agent will handle notification", - map[string]interface{}{ + map[string]any{ "tool": tc.Name, "content_len": len(result.ForUser), }) } } - toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) + toolResult := agent.Tools.ExecuteWithContext( + ctx, + tc.Name, + tc.Arguments, + opts.Channel, + opts.ChatID, + asyncCallback, + ) // Send ForUser content to user immediately if not Silent if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { @@ -648,7 +668,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, Content: toolResult.ForUser, }) logger.DebugCF("agent", "Sent tool result to user", - map[string]interface{}{ + map[string]any{ "tool": tc.Name, "content_len": len(toolResult.ForUser), }) @@ -754,7 +774,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { newHistory = append(newHistory, history[0]) // System prompt // Add a note about compression - compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount) + compressionNote := fmt.Sprintf( + "[System: Emergency compression dropped %d oldest messages due to context limit]", + droppedCount, + ) // If there was an existing summary, we might lose it if it was in the dropped part (which is just messages). // The summary is stored separately in session.Summary, so it persists! // We just need to ensure the user knows there's a gap. @@ -772,7 +795,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { agent.Sessions.SetHistory(sessionKey, newHistory) agent.Sessions.Save(sessionKey) - logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{ + logger.WarnCF("agent", "Forced compression executed", map[string]any{ "session_key": sessionKey, "dropped_msgs": droppedCount, "new_count": len(newHistory), @@ -780,8 +803,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { } // GetStartupInfo returns information about loaded tools and skills for logging. -func (al *AgentLoop) GetStartupInfo() map[string]interface{} { - info := make(map[string]interface{}) +func (al *AgentLoop) GetStartupInfo() map[string]any { + info := make(map[string]any) agent := al.registry.GetDefaultAgent() if agent == nil { @@ -790,7 +813,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { // Tools info toolsList := agent.Tools.List() - info["tools"] = map[string]interface{}{ + info["tools"] = map[string]any{ "count": len(toolsList), "names": toolsList, } @@ -799,7 +822,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { info["skills"] = agent.ContextBuilder.GetSkillsInfo() // Agents info - info["agents"] = map[string]interface{}{ + info["agents"] = map[string]any{ "count": len(al.registry.ListAgentIDs()), "ids": al.registry.ListAgentIDs(), } @@ -851,7 +874,10 @@ func formatToolsForLog(tools []providers.ToolDefinition) string { result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) result += fmt.Sprintf(" Description: %s\n", tool.Function.Description) if len(tool.Function.Parameters) > 0 { - result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) + result += fmt.Sprintf( + " Parameters: %s\n", + utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), + ) } } result += "]" @@ -904,11 +930,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { s1, _ := al.summarizeBatch(ctx, agent, part1, "") s2, _ := al.summarizeBatch(ctx, agent, part2, "") - mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2) - resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) + mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, + s2, + ) + resp, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: mergePrompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + }, + ) if err == nil { finalSummary = resp.Content } else { @@ -930,7 +966,12 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { } // summarizeBatch summarizes a batch of messages. -func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) { +func (al *AgentLoop) summarizeBatch( + ctx context.Context, + agent *AgentInstance, + batch []providers.Message, + existingSummary string, +) (string, error) { prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" if existingSummary != "" { prompt += "Existing context: " + existingSummary + "\n" @@ -940,10 +981,16 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, b prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content) } - response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) + response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + }, + ) if err != nil { return "", err } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 360685eca..4414398b1 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -171,7 +171,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { // Verify tool is registered by checking it doesn't panic on GetStartupInfo // (actual tool retrieval is tested in tools package tests) info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]interface{}) + toolsInfo := info["tools"].(map[string]any) toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list @@ -246,7 +246,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { al.RegisterTool(testTool) info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]interface{}) + toolsInfo := info["tools"].(map[string]any) toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list @@ -293,7 +293,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) { t.Fatal("Expected 'tools' key in startup info") } - toolsMap, ok := toolsInfo.(map[string]interface{}) + toolsMap, ok := toolsInfo.(map[string]any) if !ok { t.Fatal("Expected 'tools' to be a map") } @@ -349,7 +349,13 @@ type simpleMockProvider struct { response string } -func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { +func (m *simpleMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { return &providers.LLMResponse{ Content: m.response, ToolCalls: []providers.ToolCall{}, @@ -371,14 +377,14 @@ func (m *mockCustomTool) Description() string { return "Mock custom tool for testing" } -func (m *mockCustomTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (m *mockCustomTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, } } -func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { +func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { return tools.SilentResult("Custom tool executed") } @@ -396,14 +402,14 @@ func (m *mockContextualTool) Description() string { return "Mock contextual tool" } -func (m *mockContextualTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (m *mockContextualTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{}, + "properties": map[string]any{}, } } -func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { +func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { return tools.SilentResult("Contextual tool executed") } @@ -523,7 +529,13 @@ type failFirstMockProvider struct { successResp string } -func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { +func (m *failFirstMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { m.currentCall++ if m.currentCall <= m.failures { return nil, m.failError @@ -588,7 +600,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { // Call ProcessDirectWithChannel // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration - response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat") + response, err := al.ProcessDirectWithChannel( + context.Background(), + "Trigger message", + sessionKey, + "test", + "test-chat", + ) if err != nil { t.Fatalf("Expected success after retry, got error: %v", err) } diff --git a/pkg/agent/mock_provider_test.go b/pkg/agent/mock_provider_test.go index ccbecbafe..4962810dc 100644 --- a/pkg/agent/mock_provider_test.go +++ b/pkg/agent/mock_provider_test.go @@ -8,7 +8,13 @@ import ( type mockProvider struct{} -func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { +func (m *mockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { return &providers.LLMResponse{ Content: "Mock response", ToolCalls: []providers.ToolCall{}, diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 9ddec662c..b26f2e684 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -8,6 +8,7 @@ import ( "time" "github.com/bwmarrin/discordgo" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -296,7 +297,7 @@ func (c *DiscordChannel) startTyping(chatID string) { go func() { if err := c.session.ChannelTyping(chatID); err != nil { - logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) } ticker := time.NewTicker(8 * time.Second) defer ticker.Stop() @@ -311,7 +312,7 @@ func (c *DiscordChannel) startTyping(chatID string) { return case <-ticker.C: if err := c.session.ChannelTyping(chatID); err != nil { - logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) } } } diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 53e82b44d..b221365e3 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -87,14 +87,14 @@ type oneBotSender struct { } type oneBotAPIRequest struct { - Action string `json:"action"` - Params interface{} `json:"params"` - Echo string `json:"echo,omitempty"` + Action string `json:"action"` + Params any `json:"params"` + Echo string `json:"echo,omitempty"` } type oneBotMessageSegment struct { - Type string `json:"type"` - Data map[string]interface{} `json:"data"` + Type string `json:"type"` + Data map[string]any `json:"data"` } func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { @@ -117,13 +117,13 @@ func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { go func() { - _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{ + _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{ "message_id": messageID, "emoji_id": emojiID, "set": set, }, 5*time.Second) if err != nil { - logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{ + logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{ "message_id": messageID, "error": err.Error(), }) @@ -136,14 +136,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error { return fmt.Errorf("OneBot ws_url not configured") } - logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{ + logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{ "ws_url": c.config.WSUrl, }) c.ctx, c.cancel = context.WithCancel(ctx) if err := c.connect(); err != nil { - logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{ + logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{ "error": err.Error(), }) } else { @@ -208,7 +208,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) { err := conn.WriteMessage(websocket.PingMessage, nil) c.writeMu.Unlock() if err != nil { - logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{ + logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{ "error": err.Error(), }) return @@ -220,7 +220,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) { func (c *OneBotChannel) fetchSelfID() { resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second) if err != nil { - logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{ "error": err.Error(), }) return @@ -250,7 +250,7 @@ func (c *OneBotChannel) fetchSelfID() { } if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 { atomic.StoreInt64(&c.selfID, uid) - logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{ + logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{ "self_id": uid, "nickname": info.Nickname, }) @@ -258,12 +258,12 @@ func (c *OneBotChannel) fetchSelfID() { } } - logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{ + logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{ "response": string(resp), }) } -func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) { +func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) { c.mu.Lock() conn := c.conn c.mu.Unlock() @@ -332,7 +332,7 @@ func (c *OneBotChannel) reconnectLoop() { if conn == nil { logger.InfoC("onebot", "Attempting to reconnect...") if err := c.connect(); err != nil { - logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{ + logger.ErrorCF("onebot", "Reconnect failed", map[string]any{ "error": err.Error(), }) } else { @@ -405,7 +405,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.writeMu.Unlock() if err != nil { - logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{ + logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) return err @@ -427,20 +427,20 @@ func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMes if msgID, ok := lastMsgID.(string); ok && msgID != "" { segments = append(segments, oneBotMessageSegment{ Type: "reply", - Data: map[string]interface{}{"id": msgID}, + Data: map[string]any{"id": msgID}, }) } } segments = append(segments, oneBotMessageSegment{ Type: "text", - Data: map[string]interface{}{"text": content}, + Data: map[string]any{"text": content}, }) return segments } -func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) { +func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) { chatID := msg.ChatID segments := c.buildMessageSegments(chatID, msg.Content) @@ -458,7 +458,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter if err != nil { return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID) } - return action, map[string]interface{}{idKey: id, "message": segments}, nil + return action, map[string]any{idKey: id, "message": segments}, nil } func (c *OneBotChannel) listen() { @@ -478,7 +478,7 @@ func (c *OneBotChannel) listen() { default: _, message, err := conn.ReadMessage() if err != nil { - logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{ + logger.ErrorCF("onebot", "WebSocket read error", map[string]any{ "error": err.Error(), }) c.mu.Lock() @@ -494,14 +494,14 @@ func (c *OneBotChannel) listen() { var raw oneBotRawEvent if err := json.Unmarshal(message, &raw); err != nil { - logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{ "error": err.Error(), "payload": string(message), }) continue } - logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{ + logger.DebugCF("onebot", "WebSocket event", map[string]any{ "length": len(message), "post_type": raw.PostType, "sub_type": raw.SubType, @@ -518,7 +518,7 @@ func (c *OneBotChannel) listen() { default: } } else { - logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{ + logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{ "echo": raw.Echo, "status": string(raw.Status), }) @@ -527,7 +527,7 @@ func (c *OneBotChannel) listen() { } if isAPIResponse(raw.Status) { - logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{ + logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{ "status": string(raw.Status), }) continue @@ -594,7 +594,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) return parseMessageResult{Text: s, IsBotMentioned: mentioned} } - var segments []map[string]interface{} + var segments []map[string]any if err := json.Unmarshal(raw, &segments); err != nil { return parseMessageResult{} } @@ -608,7 +608,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) for _, seg := range segments { segType, _ := seg["type"].(string) - data, _ := seg["data"].(map[string]interface{}) + data, _ := seg["data"].(map[string]any) switch segType { case "text": @@ -662,7 +662,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) result, err := c.transcriber.Transcribe(tctx, localPath) tcancel() if err != nil { - logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{ + logger.WarnCF("onebot", "Voice transcription failed", map[string]any{ "error": err.Error(), }) textParts = append(textParts, "[voice (transcription failed)]") @@ -713,7 +713,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { case "message": if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { if !c.IsAllowed(strconv.FormatInt(userID, 10)) { - logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{ + logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{ "user_id": userID, }) return @@ -722,7 +722,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { c.handleMessage(raw) case "message_sent": - logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{ + logger.DebugCF("onebot", "Bot sent message event", map[string]any{ "message_type": raw.MessageType, "message_id": parseJSONString(raw.MessageID), }) @@ -734,18 +734,18 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { c.handleNoticeEvent(raw) case "request": - logger.DebugCF("onebot", "Request event received", map[string]interface{}{ + logger.DebugCF("onebot", "Request event received", map[string]any{ "sub_type": raw.SubType, }) case "": - logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{ + logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{ "echo": raw.Echo, "status": raw.Status, }) default: - logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{ + logger.DebugCF("onebot", "Unknown post_type", map[string]any{ "post_type": raw.PostType, }) } @@ -753,14 +753,14 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { if raw.MetaEventType == "lifecycle" { - logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType}) + logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType}) } else if raw.MetaEventType != "heartbeat" { logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil) } } func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) { - fields := map[string]interface{}{ + fields := map[string]any{ "notice_type": raw.NoticeType, "sub_type": raw.SubType, "group_id": parseJSONString(raw.GroupID), @@ -780,7 +780,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { // Parse fields from raw event userID, err := parseJSONInt64(raw.UserID) if err != nil { - logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{ "error": err.Error(), "raw": string(raw.UserID), }) @@ -817,7 +817,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { var sender oneBotSender if len(raw.Sender) > 0 { if err := json.Unmarshal(raw.Sender, &sender); err != nil { - logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to parse sender", map[string]any{ "error": err.Error(), "sender": string(raw.Sender), }) @@ -829,7 +829,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { defer func() { for _, f := range parsed.LocalFiles { if err := os.Remove(f); err != nil { - logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{ + logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{ "path": f, "error": err.Error(), }) @@ -839,14 +839,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { } if c.isDuplicate(messageID) { - logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{ + logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ "message_id": messageID, }) return } if content == "" { - logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{ + logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{ "message_id": messageID, }) return @@ -885,7 +885,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned) if !triggered { - logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{ + logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{ "sender": senderID, "group": groupIDStr, "is_mentioned": isBotMentioned, @@ -896,7 +896,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { content = strippedContent default: - logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{ + logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{ "type": raw.MessageType, "message_id": messageID, "user_id": userID, @@ -904,7 +904,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { return } - logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{ + logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{ "sender": senderID, "chat_id": chatID, "message_id": messageID, @@ -957,7 +957,10 @@ func truncate(s string, n int) string { return string(runes[:n]) + "..." } -func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) { +func (c *OneBotChannel) checkGroupTrigger( + content string, + isBotMentioned bool, +) (triggered bool, strippedContent string) { if isBotMentioned { return true, strings.TrimSpace(content) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 3bdb6f030..220eae88a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,7 +23,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { } // Try []interface{} to handle mixed types - var raw []interface{} + var raw []any if err := json.Unmarshal(data, &raw); err != nil { return err } @@ -139,16 +139,16 @@ type SessionConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` 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"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { @@ -165,87 +165,87 @@ type ChannelsConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` } type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` } type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } type HeartbeatConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } type DevicesConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } @@ -266,11 +266,11 @@ type ProvidersConfig struct { } type ProviderConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` - ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` + APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` + APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` + AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` } type OpenAIProviderConfig struct { @@ -284,19 +284,19 @@ type GatewayConfig struct { } 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"` + 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"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` 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"` + 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"` } @@ -482,11 +482,11 @@ func SaveConfig(path string, cfg *Config) error { } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return err } - return os.WriteFile(path, data, 0600) + return os.WriteFile(path, data, 0o600) } func (c *Config) WorkspacePath() string { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 9cfec44fe..a09825c1c 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -79,7 +79,8 @@ func (p *Provider) Chat( if maxTokens, ok := asInt(options["max_tokens"]); ok { lowerModel := strings.ToLower(model) - if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") { + if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || + strings.Contains(lowerModel, "gpt-5") { requestBody["max_completion_tokens"] = maxTokens } else { requestBody["max_tokens"] = maxTokens diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index f960a7fda..59bfdffae 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -11,10 +11,16 @@ import ( // MockLLMProvider is a test implementation of LLMProvider type MockLLMProvider struct { - lastOptions map[string]interface{} + lastOptions map[string]any } -func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { +func (m *MockLLMProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { m.lastOptions = options // Find the last user message to generate a response for i := len(messages) - 1; i >= 0; i-- { @@ -47,7 +53,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { tool.SetContext("cli", "direct") ctx := context.Background() - args := map[string]interface{}{"task": "Do something"} + args := map[string]any{"task": "Do something"} result := tool.Execute(ctx, args) if result == nil || result.IsError { @@ -108,13 +114,13 @@ func TestSubagentTool_Parameters(t *testing.T) { } // Check properties - props, ok := params["properties"].(map[string]interface{}) + props, ok := params["properties"].(map[string]any) if !ok { t.Fatal("Properties should be a map") } // Verify task parameter - task, ok := props["task"].(map[string]interface{}) + task, ok := props["task"].(map[string]any) if !ok { t.Fatal("Task parameter should exist") } @@ -123,7 +129,7 @@ func TestSubagentTool_Parameters(t *testing.T) { } // Verify label parameter - label, ok := props["label"].(map[string]interface{}) + label, ok := props["label"].(map[string]any) if !ok { t.Fatal("Label parameter should exist") } @@ -163,7 +169,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) { tool.SetContext("telegram", "chat-123") ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "Write a haiku about coding", "label": "haiku-task", } @@ -218,7 +224,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { tool := NewSubagentTool(manager) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "Test task without label", } @@ -241,7 +247,7 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) { tool := NewSubagentTool(manager) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "label": "test", } @@ -268,7 +274,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) { tool := NewSubagentTool(nil) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "test task", } @@ -297,7 +303,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) { tool.SetContext(channel, chatID) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "task": "Test context passing", } @@ -324,7 +330,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { // Create a task that will generate long response longTask := strings.Repeat("This is a very long task description. ", 100) - args := map[string]interface{}{ + args := map[string]any{ "task": longTask, "label": "long-test", } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 222a38972..d999d8958 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -255,7 +255,8 @@ func TestWebFetchTool_extractText(t *testing.T) { if len(lines) < 2 { t.Errorf("Expected multiple lines, got %d: %q", len(lines), got) } - if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || !strings.Contains(got, "Paragraph 2") { + if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || + !strings.Contains(got, "Paragraph 2") { t.Errorf("Missing expected text: %q", got) } }, From bca92433ba209e1866c86eaa342f708f29ba882e Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Fri, 20 Feb 2026 16:06:33 +0900 Subject: [PATCH 06/21] Use strings.Builder instead of += concatenation in loops --- pkg/agent/context.go | 6 ++--- pkg/agent/loop.go | 54 +++++++++++++++++++++------------------ pkg/agent/memory.go | 61 +++++++++++++++++++------------------------- 3 files changed, 58 insertions(+), 63 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 27e3ef9dc..78f5f1ffa 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -146,15 +146,15 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { "IDENTITY.md", } - var result string + var sb strings.Builder for _, filename := range bootstrapFiles { filePath := filepath.Join(cb.workspace, filename) if data, err := os.ReadFile(filePath); err == nil { - result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data)) + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) } } - return result + return sb.String() } func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e7b48d47a..bec44325e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -818,49 +818,49 @@ func formatMessagesForLog(messages []providers.Message) string { return "[]" } - var result string - result += "[\n" + var sb strings.Builder + sb.WriteString("[\n") for i, msg := range messages { - result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role) + fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) if len(msg.ToolCalls) > 0 { - result += " ToolCalls:\n" + sb.WriteString(" ToolCalls:\n") for _, tc := range msg.ToolCalls { - result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) + fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) if tc.Function != nil { - result += fmt.Sprintf(" Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) + fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) } } } if msg.Content != "" { content := utils.Truncate(msg.Content, 200) - result += fmt.Sprintf(" Content: %s\n", content) + fmt.Fprintf(&sb, " Content: %s\n", content) } if msg.ToolCallID != "" { - result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID) + fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) } - result += "\n" + sb.WriteString("\n") } - result += "]" - return result + sb.WriteString("]") + return sb.String() } // formatToolsForLog formats tool definitions for logging -func formatToolsForLog(tools []providers.ToolDefinition) string { - if len(tools) == 0 { +func formatToolsForLog(toolDefs []providers.ToolDefinition) string { + if len(toolDefs) == 0 { return "[]" } - var result string - result += "[\n" - for i, tool := range tools { - result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) - result += fmt.Sprintf(" Description: %s\n", tool.Function.Description) + var sb strings.Builder + sb.WriteString("[\n") + for i, tool := range toolDefs { + fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) + fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) if len(tool.Function.Parameters) > 0 { - result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) + fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) } } - result += "]" - return result + sb.WriteString("]") + return sb.String() } // summarizeSession summarizes the conversation history for a session. @@ -936,14 +936,18 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { // summarizeBatch summarizes a batch of messages. func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) { - prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" + var sb strings.Builder + sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") if existingSummary != "" { - prompt += "Existing context: " + existingSummary + "\n" + sb.WriteString("Existing context: ") + sb.WriteString(existingSummary) + sb.WriteString("\n") } - prompt += "\nCONVERSATION:\n" + sb.WriteString("\nCONVERSATION:\n") for _, m := range batch { - prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content) + fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) } + prompt := sb.String() response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{ "max_tokens": 1024, diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 3f6896f91..6e5d0ba40 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" ) @@ -100,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error { // GetRecentDailyNotes returns daily notes from the last N days. // Contents are joined with "---" separator. func (ms *MemoryStore) GetRecentDailyNotes(days int) string { - var notes []string + var sb strings.Builder + first := true for i := 0; i < days; i++ { date := time.Now().AddDate(0, 0, -i) @@ -109,53 +111,42 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md") if data, err := os.ReadFile(filePath); err == nil { - notes = append(notes, string(data)) + if !first { + sb.WriteString("\n\n---\n\n") + } + sb.Write(data) + first = false } } - if len(notes) == 0 { - return "" - } - - // Join with separator - var result string - for i, note := range notes { - if i > 0 { - result += "\n\n---\n\n" - } - result += note - } - return result + return sb.String() } // GetMemoryContext returns formatted memory context for the agent prompt. // Includes long-term memory and recent daily notes. func (ms *MemoryStore) GetMemoryContext() string { - var parts []string - - // Long-term memory longTerm := ms.ReadLongTerm() - if longTerm != "" { - parts = append(parts, "## Long-term Memory\n\n"+longTerm) - } - - // Recent daily notes (last 3 days) recentNotes := ms.GetRecentDailyNotes(3) - if recentNotes != "" { - parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) - } - if len(parts) == 0 { + if longTerm == "" && recentNotes == "" { return "" } - // Join parts with separator - var result string - for i, part := range parts { - if i > 0 { - result += "\n\n---\n\n" - } - result += part + var sb strings.Builder + sb.WriteString("# Memory\n\n") + + if longTerm != "" { + sb.WriteString("## Long-term Memory\n\n") + sb.WriteString(longTerm) } - return fmt.Sprintf("# Memory\n\n%s", result) + + if recentNotes != "" { + if longTerm != "" { + sb.WriteString("\n\n---\n\n") + } + sb.WriteString("## Recent Daily Notes\n\n") + sb.WriteString(recentNotes) + } + + return sb.String() } From df49f6698a145f619fb687125792953401800e61 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Fri, 20 Feb 2026 20:48:43 +0900 Subject: [PATCH 07/21] Fix --- pkg/agent/memory.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 6e5d0ba40..70be2fb61 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -133,7 +133,6 @@ func (ms *MemoryStore) GetMemoryContext() string { } var sb strings.Builder - sb.WriteString("# Memory\n\n") if longTerm != "" { sb.WriteString("## Long-term Memory\n\n") From 838a69085bafadd90175d8b1a52aa3547084222b Mon Sep 17 00:00:00 2001 From: esubaalew Date: Fri, 20 Feb 2026 18:23:22 +0300 Subject: [PATCH 08/21] fix: correct docs misalignment across translations and guides - Fix DingTalk section referencing "QQ numbers" instead of DingTalk user IDs - Fix Anthropic example showing OAuth when code uses paste-token auth - Replace OpenClaw references in ANTIGRAVITY_AUTH.md with actual PicoClaw paths and Go patterns - Fix auth file path from auth-profiles.json to auth.json in ANTIGRAVITY_USAGE.md - Remove non-existent approval tool from tools_configuration.md, add skills tool docs - Update Quick Start configs in fr/pt-br/vi/ja translations to use model_list format - Fix allowFrom camelCase to allow_from in fr/pt-br translations - Fix camelCase config keys in ja translation - Update zh/ja web search config from old flat format to brave/duckduckgo - Fix broken ClawdChat link and trailing commas in zh translation - Add missing qwen/cerebras providers to fr/pt-br/vi translation tables - Add missing protocol prefixes to migration guide - Fix typos in community roadmap --- README.fr.md | 31 +- README.ja.md | 61 ++- README.md | 8 +- README.pt-br.md | 29 +- README.vi.md | 40 +- README.zh.md | 33 +- docs/ANTIGRAVITY_AUTH.md | 435 ++++++---------------- docs/ANTIGRAVITY_USAGE.md | 10 +- docs/design/provider-refactoring-tests.md | 9 +- docs/migration/model-list-migration.md | 8 + docs/picoclaw_community_roadmap_260216.md | 4 +- docs/tools_configuration.md | 53 ++- 12 files changed, 281 insertions(+), 440 deletions(-) diff --git a/README.fr.md b/README.fr.md index d49edc5ee..7199f7098 100644 --- a/README.fr.md +++ b/README.fr.md @@ -212,19 +212,24 @@ picoclaw onboard ```json { + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-5.2", + "api_key": "sk-your-openai-key", + "api_base": "https://api.openai.com/v1" + } + ], "agents": { "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 + "model": "gpt4" } }, - "providers": { - "openrouter": { - "api_key": "xxx", - "api_base": "https://openrouter.ai/api/v1" + "channels": { + "telegram": { + "enabled": true, + "token": "VOTRE_TOKEN_BOT", + "allow_from": ["VOTRE_USER_ID"] } }, "tools": { @@ -290,7 +295,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom "telegram": { "enabled": true, "token": "VOTRE_TOKEN_BOT", - "allowFrom": ["VOTRE_USER_ID"] + "allow_from": ["VOTRE_USER_ID"] } } } @@ -333,7 +338,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "VOTRE_TOKEN_BOT", - "allowFrom": ["VOTRE_USER_ID"] + "allow_from": ["VOTRE_USER_ID"] } } } @@ -765,6 +770,8 @@ Le sous-agent a accĆØs aux outils (message, web_search, etc.) et peut communique | `anthropic` (ƀ tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` (ƀ tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek` (ƀ tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Alibaba Qwen) | [dashscope.aliyuncs.com](https://dashscope.aliyuncs.com/compatible-mode/v1) | +| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) | | `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) |
@@ -1087,7 +1094,7 @@ Ajoutez la clĆ© dans `~/.picoclaw/config.json` si vous utilisez Brave : "tools": { "web": { "brave": { - "enabled": true, + "enabled": false, "api_key": "VOTRE_CLE_API_BRAVE", "max_results": 5 }, diff --git a/README.ja.md b/README.ja.md index 793a51101..bb0bdfb28 100644 --- a/README.ja.md +++ b/README.ja.md @@ -174,35 +174,25 @@ picoclaw onboard ```json { + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-5.2", + "api_key": "sk-your-openai-key", + "api_base": "https://api.openai.com/v1" + } + ], "agents": { "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 + "model": "gpt4" } }, - "providers": { - "openrouter": { - "api_key": "xxx", - "api_base": "https://openrouter.ai/api/v1" + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_TELEGRAM_BOT_TOKEN", + "allow_from": [] } - }, - "tools": { - "web": { - "search": { - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 } } ``` @@ -214,7 +204,7 @@ picoclaw onboard > **ę³Øę„**: å®Œå…ØćŖčØ­å®šćƒ†ćƒ³ćƒ—ćƒ¬ćƒ¼ćƒˆćÆ `config.example.json` ć‚’å‚ē…§ć—ć¦ćć ć•ć„ć€‚ -**3. チャット** +**4. チャット** ```bash picoclaw agent -m "What is 2+2?" @@ -764,10 +754,10 @@ HEARTBEAT_OK åæœē­” ćƒ¦ćƒ¼ć‚¶ćƒ¼ćŒē›“ęŽ„ēµęžœć‚’å—ć‘å–ć‚‹ }, "providers": { "openrouter": { - "apiKey": "sk-or-v1-xxx" + "api_key": "sk-or-v1-xxx" }, "groq": { - "apiKey": "gsk_xxx" + "api_key": "gsk_xxx" } }, "channels": { @@ -786,17 +776,17 @@ HEARTBEAT_OK åæœē­” ćƒ¦ćƒ¼ć‚¶ćƒ¼ćŒē›“ęŽ„ēµęžœć‚’å—ć‘å–ć‚‹ }, "feishu": { "enabled": false, - "appId": "cli_xxx", - "appSecret": "xxx", - "encryptKey": "", - "verificationToken": "", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", "allow_from": [] } }, "tools": { "web": { "search": { - "apiKey": "BSA..." + "api_key": "BSA..." } }, "cron": { @@ -1001,9 +991,14 @@ Web ę¤œē“¢ć‚’ęœ‰åŠ¹ć«ć™ć‚‹ć«ćÆļ¼š { "tools": { "web": { - "search": { + "brave": { + "enabled": true, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 } } } diff --git a/README.md b/README.md index a82a9ad32..d7d8be80b 100644 --- a/README.md +++ b/README.md @@ -418,7 +418,7 @@ picoclaw gateway } ``` -> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access. +> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access. **3. Run** @@ -867,15 +867,15 @@ This design also enables **multi-agent support** with flexible provider selectio } ``` -**Anthropic (with OAuth)** +**Anthropic (with API key)** ```json { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" + "api_key": "sk-ant-your-key" } ``` -> Run `picoclaw auth login --provider anthropic` to set up OAuth credentials. +> Run `picoclaw auth login --provider anthropic` to paste your API token. **Ollama (local)** ```json diff --git a/README.pt-br.md b/README.pt-br.md index a1788d119..ec8fe8e1c 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -213,19 +213,17 @@ picoclaw onboard ```json { + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-5.2", + "api_key": "sk-your-openai-key", + "api_base": "https://api.openai.com/v1" + } + ], "agents": { "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "openrouter": { - "api_key": "xxx", - "api_base": "https://openrouter.ai/api/v1" + "model": "gpt4" } }, "tools": { @@ -291,7 +289,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom. "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -334,7 +332,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -766,6 +764,8 @@ O subagente tem acesso Ć s ferramentas (message, web_search, etc.) e pode se com | `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | | `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | | `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | Alibaba Qwen | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `cerebras` | Cerebras | [cerebras.ai](https://cerebras.ai) | | `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
@@ -1088,7 +1088,7 @@ Adicione a key em `~/.picoclaw/config.json` se usar o Brave: "tools": { "web": { "brave": { - "enabled": true, + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, @@ -1119,3 +1119,4 @@ Isso acontece quando outra instĆ¢ncia do bot estĆ” em execução. Certifique-se | **Zhipu** | 200K tokens/mĆŖs | Melhor para usuĆ”rios chineses | | **Brave Search** | 2000 consultas/mĆŖs | Funcionalidade de busca web | | **Groq** | Plano gratuito disponĆ­vel | InferĆŖncia ultra-rĆ”pida (Llama, Mixtral) | +| **Cerebras** | Plano gratuito disponĆ­vel | InferĆŖncia ultra-rĆ”pida (Llama 3.3 70B) | diff --git a/README.vi.md b/README.vi.md index 5548f88a4..161842933 100644 --- a/README.vi.md +++ b/README.vi.md @@ -193,32 +193,24 @@ picoclaw onboard ```json { + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-5.2", + "api_key": "sk-your-openai-key", + "api_base": "https://api.openai.com/v1" + } + ], "agents": { "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 + "model": "gpt4" } }, - "providers": { - "openrouter": { - "api_key": "xxx", - "api_base": "https://openrouter.ai/api/v1" - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_TELEGRAM_BOT_TOKEN", + "allow_from": [] } } } @@ -747,6 +739,8 @@ Subagent có quyền truy cįŗ­p cĆ”c cĆ“ng cỄ (message, web_search, v.v.) vĆ  | `openai` (Đang thį»­ nghiệm) | LLM (GPT trį»±c tiįŗæp) | [platform.openai.com](https://platform.openai.com) | | `deepseek` (Đang thį»­ nghiệm) | LLM (DeepSeek trį»±c tiįŗæp) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + **Chuyển giį»ng nói** (Whisper) | [console.groq.com](https://console.groq.com) | +| `qwen` | LLM (Qwen trį»±c tiįŗæp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `cerebras` | LLM (Cerebras trį»±c tiįŗæp) | [cerebras.ai](https://cerebras.ai) |
Cįŗ„u hƬnh Zhipu @@ -1065,7 +1059,7 @@ ThĆŖm key vĆ o `~/.picoclaw/config.json` nįŗæu dùng Brave: "tools": { "web": { "brave": { - "enabled": true, + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, diff --git a/README.zh.md b/README.zh.md index d470db033..0989770ca 100644 --- a/README.zh.md +++ b/README.zh.md @@ -238,9 +238,14 @@ picoclaw onboard ], "tools": { "web": { - "search": { + "brave": { + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 } }, "cron": { @@ -345,7 +350,8 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"], + "mention_only": false } } } @@ -525,7 +531,7 @@ picoclaw gateway åŖéœ€é€ščæ‡ CLI ęˆ–ä»»ä½•é›†ęˆēš„čŠå¤©åŗ”ē”Øå‘é€äø€ę”ę¶ˆęÆļ¼Œå³åÆå°† PicoClaw čæžęŽ„åˆ° Agent ē¤¾äŗ¤ē½‘ē»œć€‚ -**é˜…čÆ» `https://clawdchat.ai/skill.md` å¹¶ęŒ‰ē…§čÆ“ę˜ŽåŠ å…„ [ClawdChat.ai**](https://clawdchat.ai) +**é˜…čÆ» `https://clawdchat.ai/skill.md` å¹¶ęŒ‰ē…§čÆ“ę˜ŽåŠ å…„ [ClawdChat.ai](https://clawdchat.ai) ## āš™ļø é…ē½®čÆ¦č§£ @@ -857,8 +863,8 @@ Agent čÆ»å– HEARTBEAT.md "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" - }, - }, + } + } } ``` @@ -921,8 +927,14 @@ picoclaw agent -m "你儽" }, "tools": { "web": { - "search": { - "api_key": "BSA..." + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 } }, "cron": { @@ -989,9 +1001,14 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) { "tools": { "web": { - "search": { + "brave": { + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 } } } diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/ANTIGRAVITY_AUTH.md index 5d68de427..89261d899 100644 --- a/docs/ANTIGRAVITY_AUTH.md +++ b/docs/ANTIGRAVITY_AUTH.md @@ -378,7 +378,7 @@ const antigravityPlugin = { description: "OAuth flow for Google Antigravity (Cloud Code Assist)", configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api: PicoClawPluginApi) { api.registerProvider({ id: "google-antigravity", label: "Google Antigravity", @@ -405,7 +405,7 @@ const antigravityPlugin = { ```typescript type ProviderAuthContext = { - config: OpenClawConfig; + config: PicoClawConfig; agentDir?: string; workspaceDir?: string; prompter: WizardPrompter; // UI prompts/notifications @@ -426,7 +426,7 @@ type ProviderAuthResult = { profileId: string; credential: AuthProfileCredential; }>; - configPatch?: Partial; + configPatch?: Partial; defaultModel?: string; notes?: string[]; }; @@ -438,10 +438,9 @@ type ProviderAuthResult = { ### 1. Required Environment/Dependencies -- Node.js ≄ 22 -- OpenClaw plugin-sdk -- crypto module (built-in) -- http module (built-in) +- Go ≄ 1.21 +- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`) +- `crypto` and `net/http` standard library packages ### 2. Required Headers for API Calls @@ -572,36 +571,40 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field: ## Configuration -### openclaw.json Configuration +### config.json Configuration -```json5 +```json { - agents: { - defaults: { - model: { - primary: "google-antigravity/claude-opus-4-6-thinking", - }, - }, - }, + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model": "gemini-flash" + } + } } ``` ### Auth Profile Storage -Auth profiles are stored in `~/.openclaw/agent/auth-profiles.json`: +Auth profiles are stored in `~/.picoclaw/auth.json`: ```json { - "version": 1, - "profiles": { - "google-antigravity:user@example.com": { - "type": "oauth", + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", "provider": "google-antigravity", - "access": "ya29...", - "refresh": "1//...", - "expires": 1704067200000, + "auth_method": "oauth", "email": "user@example.com", - "projectId": "my-project-id" + "project_id": "my-project-id" } } } @@ -611,277 +614,85 @@ Auth profiles are stored in `~/.openclaw/agent/auth-profiles.json`: ## Creating a New Provider in PicoClaw +PicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider: + ### Step-by-Step Implementation -#### 1. Create Plugin Structure +#### 1. Create Provider File + +Create a new Go file in `pkg/providers/`: ``` -extensions/ -└── your-provider-auth/ - ā”œā”€ā”€ openclaw.plugin.json - ā”œā”€ā”€ package.json - ā”œā”€ā”€ README.md - └── index.ts +pkg/providers/ +└── your_provider.go ``` -#### 2. Define Plugin Manifest +#### 2. Implement the Provider Interface -**openclaw.plugin.json:** -```json -{ - "id": "your-provider-auth", - "providers": ["your-provider"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } +Your provider must implement the `Provider` interface defined in `pkg/providers/types.go`: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string } -``` -**package.json:** -```json -{ - "name": "@openclaw/your-provider-auth", - "version": "1.0.0", - "private": true, - "description": "Your Provider OAuth plugin", - "type": "module" -} -``` - -#### 3. Implement OAuth Flow - -```typescript -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderAuthContext, -} from "openclaw/plugin-sdk"; - -const YOUR_CLIENT_ID = "your-client-id"; -const YOUR_CLIENT_SECRET = "your-client-secret"; -const AUTH_URL = "https://provider.com/oauth/authorize"; -const TOKEN_URL = "https://provider.com/oauth/token"; -const REDIRECT_URI = "http://localhost:PORT/oauth-callback"; - -async function loginYourProvider(params: { - isRemote: boolean; - openUrl: (url: string) => Promise; - prompt: (message: string) => Promise; - note: (message: string, title?: string) => Promise; - log: (message: string) => void; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}) { - // 1. Generate PKCE - const { verifier, challenge } = generatePkce(); - const state = randomBytes(16).toString("hex"); - - // 2. Build auth URL - const authUrl = buildAuthUrl({ challenge, state }); - - // 3. Start callback server (if not remote) - const callbackServer = !params.isRemote - ? await startCallbackServer({ timeoutMs: 5 * 60 * 1000 }) - : null; - - // 4. Open browser or show URL - if (callbackServer) { - await params.openUrl(authUrl); - const callback = await callbackServer.waitForCallback(); - code = callback.searchParams.get("code"); - } else { - await params.note(`Auth URL: ${authUrl}`, "OAuth"); - const input = await params.prompt("Paste redirect URL:"); - const parsed = parseCallbackInput(input); - code = parsed.code; - } - - // 5. Exchange code for tokens - const tokens = await exchangeCode({ code, verifier }); - - // 6. Fetch additional user data - const email = await fetchUserEmail(tokens.access); - - return { ...tokens, email }; -} -``` - -#### 4. Register Provider - -```typescript -const yourProviderPlugin = { - id: "your-provider-auth", - name: "Your Provider Auth", - description: "OAuth for Your Provider", - configSchema: emptyPluginConfigSchema(), - - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: "your-provider", - label: "Your Provider", - docsPath: "/providers/models", - aliases: ["yp"], - - auth: [ - { - id: "oauth", - label: "OAuth Login", - hint: "Browser-based authentication", - kind: "oauth", - - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting OAuth..."); - - try { - const result = await loginYourProvider({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - prompt: async (msg) => String(await ctx.prompter.text({ message: msg })), - note: ctx.prompter.note, - log: (msg) => ctx.runtime.log(msg), - progress: spin, - }); - - return buildOauthProviderAuthResult({ - providerId: "your-provider", - defaultModel: "your-provider/model-name", - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - notes: ["Provider-specific notes"], - }); - } catch (err) { - spin.stop("OAuth failed"); - throw err; - } - }, - }, - ], - }); - }, -}; - -export default yourProviderPlugin; -``` - -#### 5. Implement Usage Tracking (Optional) - -```typescript -// src/infra/provider-usage.fetch.your-provider.ts -export async function fetchYourProviderUsage( - token: string, - timeoutMs: number, - fetchFn: typeof fetch -): Promise { - // Fetch usage data from provider API - const response = await fetchFn("https://api.provider.com/usage", { - headers: { Authorization: `Bearer ${token}` }, - }); - - const data = await response.json(); - - return { - provider: "your-provider", - displayName: "Your Provider", - windows: [ - { label: "Credits", usedPercent: data.usedPercent }, - ], - plan: data.planName, - }; -} -``` - -#### 6. Register Usage Fetcher - -```typescript -// src/infra/provider-usage.load.ts -case "your-provider": - return await fetchYourProviderUsage(auth.token, timeoutMs, fetchFn); -``` - -#### 7. Add Provider to Type Definitions - -```typescript -// src/infra/provider-usage.types.ts -export type SupportedProvider = - | "anthropic" - | "github-copilot" - | "google-gemini-cli" - | "google-antigravity" - | "your-provider" // Add here - | "minimax" - | "openai-codex"; -``` - -#### 8. Add Auth Choice Handler - -```typescript -// src/commands/auth-choice.apply.your-provider.ts -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; - -export async function applyAuthChoiceYourProvider( - params: ApplyAuthChoiceParams -): Promise { - return await applyAuthChoicePluginProvider(params, { - authChoice: "your-provider", - pluginId: "your-provider-auth", - providerId: "your-provider", - methodId: "oauth", - label: "Your Provider", - }); -} -``` - -#### 9. Export from Main Index - -```typescript -// src/commands/auth-choice.apply.ts -import { applyAuthChoiceYourProvider } from "./auth-choice.apply.your-provider.js"; - -// In the switch statement: -case "your-provider": - return await applyAuthChoiceYourProvider(params); -``` - -### Helper Utilities - -#### PKCE Generation -```typescript -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} -``` - -#### Callback Server -```typescript -async function startCallbackServer(params: { timeoutMs: number }) { - const port = 51121; // Your port - - const server = createServer((request, response) => { - const url = new URL(request.url!, `http://localhost:${port}`); - - if (url.pathname === "/oauth-callback") { - response.writeHead(200, { "Content-Type": "text/html" }); - response.end("

Authentication complete

"); - resolveCallback(url); - server.close(); +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" } - }); - - await new Promise((resolve, reject) => { - server.listen(port, "127.0.0.1", resolve); - server.once("error", reject); - }); - - return { - waitForCallback: () => callbackPromise, - close: () => new Promise((resolve) => server.close(resolve)), - }; + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // Implement chat completion with streaming +} +``` + +#### 3. Register in the Factory + +Add your provider to the protocol switch in `pkg/providers/factory.go`: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. Add Default Config (Optional) + +Add a default entry in `pkg/config/defaults.go`: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. Add Auth Support (Optional) + +If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. Configure via `config.json` + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] } ``` @@ -892,33 +703,27 @@ async function startCallbackServer(params: { timeoutMs: number }) { ### CLI Commands ```bash -# Enable the plugin -openclaw plugins enable your-provider-auth +# Authenticate with a provider +picoclaw auth login --provider your-provider -# Restart gateway -openclaw gateway restart +# List models (for Antigravity) +picoclaw auth models -# Authenticate -openclaw models auth login --provider your-provider --set-default +# Start the gateway +picoclaw gateway -# List models -openclaw models list - -# Set model -openclaw models set your-provider/model-name - -# Check usage -openclaw models usage +# Run an agent with a specific model +picoclaw agent -m "Hello" --model your-model ``` ### Environment Variables for Testing ```bash -# Test specific providers only -export OPENCLAW_LIVE_PROVIDERS="your-provider,google-antigravity" +# Override default model +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model -# Test with specific models -export OPENCLAW_LIVE_GATEWAY_MODELS="your-provider/model-name" +# Override provider settings +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' ``` --- @@ -926,16 +731,16 @@ export OPENCLAW_LIVE_GATEWAY_MODELS="your-provider/model-name" ## References - **Source Files:** - - `extensions/google-antigravity-auth/index.ts` - Full OAuth implementation - - `src/infra/provider-usage.fetch.antigravity.ts` - Usage fetching - - `src/agents/pi-embedded-runner/google.ts` - Model sanitization - - `src/agents/model-forward-compat.ts` - Forward compatibility - - `src/plugin-sdk/provider-auth-result.ts` - Auth result builder - - `src/plugins/types.ts` - Plugin type definitions + - `pkg/providers/antigravity_provider.go` - Antigravity provider implementation + - `pkg/auth/oauth.go` - OAuth flow implementation + - `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - Provider factory and protocol routing + - `pkg/providers/types.go` - Provider interface definitions + - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands - **Documentation:** - - `docs/concepts/model-providers.md` - Provider overview - - `docs/concepts/usage-tracking.md` - Usage tracking + - `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide + - `docs/migration/model-list-migration.md` - Migration guide --- @@ -987,7 +792,7 @@ Some models might show up in the available models list but return an empty respo ## Troubleshooting ### "Token expired" -- Refresh OAuth tokens: `openclaw models auth login --provider google-antigravity` +- Refresh OAuth tokens: `picoclaw auth login --provider antigravity` ### "Gemini for Google Cloud is not enabled" - Enable the API in your Google Cloud Console @@ -998,5 +803,5 @@ Some models might show up in the available models list but return an empty respo ### Models not appearing in list - Verify OAuth authentication completed successfully -- Check auth profile storage: `~/.openclaw/agent/auth-profiles.json` -- Ensure the plugin is enabled: `openclaw plugins list` +- Check auth profile storage: `~/.picoclaw/auth.json` +- Re-run `picoclaw auth login --provider antigravity` diff --git a/docs/ANTIGRAVITY_USAGE.md b/docs/ANTIGRAVITY_USAGE.md index 8bf1fdfdb..e8194b6bc 100644 --- a/docs/ANTIGRAVITY_USAGE.md +++ b/docs/ANTIGRAVITY_USAGE.md @@ -47,14 +47,12 @@ picoclaw agent -m "Hello" --model claude-opus-4-6-thinking If you are deploying via Coolify or Docker, follow these steps to test: -1. **Branch**: Use the `feat/antigravity-provider` branch. -2. **Environment Variables**: - * `PICOCLAW_AGENTS_DEFAULTS_PROVIDER=antigravity` - * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-3-flash` -3. **Authentication persistence**: +1. **Environment Variables**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **Authentication persistence**: If you've logged in locally, you can copy your credentials to the server: ```bash - scp ~/.picoclaw/auth-profiles.json user@your-server:~/.picoclaw/ + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ ``` *Alternatively*, run the `auth login` command once on the server if you have terminal access. diff --git a/docs/design/provider-refactoring-tests.md b/docs/design/provider-refactoring-tests.md index fc6429278..060be9ba8 100644 --- a/docs/design/provider-refactoring-tests.md +++ b/docs/design/provider-refactoring-tests.md @@ -1,7 +1,5 @@ # Provider Architecture Refactoring - Test Suite Summary -> PRD: `tasks/prd-provider-refactoring.md` - This document summarizes the complete test suite designed for the Provider architecture refactoring. ## Test File Structure @@ -12,10 +10,8 @@ pkg/ │ ā”œā”€ā”€ model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests │ └── migration_test.go # US-003: Backward compatibility and migration tests ā”œā”€ā”€ providers/ -│ ā”œā”€ā”€ registry_test.go # US-006: Load balancing tests -│ ā”œā”€ā”€ integration_test.go # E2E integration tests -│ └── factory/ -│ └── factory_test.go # US-004, US-005: Provider factory tests +│ ā”œā”€ā”€ factory_test.go # US-004, US-005: Provider factory tests +│ └── factory_provider_test.go # Factory provider integration tests ``` --- @@ -122,7 +118,6 @@ go test ./pkg/... -race # Run specific package tests go test ./pkg/config -v go test ./pkg/providers -v -go test ./pkg/providers/factory -v # Run E2E tests go test ./pkg/providers -run TestE2E -v diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index 0682bae1a..589dfc043 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -85,6 +85,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | `openai/` | OpenAI API (default) | `openai/gpt-5.2` | | `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | | `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | +| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` | | `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` | | `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` | | `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` | @@ -93,6 +94,13 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` | | `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` | | `qwen/` | Alibaba Qwen | `qwen/qwen-max` | +| `zhipu/` | Zhipu AI | `zhipu/glm-4` | +| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` | +| `ollama/` | Ollama (local) | `ollama/llama3` | +| `vllm/` | vLLM (local) | `vllm/my-model` | +| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` | +| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` | +| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` | **Note**: If no prefix is specified, `openai/` is used as the default. diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md index cfcc30f17..95de768c6 100644 --- a/docs/picoclaw_community_roadmap_260216.md +++ b/docs/picoclaw_community_roadmap_260216.md @@ -71,14 +71,14 @@ Interested in a specific feature? You can "claim" these tasks and start building * Support for OneBot, additional platforms * attachments (images, audio, video, files). * **Skills:** - * Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms. + * Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms. * **Operations:** * MCP Support. * Android operations (e.g., botdrop). * Browser automation via CDP or ActionBook. * **Multi-Agent Ecosystem:** - * **Basic Model-Agnet** S + * **Basic Model-Agent** * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens). * **Swarm Mode.** * **AIEOS Integration.** diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index 8777ddbd6..8aba1aa91 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -9,8 +9,8 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. "tools": { "web": { ... }, "exec": { ... }, - "approval": { ... }, - "cron": { ... } + "cron": { ... }, + "skills": { ... } } } ``` @@ -83,25 +83,12 @@ By default, PicoClaw blocks the following dangerous commands: "custom_deny_patterns": [ "\\brm\\s+-r\\b", "\\bkillall\\s+python" - ], + ] } } } ``` -## Approval Tool - -The approval tool controls permissions for dangerous operations. - -| Config | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | bool | true | Enable approval functionality | -| `write_file` | bool | true | Require approval for file writes | -| `edit_file` | bool | true | Require approval for file edits | -| `append_file` | bool | true | Require approval for file appends | -| `exec` | bool | true | Require approval for command execution | -| `timeout_minutes` | int | 5 | Approval timeout in minutes | - ## Cron Tool The cron tool is used for scheduling periodic tasks. @@ -110,6 +97,40 @@ The cron tool is used for scheduling periodic tasks. |--------|------|---------|-------------| | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | +## Skills Tool + +The skills tool configures skill discovery and installation via registries like ClawHub. + +### Registries + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | + +### Configuration Example + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + ## Environment Variables All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: From 0675ce7c38ba4139bfce94c47e19275dbaed4017 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Fri, 20 Feb 2026 20:03:11 +0200 Subject: [PATCH 09/21] feat(fmt): Fix formatting --- cmd/picoclaw/cmd_agent.go | 10 +- cmd/picoclaw/cmd_gateway.go | 26 +++- cmd/picoclaw/cmd_onboard.go | 6 +- cmd/picoclaw/cmd_skills.go | 4 +- pkg/agent/context.go | 43 ++++-- pkg/agent/loop.go | 127 +++++++++++------ pkg/auth/oauth.go | 13 +- pkg/channels/dingtalk.go | 13 +- pkg/channels/feishu_64.go | 6 +- pkg/channels/line.go | 36 ++--- pkg/channels/manager.go | 44 +++--- pkg/channels/wecom.go | 25 ++-- pkg/channels/wecom_app.go | 38 ++--- pkg/channels/wecom_app_test.go | 42 +++++- pkg/channels/wecom_test.go | 48 +++++-- pkg/config/config.go | 168 +++++++++++----------- pkg/config/migration_test.go | 16 ++- pkg/migrate/migrate_test.go | 172 +++++++++++------------ pkg/providers/anthropic/provider_test.go | 44 +++--- pkg/providers/antigravity_provider.go | 118 ++++++++++------ pkg/providers/claude_provider_test.go | 11 +- pkg/providers/http_provider.go | 8 +- pkg/providers/openai_compat/provider.go | 39 +++-- pkg/providers/protocoltypes/types.go | 20 +-- pkg/providers/toolcall_utils.go | 4 +- pkg/providers/types.go | 28 ++-- pkg/skills/clawhub_registry.go | 5 +- pkg/skills/clawhub_registry_test.go | 7 +- pkg/skills/registry_test.go | 3 +- pkg/tools/shell_timeout_unix_test.go | 2 +- pkg/tools/skills_install.go | 30 ++-- pkg/tools/skills_install_test.go | 19 +-- pkg/tools/skills_search.go | 12 +- pkg/tools/skills_search_test.go | 20 ++- pkg/utils/download.go | 6 +- pkg/utils/zip.go | 13 +- 36 files changed, 731 insertions(+), 495 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index cee9f68ec..6d6ff935f 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/chzyer/readline" + "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" @@ -74,10 +75,10 @@ func agentCmd() { // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ - "tools_count": startupInfo["tools"].(map[string]interface{})["count"], - "skills_total": startupInfo["skills"].(map[string]interface{})["total"], - "skills_available": startupInfo["skills"].(map[string]interface{})["available"], + map[string]any{ + "tools_count": startupInfo["tools"].(map[string]any)["count"], + "skills_total": startupInfo["skills"].(map[string]any)["total"], + "skills_available": startupInfo["skills"].(map[string]any)["available"], }) if message != "" { @@ -104,7 +105,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { InterruptPrompt: "^C", EOFPrompt: "exit", }) - if err != nil { fmt.Printf("Error initializing readline: %v\n", err) fmt.Println("Falling back to simple input mode...") diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 1f1bf5491..00ec0f96d 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -60,8 +60,8 @@ func gatewayCmd() { // Print agent startup info fmt.Println("\nšŸ“¦ Agent Status:") startupInfo := agentLoop.GetStartupInfo() - toolsInfo := startupInfo["tools"].(map[string]interface{}) - skillsInfo := startupInfo["skills"].(map[string]interface{}) + toolsInfo := startupInfo["tools"].(map[string]any) + skillsInfo := startupInfo["skills"].(map[string]any) fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], @@ -69,7 +69,7 @@ func gatewayCmd() { // Log to file as well logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ + map[string]any{ "tools_count": toolsInfo["count"], "skills_total": skillsInfo["total"], "skills_available": skillsInfo["available"], @@ -77,7 +77,14 @@ func gatewayCmd() { // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg) + cronService := setupCronTool( + agentLoop, + msgBus, + cfg.WorkspacePath(), + cfg.Agents.Defaults.RestrictToWorkspace, + execTimeout, + cfg, + ) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -181,7 +188,7 @@ func gatewayCmd() { healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) go func() { if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()}) + logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) } }() fmt.Printf("āœ“ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) @@ -203,7 +210,14 @@ func gatewayCmd() { fmt.Println("āœ“ Gateway stopped") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *config.Config) *cron.CronService { +func setupCronTool( + agentLoop *agent.AgentLoop, + msgBus *bus.MessageBus, + workspace string, + restrict bool, + execTimeout time.Duration, + cfg *config.Config, +) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/cmd_onboard.go index 6e61e3267..1a9ebad61 100644 --- a/cmd/picoclaw/cmd_onboard.go +++ b/cmd/picoclaw/cmd_onboard.go @@ -55,7 +55,7 @@ func onboard() { func copyEmbeddedToTarget(targetDir string) error { // Ensure target directory exists - if err := os.MkdirAll(targetDir, 0755); err != nil { + if err := os.MkdirAll(targetDir, 0o755); err != nil { return fmt.Errorf("Failed to create target directory: %w", err) } @@ -85,12 +85,12 @@ func copyEmbeddedToTarget(targetDir string) error { targetPath := filepath.Join(targetDir, new_path) // Ensure target file's directory exists - if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) } // Write file - if err := os.WriteFile(targetPath, data, 0644); err != nil { + if err := os.WriteFile(targetPath, data, 0o644); err != nil { return fmt.Errorf("Failed to write file %s: %w", targetPath, err) } diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go index 32b7c62b8..2dd46756a 100644 --- a/cmd/picoclaw/cmd_skills.go +++ b/cmd/picoclaw/cmd_skills.go @@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil { + if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil { fmt.Printf("\u2717 Failed to create skills directory: %v\n", err) os.Exit(1) } @@ -193,7 +193,7 @@ func skillsInstallBuiltinCmd(workspace string) { continue } - if err := os.MkdirAll(workspacePath, 0755); err != nil { + if err := os.MkdirAll(workspacePath, 0o755); err != nil { fmt.Printf("āœ— Failed to create directory for %s: %v\n", skillName, err) continue } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 78f5f1ffa..e989ffaaf 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string { var sb strings.Builder sb.WriteString("## Available Tools\n\n") - sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n") + sb.WriteString( + "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n", + ) sb.WriteString("You have access to the following tools:\n\n") for _, s := range summaries { sb.WriteString(s) @@ -157,7 +159,13 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { return sb.String() } -func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message { +func (cb *ContextBuilder) BuildMessages( + history []providers.Message, + summary string, + currentMessage string, + media []string, + channel, chatID string, +) []providers.Message { messages := []providers.Message{} systemPrompt := cb.BuildSystemPrompt() @@ -169,7 +177,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str // Log system prompt summary for debugging (debug mode only) logger.DebugCF("agent", "System prompt built", - map[string]interface{}{ + map[string]any{ "total_chars": len(systemPrompt), "total_lines": strings.Count(systemPrompt, "\n") + 1, "section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1, @@ -181,7 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str preview = preview[:500] + "... (truncated)" } logger.DebugCF("agent", "System prompt preview", - map[string]interface{}{ + map[string]any{ "preview": preview, }) @@ -218,12 +226,12 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message switch msg.Role { case "tool": if len(sanitized) == 0 { - logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{}) + logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{}) continue } last := sanitized[len(sanitized)-1] if last.Role != "assistant" || len(last.ToolCalls) == 0 { - logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{}) + logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{}) continue } sanitized = append(sanitized, msg) @@ -231,12 +239,16 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message case "assistant": if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { - logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]interface{}{}) + logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) continue } prev := sanitized[len(sanitized)-1] if prev.Role != "user" && prev.Role != "tool" { - logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]interface{}{"prev_role": prev.Role}) + logger.DebugCF( + "agent", + "Dropping assistant tool-call turn with invalid predecessor", + map[string]any{"prev_role": prev.Role}, + ) continue } } @@ -250,7 +262,10 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message return sanitized } -func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message { +func (cb *ContextBuilder) AddToolResult( + messages []providers.Message, + toolCallID, toolName, result string, +) []providers.Message { messages = append(messages, providers.Message{ Role: "tool", Content: result, @@ -259,7 +274,11 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID return messages } -func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message { +func (cb *ContextBuilder) AddAssistantMessage( + messages []providers.Message, + content string, + toolCalls []map[string]any, +) []providers.Message { msg := providers.Message{ Role: "assistant", Content: content, @@ -289,13 +308,13 @@ func (cb *ContextBuilder) loadSkills() string { } // GetSkillsInfo returns information about loaded skills. -func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { +func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() skillNames := make([]string, 0, len(allSkills)) for _, s := range allSkills { skillNames = append(skillNames, s.Name) } - return map[string]interface{}{ + return map[string]any{ "total": len(allSkills), "available": len(allSkills), "names": skillNames, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index add183aaf..b36f4a0c4 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -80,7 +80,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). -func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) { +func registerSharedTools( + cfg *config.Config, + msgBus *bus.MessageBus, + registry *AgentRegistry, + provider providers.LLMProvider, +) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) if !ok { @@ -123,7 +128,10 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), }) - searchCache := skills.NewSearchCache(cfg.Tools.Skills.SearchCache.MaxSize, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second) + searchCache := skills.NewSearchCache( + cfg.Tools.Skills.SearchCache.MaxSize, + time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, + ) agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) @@ -226,7 +234,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") } -func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) { +func (al *AgentLoop) ProcessDirectWithChannel( + ctx context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { msg := bus.InboundMessage{ Channel: channel, SenderID: "cron", @@ -263,7 +274,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) logContent = utils.Truncate(msg.Content, 80) } logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), - map[string]interface{}{ + map[string]any{ "channel": msg.Channel, "chat_id": msg.ChatID, "sender_id": msg.SenderID, @@ -302,7 +313,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } logger.InfoCF("agent", "Routed message", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "session_key": sessionKey, "matched_by": route.MatchedBy, @@ -325,7 +336,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe } logger.InfoCF("agent", "Processing system message", - map[string]interface{}{ + map[string]any{ "sender_id": msg.SenderID, "chat_id": msg.ChatID, }) @@ -350,7 +361,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe // Skip internal channels - only log, don't send to user if constants.IsInternalChannel(originChannel) { logger.InfoCF("agent", "Subagent completed (internal channel)", - map[string]interface{}{ + map[string]any{ "sender_id": msg.SenderID, "content_len": len(content), "channel": originChannel, @@ -383,7 +394,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()}) + logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) } } } @@ -445,7 +456,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 9. Log response responsePreview := utils.Truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "session_key": opts.SessionKey, "iterations": iteration, @@ -456,7 +467,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // runLLMIteration executes the LLM call loop with tool handling. -func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) { +func (al *AgentLoop) runLLMIteration( + ctx context.Context, + agent *AgentInstance, + messages []providers.Message, + opts processOptions, +) (string, int, error) { iteration := 0 var finalContent string @@ -464,7 +480,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, iteration++ logger.DebugCF("agent", "LLM iteration", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "max": agent.MaxIterations, @@ -475,7 +491,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // Log LLM request details logger.DebugCF("agent", "LLM request", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "model": agent.Model, @@ -488,7 +504,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // Log full messages (detailed) logger.DebugCF("agent", "Full LLM request", - map[string]interface{}{ + map[string]any{ "iteration": iteration, "messages_json": formatMessagesForLog(messages), "tools_json": formatToolsForLog(providerToolDefs), @@ -502,7 +518,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if len(agent.Candidates) > 1 && al.fallback != nil { fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{ + return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, }) @@ -514,11 +530,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]interface{}{"agent_id": agent.ID, "iteration": iteration}) + map[string]any{"agent_id": agent.ID, "iteration": iteration}) } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{ + return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, }) @@ -539,7 +555,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, strings.Contains(errMsg, "length") if isContextError && retry < maxRetries { - logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{ + logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ "error": err.Error(), "retry": retry, }) @@ -566,7 +582,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if err != nil { logger.ErrorCF("agent", "LLM call failed", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "error": err.Error(), @@ -578,7 +594,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, if len(response.ToolCalls) == 0 { finalContent = response.Content logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "iteration": iteration, "content_chars": len(finalContent), @@ -597,7 +613,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, toolNames = append(toolNames, tc.Name) } logger.InfoCF("agent", "LLM requested tool calls", - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "tools": toolNames, "count": len(normalizedToolCalls), @@ -641,7 +657,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, argsJSON, _ := json.Marshal(tc.Arguments) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - map[string]interface{}{ + map[string]any{ "agent_id": agent.ID, "tool": tc.Name, "iteration": iteration, @@ -656,14 +672,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, // The agent will handle user notification via processSystemMessage if !result.Silent && result.ForUser != "" { logger.InfoCF("agent", "Async tool completed, agent will handle notification", - map[string]interface{}{ + map[string]any{ "tool": tc.Name, "content_len": len(result.ForUser), }) } } - toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) + toolResult := agent.Tools.ExecuteWithContext( + ctx, + tc.Name, + tc.Arguments, + opts.Channel, + opts.ChatID, + asyncCallback, + ) // Send ForUser content to user immediately if not Silent if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { @@ -673,7 +696,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, Content: toolResult.ForUser, }) logger.DebugCF("agent", "Sent tool result to user", - map[string]interface{}{ + map[string]any{ "tool": tc.Name, "content_len": len(toolResult.ForUser), }) @@ -775,7 +798,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { // Append compression note to the original system prompt instead of adding a new system message // This avoids having two consecutive system messages which some APIs (like Zhipu) reject - compressionNote := fmt.Sprintf("\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", droppedCount) + compressionNote := fmt.Sprintf( + "\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", + droppedCount, + ) enhancedSystemPrompt := history[0] enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote newHistory = append(newHistory, enhancedSystemPrompt) @@ -787,7 +813,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { agent.Sessions.SetHistory(sessionKey, newHistory) agent.Sessions.Save(sessionKey) - logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{ + logger.WarnCF("agent", "Forced compression executed", map[string]any{ "session_key": sessionKey, "dropped_msgs": droppedCount, "new_count": len(newHistory), @@ -795,8 +821,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { } // GetStartupInfo returns information about loaded tools and skills for logging. -func (al *AgentLoop) GetStartupInfo() map[string]interface{} { - info := make(map[string]interface{}) +func (al *AgentLoop) GetStartupInfo() map[string]any { + info := make(map[string]any) agent := al.registry.GetDefaultAgent() if agent == nil { @@ -805,7 +831,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { // Tools info toolsList := agent.Tools.List() - info["tools"] = map[string]interface{}{ + info["tools"] = map[string]any{ "count": len(toolsList), "names": toolsList, } @@ -814,7 +840,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { info["skills"] = agent.ContextBuilder.GetSkillsInfo() // Agents info - info["agents"] = map[string]interface{}{ + info["agents"] = map[string]any{ "count": len(al.registry.ListAgentIDs()), "ids": al.registry.ListAgentIDs(), } @@ -919,11 +945,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { s1, _ := al.summarizeBatch(ctx, agent, part1, "") s2, _ := al.summarizeBatch(ctx, agent, part2, "") - mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2) - resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) + mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, + s2, + ) + resp, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: mergePrompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + }, + ) if err == nil { finalSummary = resp.Content } else { @@ -945,7 +981,12 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { } // summarizeBatch summarizes a batch of messages. -func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) { +func (al *AgentLoop) summarizeBatch( + ctx context.Context, + agent *AgentInstance, + batch []providers.Message, + existingSummary string, +) (string, error) { var sb strings.Builder sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") if existingSummary != "" { @@ -959,10 +1000,16 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, b } prompt := sb.String() - response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) + response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + }, + ) if err != nil { return "", err } diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 496a4674c..cf8c1c9c4 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -44,7 +44,9 @@ func OpenAIOAuthConfig() OAuthProviderConfig { // Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access. func GoogleAntigravityOAuthConfig() OAuthProviderConfig { // These are the same client credentials used by the OpenCode antigravity plugin. - clientID := decodeBase64("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==") + clientID := decodeBase64( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", + ) clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=") return OAuthProviderConfig{ Issuer: "https://accounts.google.com/o/oauth2/v2", @@ -129,8 +131,13 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } - fmt.Printf("Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port) - fmt.Println("please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.") + fmt.Printf( + "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", + cfg.Port, + ) + fmt.Println( + "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", + ) fmt.Println("Waiting for authentication (browser or manual paste)...") // Start manual input in a goroutine diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go index 79cc85219..662fba3b7 100644 --- a/pkg/channels/dingtalk.go +++ b/pkg/channels/dingtalk.go @@ -10,6 +10,7 @@ import ( "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } - logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{ + logger.DebugCF("dingtalk", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) @@ -120,12 +121,15 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // onChatBotMessageReceived implements the IChatBotMessageHandler function signature // This is called by the Stream SDK when a new message arrives // IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) -func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { +func (c *DingTalkChannel) onChatBotMessageReceived( + ctx context.Context, + data *chatbot.BotCallbackDataModel, +) ([]byte, error) { // Extract message content from Text field content := data.Text.Content if content == "" { // Try to extract from Content interface{} if Text is empty - if contentMap, ok := data.Content.(map[string]interface{}); ok { + if contentMap, ok := data.Content.(map[string]any); ok { if textContent, ok := contentMap["content"].(string); ok { content = textContent } @@ -163,7 +167,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch metadata["peer_id"] = data.ConversationId } - logger.DebugCF("dingtalk", "Received message", map[string]interface{}{ + logger.DebugCF("dingtalk", "Received message", map[string]any{ "sender_nick": senderNick, "sender_id": senderID, "preview": utils.Truncate(content, 50), @@ -192,7 +196,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c titleBytes, contentBytes, ) - if err != nil { return fmt.Errorf("failed to send reply: %w", err) } diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go index 9e15fa3a7..42e74980f 100644 --- a/pkg/channels/feishu_64.go +++ b/pkg/channels/feishu_64.go @@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { go func() { if err := wsClient.Start(runCtx); err != nil { - logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{ + logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{ "error": err.Error(), }) } @@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) } - logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{ + logger.DebugCF("feishu", "Feishu message sent", map[string]any{ "chat_id": msg.ChatID, }) @@ -174,7 +174,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 metadata["peer_id"] = chatID } - logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{ + logger.InfoCF("feishu", "Feishu message received", map[string]any{ "sender_id": senderID, "chat_id": chatID, "preview": utils.Truncate(content, 80), diff --git a/pkg/channels/line.go b/pkg/channels/line.go index 9f7d2bde0..44134996f 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error { // Fetch bot profile to get bot's userId for mention detection if err := c.fetchBotInfo(); err != nil { - logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{ + logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ "error": err.Error(), }) } else { - logger.InfoCF("line", "Bot info fetched", map[string]interface{}{ + logger.InfoCF("line", "Bot info fetched", map[string]any{ "bot_user_id": c.botUserID, "basic_id": c.botBasicID, "display_name": c.botDisplayName, @@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error { } go func() { - logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{ + logger.InfoCF("line", "LINE webhook server listening", map[string]any{ "addr": addr, "path": path, }) if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("line", "Webhook server error", map[string]interface{}{ + logger.ErrorCF("line", "Webhook server error", map[string]any{ "error": err.Error(), }) } @@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error { shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := c.httpServer.Shutdown(shutdownCtx); err != nil { - logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{ + logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{ "error": err.Error(), }) } @@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{ + logger.ErrorCF("line", "Failed to read request body", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) @@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { Events []lineEvent `json:"events"` } if err := json.Unmarshal(body, &payload); err != nil { - logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{ + logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) @@ -266,7 +266,7 @@ type lineMentionee struct { func (c *LINEChannel) processEvent(event lineEvent) { if event.Type != "message" { - logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{ + logger.DebugCF("line", "Ignoring non-message event", map[string]any{ "type": event.Type, }) return @@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { var msg lineMessage if err := json.Unmarshal(event.Message, &msg); err != nil { - logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{ + logger.ErrorCF("line", "Failed to parse message", map[string]any{ "error": err.Error(), }) return @@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { // In group chats, only respond when the bot is mentioned if isGroup && !c.isBotMentioned(msg) { - logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{ + logger.DebugCF("line", "Ignoring group message without mention", map[string]any{ "chat_id": chatID, }) return @@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{ + logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{ "file": file, "error": err.Error(), }) @@ -374,7 +374,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { metadata["peer_id"] = senderID } - logger.DebugCF("line", "Received message", map[string]interface{}{ + logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "message_type": msg.Type, @@ -505,7 +505,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { - logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{ + logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, "quoted": quoteToken != "", }) @@ -533,7 +533,7 @@ func buildTextMessage(content, quoteToken string) map[string]string { // sendReply sends a message using the LINE Reply API. func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "replyToken": replyToken, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } @@ -543,7 +543,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT // sendPush sends a message using the LINE Push API. func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "to": to, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } @@ -553,19 +553,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(chatID string) { - payload := map[string]interface{}{ + payload := map[string]any{ "chatId": chatID, "loadingSeconds": 60, } if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { - logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{ + logger.DebugCF("line", "Failed to send loading indicator", map[string]any{ "error": err.Error(), }) } } // callAPI makes an authenticated POST request to the LINE API. -func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error { +func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal payload: %w", err) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index b80d1c8fb..75edaf49e 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -50,7 +50,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Telegram channel") telegram, err := NewTelegramChannel(m.config, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{ "error": err.Error(), }) } else { @@ -63,7 +63,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize WhatsApp channel") whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{ "error": err.Error(), }) } else { @@ -76,7 +76,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Feishu channel") feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{ "error": err.Error(), }) } else { @@ -89,7 +89,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Discord channel") discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{ "error": err.Error(), }) } else { @@ -102,7 +102,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize MaixCam channel") maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{ "error": err.Error(), }) } else { @@ -115,7 +115,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize QQ channel") qq, err := NewQQChannel(m.config.Channels.QQ, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{ "error": err.Error(), }) } else { @@ -128,7 +128,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize DingTalk channel") dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{ "error": err.Error(), }) } else { @@ -141,7 +141,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize Slack channel") slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{ "error": err.Error(), }) } else { @@ -154,7 +154,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize LINE channel") line, err := NewLINEChannel(m.config.Channels.LINE, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{ "error": err.Error(), }) } else { @@ -167,7 +167,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize OneBot channel") onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{ "error": err.Error(), }) } else { @@ -180,7 +180,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize WeCom channel") wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{ "error": err.Error(), }) } else { @@ -193,7 +193,7 @@ func (m *Manager) initChannels() error { logger.DebugC("channels", "Attempting to initialize WeCom App channel") wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{ "error": err.Error(), }) } else { @@ -202,7 +202,7 @@ func (m *Manager) initChannels() error { } } - logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{ + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) @@ -226,11 +226,11 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.dispatchOutbound(dispatchCtx) for name, channel := range m.channels { - logger.InfoCF("channels", "Starting channel", map[string]interface{}{ + logger.InfoCF("channels", "Starting channel", map[string]any{ "channel": name, }) if err := channel.Start(ctx); err != nil { - logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to start channel", map[string]any{ "channel": name, "error": err.Error(), }) @@ -253,11 +253,11 @@ func (m *Manager) StopAll(ctx context.Context) error { } for name, channel := range m.channels { - logger.InfoCF("channels", "Stopping channel", map[string]interface{}{ + logger.InfoCF("channels", "Stopping channel", map[string]any{ "channel": name, }) if err := channel.Stop(ctx); err != nil { - logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{ + logger.ErrorCF("channels", "Error stopping channel", map[string]any{ "channel": name, "error": err.Error(), }) @@ -292,14 +292,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { m.mu.RUnlock() if !exists { - logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{ + logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ "channel": msg.Channel, }) continue } if err := channel.Send(ctx, msg); err != nil { - logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{ + logger.ErrorCF("channels", "Error sending message to channel", map[string]any{ "channel": msg.Channel, "error": err.Error(), }) @@ -315,13 +315,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) { return channel, ok } -func (m *Manager) GetStatus() map[string]interface{} { +func (m *Manager) GetStatus() map[string]any { m.mu.RLock() defer m.mu.RUnlock() - status := make(map[string]interface{}) + status := make(map[string]any) for name, channel := range m.channels { - status[name] = map[string]interface{}{ + status[name] = map[string]any{ "enabled": true, "running": channel.IsRunning(), } diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go index 064568243..07bd8488c 100644 --- a/pkg/channels/wecom.go +++ b/pkg/channels/wecom.go @@ -134,7 +134,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error { } c.setRunning(true) - logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{ + logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{ "address": addr, "path": webhookPath, }) @@ -142,7 +142,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error { // Start server in goroutine go func() { if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{ + logger.ErrorCF("wecom", "HTTP server error", map[string]any{ "error": err.Error(), }) } @@ -178,7 +178,7 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("wecom channel not running") } - logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{ + logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) @@ -230,7 +230,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons // Reference: https://developer.work.weixin.qq.com/document/path/101033 decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{ + logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) @@ -273,7 +273,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp } if err := xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{ + logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid XML", http.StatusBadRequest) @@ -292,7 +292,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // Reference: https://developer.work.weixin.qq.com/document/path/101033 decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{ + logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) @@ -302,7 +302,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // Parse decrypted JSON message (AIBOT uses JSON format) var msg WeComBotMessage if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{ + logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid message format", http.StatusBadRequest) @@ -320,8 +320,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // processMessage processes the received message func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) { // Skip unsupported message types - if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" { - logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{ + if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && + msg.MsgType != "mixed" { + logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{ "msg_type": msg.MsgType, }) return @@ -332,7 +333,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag c.msgMu.Lock() if c.processedMsgs[msgID] { c.msgMu.Unlock() - logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{ + logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return @@ -399,7 +400,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag metadata["sender_id"] = senderID } - logger.DebugCF("wecom", "Received message", map[string]interface{}{ + logger.DebugCF("wecom", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "peer_kind": peerKind, @@ -468,7 +469,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content // handleHealth handles health check requests func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]interface{}{ + status := map[string]any{ "status": "ok", "running": c.IsRunning(), } diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go index 63a1dd815..878504106 100644 --- a/pkg/channels/wecom_app.go +++ b/pkg/channels/wecom_app.go @@ -145,7 +145,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { // Get initial access token if err := c.refreshAccessToken(); err != nil { - logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{ + logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{ "error": err.Error(), }) } @@ -171,7 +171,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { } c.setRunning(true) - logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{ + logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{ "address": addr, "path": webhookPath, }) @@ -179,7 +179,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { // Start server in goroutine go func() { if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{ + logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{ "error": err.Error(), }) } @@ -218,7 +218,7 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("no valid access token available") } - logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{ + logger.DebugCF("wecom_app", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) @@ -231,7 +231,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) ctx := r.Context() // Log all incoming requests for debugging - logger.DebugCF("wecom_app", "Received webhook request", map[string]interface{}{ + logger.DebugCF("wecom_app", "Received webhook request", map[string]any{ "method": r.Method, "url": r.URL.String(), "path": r.URL.Path, @@ -250,7 +250,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) return } - logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{ + logger.WarnCF("wecom_app", "Method not allowed", map[string]any{ "method": r.Method, }) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -264,7 +264,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons nonce := query.Get("nonce") echostr := query.Get("echostr") - logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{ + logger.DebugCF("wecom_app", "Handling verification request", map[string]any{ "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, @@ -280,7 +280,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons // Verify signature if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { - logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{ + logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ "token": c.config.Token, "msg_signature": msgSignature, "timestamp": timestamp, @@ -294,13 +294,13 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons // Decrypt echostr with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id - logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]interface{}{ + logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{ "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, }) decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, @@ -309,7 +309,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons return } - logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{ + logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{ "decrypted": decryptedEchoStr, }) @@ -349,7 +349,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp } if err := xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid XML", http.StatusBadRequest) @@ -367,7 +367,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp // For WeCom App (自建应用), receiveid should be corp_id decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) @@ -377,7 +377,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp // Parse decrypted XML message var msg WeComXMLMessage if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid message format", http.StatusBadRequest) @@ -396,7 +396,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) { // Skip non-text messages for now (can be extended) if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { - logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]interface{}{ + logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{ "msg_type": msg.MsgType, }) return @@ -408,7 +408,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag c.msgMu.Lock() if c.processedMsgs[msgID] { c.msgMu.Unlock() - logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{ + logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return @@ -441,7 +441,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag content := msg.Content - logger.DebugCF("wecom_app", "Received message", map[string]interface{}{ + logger.DebugCF("wecom_app", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "preview": utils.Truncate(content, 50), @@ -462,7 +462,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() { return case <-ticker.C: if err := c.refreshAccessToken(); err != nil { - logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{ "error": err.Error(), }) } @@ -628,7 +628,7 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, // handleHealth handles health check requests func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]interface{}{ + status := map[string]any{ "status": "ok", "running": c.IsRunning(), "has_token": c.getAccessToken() != "", diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go index bc40806bb..6778520f3 100644 --- a/pkg/channels/wecom_app_test.go +++ b/pkg/channels/wecom_app_test.go @@ -399,7 +399,11 @@ func TestWeComAppHandleVerification(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -429,7 +433,11 @@ func TestWeComAppHandleVerification(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -481,7 +489,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -510,7 +522,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, "") - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml")) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + strings.NewReader("invalid xml"), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -532,7 +548,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -646,7 +666,11 @@ func TestWeComAppHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encoded) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, + nil, + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) @@ -669,7 +693,11 @@ func TestWeComAppHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go index c3f889c64..53cde2693 100644 --- a/pkg/channels/wecom_test.go +++ b/pkg/channels/wecom_test.go @@ -358,7 +358,11 @@ func TestWeComBotHandleVerification(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -388,7 +392,11 @@ func TestWeComBotHandleVerification(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -437,7 +445,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -479,7 +491,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -508,7 +524,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, "") - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml")) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + strings.NewReader("invalid xml"), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -530,7 +550,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -625,7 +649,11 @@ func TestWeComBotHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encoded) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, + nil, + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) @@ -648,7 +676,11 @@ func TestWeComBotHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) diff --git a/pkg/config/config.go b/pkg/config/config.go index 005631e4a..20556011a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -26,7 +26,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { } // Try []interface{} to handle mixed types - var raw []interface{} + var raw []any if err := json.Unmarshal(data, &raw); err != nil { return err } @@ -167,16 +167,16 @@ type SessionConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` 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"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { @@ -195,114 +195,114 @@ type ChannelsConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` } type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` } type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } type WeComConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` - WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` + WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` } type WeComAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` - CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` - CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` - AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` + CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` + CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` + AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` } type HeartbeatConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } type DevicesConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } @@ -359,11 +359,11 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) { } type ProviderConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` - ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` + APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` + APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` + AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` } type OpenAIProviderConfig struct { @@ -413,19 +413,19 @@ type GatewayConfig struct { } 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"` + 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"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` 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"` + 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"` } @@ -458,7 +458,7 @@ type SkillsToolsConfig struct { } type SearchCacheConfig struct { - MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` + MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` } @@ -467,14 +467,14 @@ type SkillsRegistriesConfig struct { } type ClawHubRegistryConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` - BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` - AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` - SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` - SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` - DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` - Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` - MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` + Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` + BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` + AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` + SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` + SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` + DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` + Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` + MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` } @@ -517,11 +517,11 @@ func SaveConfig(path string, cfg *Config) error { } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return err } - return os.WriteFile(path, data, 0600) + return os.WriteFile(path, data, 0o600) } func (c *Config) WorkspacePath() string { diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index b9a333f9e..1e8139e68 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -361,7 +361,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) { Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: tt.providerAlias, - Model: strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1]), + Model: strings.TrimPrefix( + tt.expectedModel, + tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], + ), }, }, Providers: ProvidersConfig{}, @@ -382,7 +385,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) { } // Need to fix the model name in config - cfg.Agents.Defaults.Model = strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1]) + cfg.Agents.Defaults.Model = strings.TrimPrefix( + tt.expectedModel, + tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], + ) result := ConvertProvidersToModelList(cfg) if len(result) != 1 { @@ -515,7 +521,11 @@ func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) { func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) { result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6") if result != "openrouter/claude-sonnet-4.6" { - t.Errorf("buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", result, "openrouter/claude-sonnet-4.6") + t.Errorf( + "buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", + result, + "openrouter/claude-sonnet-4.6", + ) } } diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index 759fc9024..ccc00f72c 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -40,20 +40,20 @@ func TestCamelToSnake(t *testing.T) { } func TestConvertKeysToSnake(t *testing.T) { - input := map[string]interface{}{ + input := map[string]any{ "apiKey": "test-key", "apiBase": "https://example.com", - "nested": map[string]interface{}{ + "nested": map[string]any{ "maxTokens": float64(8192), - "allowFrom": []interface{}{"user1", "user2"}, - "deeperLevel": map[string]interface{}{ + "allowFrom": []any{"user1", "user2"}, + "deeperLevel": map[string]any{ "clientId": "abc", }, }, } result := convertKeysToSnake(input) - m, ok := result.(map[string]interface{}) + m, ok := result.(map[string]any) if !ok { t.Fatal("expected map[string]interface{}") } @@ -65,7 +65,7 @@ func TestConvertKeysToSnake(t *testing.T) { t.Error("expected key 'api_base' after conversion") } - nested, ok := m["nested"].(map[string]interface{}) + nested, ok := m["nested"].(map[string]any) if !ok { t.Fatal("expected nested map") } @@ -76,7 +76,7 @@ func TestConvertKeysToSnake(t *testing.T) { t.Error("expected key 'allow_from' in nested map") } - deeper, ok := nested["deeper_level"].(map[string]interface{}) + deeper, ok := nested["deeper_level"].(map[string]any) if !ok { t.Fatal("expected deeper_level map") } @@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") - openclawConfig := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + openclawConfig := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-ant-test123", "apiBase": "https://api.anthropic.com", }, }, - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ + "agents": map[string]any{ + "defaults": map[string]any{ "maxTokens": float64(4096), "model": "claude-3-opus", }, @@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(configPath, data, 0644); err != nil { + if err := os.WriteFile(configPath, data, 0o644); err != nil { t.Fatal(err) } @@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) { t.Fatalf("LoadOpenClawConfig: %v", err) } - providers, ok := result["providers"].(map[string]interface{}) + providers, ok := result["providers"].(map[string]any) if !ok { t.Fatal("expected providers map") } - anthropic, ok := providers["anthropic"].(map[string]interface{}) + anthropic, ok := providers["anthropic"].(map[string]any) if !ok { t.Fatal("expected anthropic map") } @@ -129,11 +129,11 @@ func TestLoadOpenClawConfig(t *testing.T) { t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"]) } - agents, ok := result["agents"].(map[string]interface{}) + agents, ok := result["agents"].(map[string]any) if !ok { t.Fatal("expected agents map") } - defaults, ok := agents["defaults"].(map[string]interface{}) + defaults, ok := agents["defaults"].(map[string]any) if !ok { t.Fatal("expected defaults map") } @@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) { func TestConvertConfig(t *testing.T) { t.Run("providers mapping", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + data := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "api_key": "sk-ant-test", "api_base": "https://api.anthropic.com", }, - "openrouter": map[string]interface{}{ + "openrouter": map[string]any{ "api_key": "sk-or-test", }, - "groq": map[string]interface{}{ + "groq": map[string]any{ "api_key": "gsk-test", }, }, @@ -178,9 +178,9 @@ func TestConvertConfig(t *testing.T) { }) t.Run("unsupported provider warning", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "unknown_provider": map[string]interface{}{ + data := map[string]any{ + "providers": map[string]any{ + "unknown_provider": map[string]any{ "api_key": "sk-test", }, }, @@ -199,14 +199,14 @@ func TestConvertConfig(t *testing.T) { }) t.Run("channels mapping", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ + data := map[string]any{ + "channels": map[string]any{ + "telegram": map[string]any{ "enabled": true, "token": "tg-token-123", - "allow_from": []interface{}{"user1"}, + "allow_from": []any{"user1"}, }, - "discord": map[string]interface{}{ + "discord": map[string]any{ "enabled": true, "token": "disc-token-456", }, @@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) { }) t.Run("unsupported channel warning", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "email": map[string]interface{}{ + data := map[string]any{ + "channels": map[string]any{ + "email": map[string]any{ "enabled": true, }, }, @@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) { }) t.Run("agent defaults", func(t *testing.T) { - data := map[string]interface{}{ - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ + data := map[string]any{ + "agents": map[string]any{ + "defaults": map[string]any{ "model": "claude-3-opus", "max_tokens": float64(4096), "temperature": 0.5, @@ -287,7 +287,7 @@ func TestConvertConfig(t *testing.T) { }) t.Run("empty config", func(t *testing.T) { - data := map[string]interface{}{} + data := map[string]any{} cfg, warnings, err := ConvertConfig(data) if err != nil { @@ -389,9 +389,9 @@ func TestPlanWorkspaceMigration(t *testing.T) { srcDir := t.TempDir() dstDir := t.TempDir() - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644) + os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644) + os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644) + os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -420,8 +420,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { srcDir := t.TempDir() dstDir := t.TempDir() - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644) + os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644) + os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -443,8 +443,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { srcDir := t.TempDir() dstDir := t.TempDir() - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644) + os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644) + os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, true) if err != nil { @@ -463,8 +463,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { dstDir := t.TempDir() memDir := filepath.Join(srcDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644) + os.MkdirAll(memDir, 0o755) + os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -494,8 +494,8 @@ func TestPlanWorkspaceMigration(t *testing.T) { dstDir := t.TempDir() skillDir := filepath.Join(srcDir, "skills", "weather") - os.MkdirAll(skillDir, 0755) - os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644) + os.MkdirAll(skillDir, 0o755) + os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644) actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) if err != nil { @@ -518,7 +518,7 @@ func TestFindOpenClawConfig(t *testing.T) { t.Run("finds openclaw.json", func(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(configPath, []byte("{}"), 0644) + os.WriteFile(configPath, []byte("{}"), 0o644) found, err := findOpenClawConfig(tmpDir) if err != nil { @@ -532,7 +532,7 @@ func TestFindOpenClawConfig(t *testing.T) { t.Run("falls back to config.json", func(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") - os.WriteFile(configPath, []byte("{}"), 0644) + os.WriteFile(configPath, []byte("{}"), 0o644) found, err := findOpenClawConfig(tmpDir) if err != nil { @@ -546,8 +546,8 @@ func TestFindOpenClawConfig(t *testing.T) { t.Run("prefers openclaw.json over config.json", func(t *testing.T) { tmpDir := t.TempDir() openclawPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(openclawPath, []byte("{}"), 0644) - os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644) + os.WriteFile(openclawPath, []byte("{}"), 0o644) + os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644) found, err := findOpenClawConfig(tmpDir) if err != nil { @@ -593,19 +593,19 @@ func TestRunDryRun(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) + os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "test-key", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ DryRun: true, @@ -634,33 +634,33 @@ func TestRunFullMigration(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644) + os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644) + os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644) memDir := filepath.Join(wsDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644) + os.MkdirAll(memDir, 0o755) + os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-ant-migrate-test", }, - "openrouter": map[string]interface{}{ + "openrouter": map[string]any{ "apiKey": "sk-or-migrate-test", }, }, - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ + "channels": map[string]any{ + "telegram": map[string]any{ "enabled": true, "token": "tg-migrate-test", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ Force: true, @@ -754,7 +754,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) { func TestBackupFile(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "test.md") - os.WriteFile(filePath, []byte("original content"), 0644) + os.WriteFile(filePath, []byte("original content"), 0o644) if err := backupFile(filePath); err != nil { t.Fatalf("backupFile: %v", err) @@ -775,7 +775,7 @@ func TestCopyFile(t *testing.T) { srcPath := filepath.Join(tmpDir, "src.md") dstPath := filepath.Join(tmpDir, "dst.md") - os.WriteFile(srcPath, []byte("file content"), 0644) + os.WriteFile(srcPath, []byte("file content"), 0o644) if err := copyFile(srcPath, dstPath); err != nil { t.Fatalf("copyFile: %v", err) @@ -795,18 +795,18 @@ func TestRunConfigOnly(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-config-only", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ Force: true, @@ -835,18 +835,18 @@ func TestRunWorkspaceOnly(t *testing.T) { picoClawHome := t.TempDir() wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) + os.MkdirAll(wsDir, 0o755) + os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ + configData := map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ "apiKey": "sk-ws-only", }, }, } data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) + os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) opts := Options{ Force: true, diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 08ac9c829..3d21c1d0b 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -15,7 +15,7 @@ func TestBuildParams_BasicMessage(t *testing.T) { messages := []Message{ {Role: "user", Content: "Hello"}, } - params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{ + params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{ "max_tokens": 1024, }) if err != nil { @@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) { {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } - params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{}) + params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } @@ -62,13 +62,13 @@ func TestBuildParams_ToolCallMessage(t *testing.T) { { ID: "call_1", Name: "get_weather", - Arguments: map[string]interface{}{"city": "SF"}, + Arguments: map[string]any{"city": "SF"}, }, }, }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } - params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{}) + params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } @@ -84,17 +84,17 @@ func TestBuildParams_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a city", - Parameters: map[string]interface{}{ + Parameters: map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, }, - "required": []interface{}{"city"}, + "required": []any{"city"}, }, }, }, } - params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]interface{}{}) + params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } @@ -154,19 +154,19 @@ func TestProvider_ChatRoundTrip(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) - resp := map[string]interface{}{ + resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "text", "text": "Hello! How can I help you?"}, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 15, "output_tokens": 8, }, @@ -178,7 +178,7 @@ func TestProvider_ChatRoundTrip(t *testing.T) { provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024}) + resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024}) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -221,19 +221,19 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) - resp := map[string]interface{}{ + resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "text", "text": "ok"}, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 1, "output_tokens": 1, }, @@ -247,7 +247,13 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) { return "refreshed-token", nil }, server.URL) - _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4.6", map[string]interface{}{}) + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "claude-sonnet-4.6", + map[string]any{}, + ) if err != nil { t.Fatalf("Chat() error: %v", err) } diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go index 6c6bf7830..cff67c88c 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/antigravity_provider.go @@ -45,7 +45,13 @@ func NewAntigravityProvider() *AntigravityProvider { // Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API. // The v1internal endpoint wraps the standard Gemini request in an envelope with // project, model, request, requestType, userAgent, and requestId fields. -func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *AntigravityProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { accessToken, projectID, err := p.tokenSource() if err != nil { return nil, fmt.Errorf("antigravity auth: %w", err) @@ -58,7 +64,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool model = strings.TrimPrefix(model, "google-antigravity/") model = strings.TrimPrefix(model, "antigravity/") - logger.DebugCF("provider.antigravity", "Starting chat", map[string]interface{}{ + logger.DebugCF("provider.antigravity", "Starting chat", map[string]any{ "model": model, "project": projectID, "requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)), @@ -68,7 +74,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool innerRequest := p.buildRequest(messages, tools, model, options) // Wrap in v1internal envelope (matches pi-ai SDK format) - envelope := map[string]interface{}{ + envelope := map[string]any{ "project": projectID, "model": model, "request": innerRequest, @@ -115,7 +121,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool } if resp.StatusCode != http.StatusOK { - logger.ErrorCF("provider.antigravity", "API call failed", map[string]interface{}{ + logger.ErrorCF("provider.antigravity", "API call failed", map[string]any{ "status_code": resp.StatusCode, "response": string(respBody), "model": model, @@ -133,7 +139,9 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool // Check for empty response (some models might return valid success but empty text) if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 { - return nil, fmt.Errorf("antigravity: model returned an empty response (this model might be invalid or restricted)") + return nil, fmt.Errorf( + "antigravity: model returned an empty response (this model might be invalid or restricted)", + ) } return llmResp, nil @@ -167,13 +175,13 @@ type antigravityPart struct { } type antigravityFunctionCall struct { - Name string `json:"name"` - Args map[string]interface{} `json:"args"` + Name string `json:"name"` + Args map[string]any `json:"args"` } type antigravityFunctionResponse struct { - Name string `json:"name"` - Response map[string]interface{} `json:"response"` + Name string `json:"name"` + Response map[string]any `json:"response"` } type antigravityTool struct { @@ -181,9 +189,9 @@ type antigravityTool struct { } type antigravityFuncDecl struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters interface{} `json:"parameters,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` } type antigravitySystemPrompt struct { @@ -195,7 +203,12 @@ type antigravityGenConfig struct { Temperature float64 `json:"temperature,omitempty"` } -func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) antigravityRequest { +func (p *AntigravityProvider) buildRequest( + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) antigravityRequest { req := antigravityRequest{} toolCallNames := make(map[string]string) @@ -215,7 +228,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin Parts: []antigravityPart{{ FunctionResponse: &antigravityFunctionResponse{ Name: toolName, - Response: map[string]interface{}{ + Response: map[string]any{ "result": msg.Content, }, }, @@ -237,9 +250,13 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin for _, tc := range msg.ToolCalls { toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) if toolName == "" { - logger.WarnCF("provider.antigravity", "Skipping tool call with empty name in history", map[string]interface{}{ - "tool_call_id": tc.ID, - }) + logger.WarnCF( + "provider.antigravity", + "Skipping tool call with empty name in history", + map[string]any{ + "tool_call_id": tc.ID, + }, + ) continue } if tc.ID != "" { @@ -264,7 +281,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin Parts: []antigravityPart{{ FunctionResponse: &antigravityFunctionResponse{ Name: toolName, - Response: map[string]interface{}{ + Response: map[string]any{ "result": msg.Content, }, }, @@ -311,7 +328,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin return req } -func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) { +func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { name := tc.Name args := tc.Arguments thoughtSignature := "" @@ -324,11 +341,11 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, strin } if args == nil { - args = map[string]interface{}{} + args = map[string]any{} } if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { - var parsed map[string]interface{} + var parsed map[string]any if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { args = parsed } @@ -483,9 +500,12 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, Function: &FunctionCall{ - Name: part.FunctionCall.Name, - Arguments: string(argumentsJSON), - ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake), + Name: part.FunctionCall.Name, + Arguments: string(argumentsJSON), + ThoughtSignature: extractPartThoughtSignature( + part.ThoughtSignature, + part.ThoughtSignatureSnake, + ), }, }) } @@ -556,24 +576,24 @@ var geminiUnsupportedKeywords = map[string]bool{ "maxProperties": true, } -func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} { +func sanitizeSchemaForGemini(schema map[string]any) map[string]any { if schema == nil { return nil } - result := make(map[string]interface{}) + result := make(map[string]any) for k, v := range schema { if geminiUnsupportedKeywords[k] { continue } // Recursively sanitize nested objects switch val := v.(type) { - case map[string]interface{}: + case map[string]any: result[k] = sanitizeSchemaForGemini(val) - case []interface{}: - sanitized := make([]interface{}, len(val)) + case []any: + sanitized := make([]any, len(val)) for i, item := range val { - if m, ok := item.(map[string]interface{}); ok { + if m, ok := item.(map[string]any); ok { sanitized[i] = sanitizeSchemaForGemini(m) } else { sanitized[i] = item @@ -604,7 +624,9 @@ func createAntigravityTokenSource() func() (string, string, error) { return "", "", fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { - return "", "", fmt.Errorf("no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity") + return "", "", fmt.Errorf( + "no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity", + ) } // Refresh if needed @@ -625,7 +647,9 @@ func createAntigravityTokenSource() func() (string, string, error) { } if cred.IsExpired() { - return "", "", fmt.Errorf("antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity") + return "", "", fmt.Errorf( + "antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity", + ) } projectID := cred.ProjectID @@ -633,7 +657,7 @@ func createAntigravityTokenSource() func() (string, string, error) { // Try to fetch project ID from API fetchedID, err := FetchAntigravityProjectID(cred.AccessToken) if err != nil { - logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]interface{}{ + logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]any{ "error": err.Error(), }) projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode) @@ -650,8 +674,8 @@ func createAntigravityTokenSource() func() (string, string, error) { // FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint. func FetchAntigravityProjectID(accessToken string) (string, error) { - reqBody, _ := json.Marshal(map[string]interface{}{ - "metadata": map[string]interface{}{ + reqBody, _ := json.Marshal(map[string]any{ + "metadata": map[string]any{ "ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI", @@ -695,7 +719,7 @@ func FetchAntigravityProjectID(accessToken string) (string, error) { // FetchAntigravityModels fetches available models from the Cloud Code Assist API. func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) { - reqBody, _ := json.Marshal(map[string]interface{}{ + reqBody, _ := json.Marshal(map[string]any{ "project": projectID, }) @@ -717,16 +741,20 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200)) + return nil, fmt.Errorf( + "fetchAvailableModels failed (HTTP %d): %s", + resp.StatusCode, + truncateString(string(body), 200), + ) } var result struct { Models map[string]struct { DisplayName string `json:"displayName"` QuotaInfo struct { - RemainingFraction interface{} `json:"remainingFraction"` - ResetTime string `json:"resetTime"` - IsExhausted bool `json:"isExhausted"` + RemainingFraction any `json:"remainingFraction"` + ResetTime string `json:"resetTime"` + IsExhausted bool `json:"isExhausted"` } `json:"quotaInfo"` } `json:"models"` } @@ -797,10 +825,10 @@ func randomString(n int) string { func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error { var errResp struct { Error struct { - Code int `json:"code"` - Message string `json:"message"` - Status string `json:"status"` - Details []map[string]interface{} `json:"details"` + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + Details []map[string]any `json:"details"` } `json:"error"` } @@ -813,7 +841,7 @@ func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) // Try to extract quota reset info for _, detail := range errResp.Error.Details { if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") { - if metadata, ok := detail["metadata"].(map[string]interface{}); ok { + if metadata, ok := detail["metadata"].(map[string]any); ok { if delay, ok := metadata["quotaResetDelay"].(string); ok { return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay) } diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go index b1bcd8b40..98e07bb80 100644 --- a/pkg/providers/claude_provider_test.go +++ b/pkg/providers/claude_provider_test.go @@ -8,6 +8,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) @@ -22,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) { return } - var reqBody map[string]interface{} + var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) - resp := map[string]interface{}{ + resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", - "content": []map[string]interface{}{ + "content": []map[string]any{ {"type": "text", "text": "Hello! How can I help you?"}, }, - "usage": map[string]interface{}{ + "usage": map[string]any{ "input_tokens": 15, "output_tokens": 8, }, @@ -48,7 +49,7 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) { provider := newClaudeProviderWithDelegate(delegate) messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024}) + resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024}) if err != nil { t.Fatalf("Chat() error: %v", err) } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index eeaa9690a..d0c4344f3 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -28,7 +28,13 @@ func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField st } } -func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *HTTPProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { return p.delegate.Chat(ctx, messages, tools, model, options) } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 6bc43a470..b8528953a 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -15,15 +15,17 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -type ToolCall = protocoltypes.ToolCall -type FunctionCall = protocoltypes.FunctionCall -type LLMResponse = protocoltypes.LLMResponse -type UsageInfo = protocoltypes.UsageInfo -type Message = protocoltypes.Message -type ToolDefinition = protocoltypes.ToolDefinition -type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition -type ExtraContent = protocoltypes.ExtraContent -type GoogleExtra = protocoltypes.GoogleExtra +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra +) type Provider struct { apiKey string @@ -60,14 +62,20 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string } } -func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { if p.apiBase == "" { return nil, fmt.Errorf("API base not configured") } model = normalizeModel(model, p.apiBase) - requestBody := map[string]interface{}{ + requestBody := map[string]any{ "model": model, "messages": messages, } @@ -83,7 +91,8 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef if fieldName == "" { // Fallback: detect from model name for backward compatibility lowerModel := strings.ToLower(model) - if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") { + if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || + strings.Contains(lowerModel, "gpt-5") { fieldName = "max_completion_tokens" } else { fieldName = "max_tokens" @@ -173,7 +182,7 @@ func parseResponse(body []byte) (*LLMResponse, error) { choice := apiResponse.Choices[0] toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) for _, tc := range choice.Message.ToolCalls { - arguments := make(map[string]interface{}) + arguments := make(map[string]any) name := "" // Extract thought_signature from Gemini/Google-specific extra content @@ -238,7 +247,7 @@ func normalizeModel(model, apiBase string) string { } } -func asInt(v interface{}) (int, bool) { +func asInt(v any) (int, bool) { switch val := v.(type) { case int: return val, true @@ -253,7 +262,7 @@ func asInt(v interface{}) (int, bool) { } } -func asFloat(v interface{}) (float64, bool) { +func asFloat(v any) (float64, bool) { switch val := v.(type) { case float64: return val, true diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index b7e7062b9..3a089ca47 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -1,13 +1,13 @@ package protocoltypes type ToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` - ThoughtSignature string `json:"-"` // Internal use only - ExtraContent *ExtraContent `json:"extra_content,omitempty"` + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + ThoughtSignature string `json:"-"` // Internal use only + ExtraContent *ExtraContent `json:"extra_content,omitempty"` } type ExtraContent struct { @@ -50,7 +50,7 @@ type ToolDefinition struct { } type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` } diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go index c7c35ef42..49218b1b1 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/toolcall_utils.go @@ -20,12 +20,12 @@ func NormalizeToolCall(tc ToolCall) ToolCall { // Ensure Arguments is not nil if normalized.Arguments == nil { - normalized.Arguments = map[string]interface{}{} + normalized.Arguments = map[string]any{} } // Parse Arguments from Function.Arguments if not already set if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" { - var parsed map[string]interface{} + var parsed map[string]any if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil { normalized.Arguments = parsed } diff --git a/pkg/providers/types.go b/pkg/providers/types.go index e783e6348..f711e7803 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -7,18 +7,26 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -type ToolCall = protocoltypes.ToolCall -type FunctionCall = protocoltypes.FunctionCall -type LLMResponse = protocoltypes.LLMResponse -type UsageInfo = protocoltypes.UsageInfo -type Message = protocoltypes.Message -type ToolDefinition = protocoltypes.ToolDefinition -type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition -type ExtraContent = protocoltypes.ExtraContent -type GoogleExtra = protocoltypes.GoogleExtra +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra +) type LLMProvider interface { - Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) GetDefaultModel() string } diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index e2a940afd..f78197bbe 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -214,7 +214,10 @@ func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*Skill // DownloadAndInstall fetches metadata (with fallback), resolves version, // downloads the skill ZIP, and extracts it to targetDir. // Returns an InstallResult for the caller to use for moderation decisions. -func (c *ClawHubRegistry) DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) { +func (c *ClawHubRegistry) DownloadAndInstall( + ctx context.Context, + slug, version, targetDir string, +) (*InstallResult, error) { if err := utils.ValidateSkillIdentifier(slug); err != nil { return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error()) } diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go index d12e19504..65ee638da 100644 --- a/pkg/skills/clawhub_registry_test.go +++ b/pkg/skills/clawhub_registry_test.go @@ -11,9 +11,10 @@ import ( "path/filepath" "testing" - "github.com/sipeed/picoclaw/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/utils" ) func newTestRegistry(serverURL, authToken string) *ClawHubRegistry { @@ -162,7 +163,7 @@ func TestExtractZipPathTraversal(t *testing.T) { // Write to temp file for extractZipFile. tmpZip := filepath.Join(t.TempDir(), "bad.zip") - require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0644)) + require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0o644)) tmpDir := t.TempDir() err = utils.ExtractZipFile(tmpZip, tmpDir) @@ -179,7 +180,7 @@ func TestExtractZipWithSubdirectories(t *testing.T) { // Write to temp file for extractZipFile. tmpZip := filepath.Join(t.TempDir(), "test.zip") - require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0644)) + require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0o644)) tmpDir := t.TempDir() targetDir := filepath.Join(tmpDir, "my-skill") diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go index daecd5a59..a4694bd43 100644 --- a/pkg/skills/registry_test.go +++ b/pkg/skills/registry_test.go @@ -6,8 +6,9 @@ import ( "testing" "time" - "github.com/sipeed/picoclaw/pkg/utils" "github.com/stretchr/testify/assert" + + "github.com/sipeed/picoclaw/pkg/utils" ) // mockRegistry is a test double implementing SkillRegistry. diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go index 4c6388b9b..04ef8e441 100644 --- a/pkg/tools/shell_timeout_unix_test.go +++ b/pkg/tools/shell_timeout_unix_test.go @@ -25,7 +25,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { tool := NewExecTool(t.TempDir(), false) tool.SetTimeout(500 * time.Millisecond) - args := map[string]interface{}{ + args := map[string]any{ // Spawn a child process that would outlive the shell unless process-group kill is used. "command": "sleep 60 & echo $! > child.pid; wait", } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 6b05918ce..55c0b678d 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -42,23 +42,23 @@ func (t *InstallSkillTool) Description() string { return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills." } -func (t *InstallSkillTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *InstallSkillTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "slug": map[string]interface{}{ + "properties": map[string]any{ + "slug": map[string]any{ "type": "string", "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')", }, - "version": map[string]interface{}{ + "version": map[string]any{ "type": "string", "description": "Specific version to install (optional, defaults to latest)", }, - "registry": map[string]interface{}{ + "registry": map[string]any{ "type": "string", "description": "Registry to install from (required, e.g., 'clawhub')", }, - "force": map[string]interface{}{ + "force": map[string]any{ "type": "boolean", "description": "Force reinstall if skill already exists (default false)", }, @@ -67,7 +67,7 @@ func (t *InstallSkillTool) Parameters() map[string]interface{} { } } -func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult { // Install lock to prevent concurrent directory operations. // Ideally this should be done at a `slug` level, currently, its at a `workspace` level. t.mu.Lock() @@ -94,7 +94,9 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac if !force { if _, err := os.Stat(targetDir); err == nil { - return ErrorResult(fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir)) + return ErrorResult( + fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), + ) } } else { // Force: remove existing if present. @@ -108,7 +110,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac } // Ensure skills directory exists. - if err := os.MkdirAll(skillsDir, 0755); err != nil { + if err := os.MkdirAll(skillsDir, 0o755); err != nil { return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) } @@ -119,7 +121,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac rmErr := os.RemoveAll(targetDir) if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", - map[string]interface{}{ + map[string]any{ "tool": "install_skill", "target_dir": targetDir, "error": rmErr.Error(), @@ -133,7 +135,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac rmErr := os.RemoveAll(targetDir) if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", - map[string]interface{}{ + map[string]any{ "tool": "install_skill", "target_dir": targetDir, "error": rmErr.Error(), @@ -145,7 +147,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac // Write origin metadata. if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil { logger.ErrorCF("tool", "Failed to write origin metadata", - map[string]interface{}{ + map[string]any{ "tool": "install_skill", "error": err.Error(), "target": targetDir, @@ -195,5 +197,5 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error { return err } - return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0644) + return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644) } diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go index e6941a950..676fcecc0 100644 --- a/pkg/tools/skills_install_test.go +++ b/pkg/tools/skills_install_test.go @@ -6,9 +6,10 @@ import ( "path/filepath" "testing" - "github.com/sipeed/picoclaw/pkg/skills" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/skills" ) func TestInstallSkillToolName(t *testing.T) { @@ -18,14 +19,14 @@ func TestInstallSkillToolName(t *testing.T) { func TestInstallSkillToolMissingSlug(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]interface{}{}) + result := tool.Execute(context.Background(), map[string]any{}) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") } func TestInstallSkillToolEmptySlug(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "slug": " ", }) assert.True(t, result.IsError) @@ -42,7 +43,7 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) { } for _, slug := range cases { - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "slug": slug, }) assert.True(t, result.IsError, "slug %q should be rejected", slug) @@ -53,10 +54,10 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) { func TestInstallSkillToolAlreadyExists(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "existing-skill") - require.NoError(t, os.MkdirAll(skillDir, 0755)) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "slug": "existing-skill", "registry": "clawhub", }) @@ -67,7 +68,7 @@ func TestInstallSkillToolAlreadyExists(t *testing.T) { func TestInstallSkillToolRegistryNotFound(t *testing.T) { workspace := t.TempDir() tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", "registry": "nonexistent", }) @@ -80,7 +81,7 @@ func TestInstallSkillToolParameters(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) params := tool.Parameters() - props, ok := params["properties"].(map[string]interface{}) + props, ok := params["properties"].(map[string]any) assert.True(t, ok) assert.Contains(t, props, "slug") assert.Contains(t, props, "version") @@ -95,7 +96,7 @@ func TestInstallSkillToolParameters(t *testing.T) { func TestInstallSkillToolMissingRegistry(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", }) assert.True(t, result.IsError) diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go index b12949ec2..2b6cffd38 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/skills_search.go @@ -32,15 +32,15 @@ func (t *FindSkillsTool) Description() string { return "Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill." } -func (t *FindSkillsTool) Parameters() map[string]interface{} { - return map[string]interface{}{ +func (t *FindSkillsTool) Parameters() map[string]any { + return map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ + "properties": map[string]any{ + "query": map[string]any{ "type": "string", "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')", }, - "limit": map[string]interface{}{ + "limit": map[string]any{ "type": "integer", "description": "Maximum number of results to return (1-20, default 5)", "minimum": 1.0, @@ -51,7 +51,7 @@ func (t *FindSkillsTool) Parameters() map[string]interface{} { } } -func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) query = strings.ToLower(strings.TrimSpace(query)) if !ok || query == "" { diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/skills_search_test.go index 7e07b2775..0e5387cf5 100644 --- a/pkg/tools/skills_search_test.go +++ b/pkg/tools/skills_search_test.go @@ -4,8 +4,9 @@ import ( "context" "testing" - "github.com/sipeed/picoclaw/pkg/skills" "github.com/stretchr/testify/assert" + + "github.com/sipeed/picoclaw/pkg/skills" ) func TestFindSkillsToolName(t *testing.T) { @@ -15,14 +16,14 @@ func TestFindSkillsToolName(t *testing.T) { func TestFindSkillsToolMissingQuery(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) - result := tool.Execute(context.Background(), map[string]interface{}{}) + result := tool.Execute(context.Background(), map[string]any{}) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "query is required") } func TestFindSkillsToolEmptyQuery(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "query": " ", }) assert.True(t, result.IsError) @@ -35,7 +36,7 @@ func TestFindSkillsToolCacheHit(t *testing.T) { }) tool := NewFindSkillsTool(skills.NewRegistryManager(), cache) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "query": "github", }) @@ -48,7 +49,7 @@ func TestFindSkillsToolParameters(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) params := tool.Parameters() - props, ok := params["properties"].(map[string]interface{}) + props, ok := params["properties"].(map[string]any) assert.True(t, ok) assert.Contains(t, props, "query") assert.Contains(t, props, "limit") @@ -71,7 +72,14 @@ func TestFormatSearchResultsEmpty(t *testing.T) { func TestFormatSearchResultsWithData(t *testing.T) { results := []skills.SearchResult{ - {Slug: "github", Score: 0.95, DisplayName: "GitHub", Summary: "GitHub API integration", Version: "1.0.0", RegistryName: "clawhub"}, + { + Slug: "github", + Score: 0.95, + DisplayName: "GitHub", + Summary: "GitHub API integration", + Version: "1.0.0", + RegistryName: "clawhub", + }, } output := formatSearchResults("github", results, false) assert.Contains(t, output, "github") diff --git a/pkg/utils/download.go b/pkg/utils/download.go index 9fa7fbfa7..5d9a13a30 100644 --- a/pkg/utils/download.go +++ b/pkg/utils/download.go @@ -27,7 +27,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, // Attach context. req = req.WithContext(ctx) - logger.DebugCF("download", "Starting download", map[string]interface{}{ + logger.DebugCF("download", "Starting download", map[string]any{ "url": req.URL.String(), "max_bytes": maxBytes, }) @@ -52,7 +52,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, } tmpPath := tmpFile.Name() - logger.DebugCF("download", "Streaming to temp file", map[string]interface{}{ + logger.DebugCF("download", "Streaming to temp file", map[string]any{ "path": tmpPath, }) @@ -84,7 +84,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, return "", fmt.Errorf("failed to close temp file: %w", err) } - logger.DebugCF("download", "Download complete", map[string]interface{}{ + logger.DebugCF("download", "Download complete", map[string]any{ "path": tmpPath, "bytes_written": written, }) diff --git a/pkg/utils/zip.go b/pkg/utils/zip.go index cad91e420..919ce5a20 100644 --- a/pkg/utils/zip.go +++ b/pkg/utils/zip.go @@ -22,13 +22,13 @@ func ExtractZipFile(zipPath string, targetDir string) error { } defer reader.Close() - logger.DebugCF("zip", "Extracting ZIP", map[string]interface{}{ + logger.DebugCF("zip", "Extracting ZIP", map[string]any{ "zip_path": zipPath, "target_dir": targetDir, "entries": len(reader.File), }) - if err := os.MkdirAll(targetDir, 0755); err != nil { + if err := os.MkdirAll(targetDir, 0o755); err != nil { return fmt.Errorf("failed to create target dir: %w", err) } @@ -43,7 +43,8 @@ func ExtractZipFile(zipPath string, targetDir string) error { // Double-check the resolved path is within target directory (defense-in-depth). targetDirClean := filepath.Clean(targetDir) - if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) && filepath.Clean(destPath) != targetDirClean { + if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) && + filepath.Clean(destPath) != targetDirClean { return fmt.Errorf("zip entry escapes target dir: %q", f.Name) } @@ -55,14 +56,14 @@ func ExtractZipFile(zipPath string, targetDir string) error { } if f.FileInfo().IsDir() { - if err := os.MkdirAll(destPath, 0755); err != nil { + if err := os.MkdirAll(destPath, 0o755); err != nil { return err } continue } // Ensure parent directory exists. - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { return err } @@ -98,7 +99,7 @@ func extractSingleFile(f *zip.File, destPath string) error { defer func() { if cerr := outFile.Close(); cerr != nil { _ = os.Remove(destPath) - logger.ErrorCF("zip", "Failed to close file", map[string]interface{}{ + logger.ErrorCF("zip", "Failed to close file", map[string]any{ "dest_path": destPath, "error": cerr.Error(), }) From 5ca239b5c502df157ed54774aa46a6c9e3fed1ff Mon Sep 17 00:00:00 2001 From: harshbansal7 Date: Sat, 21 Feb 2026 01:02:35 +0530 Subject: [PATCH 10/21] fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d7d8be80b..7bc7b1089 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ picoclaw onboard } ``` -> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#-model-configuration) for details. +> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. **3. Get API Keys** From 123cffa85a1d69c5489a46e4a8f49630f130df03 Mon Sep 17 00:00:00 2001 From: harshbansal7 Date: Sat, 21 Feb 2026 01:04:48 +0530 Subject: [PATCH 11/21] fix 2 --- README.zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.zh.md b/README.zh.md index 0989770ca..ab896b6c0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -256,7 +256,7 @@ picoclaw onboard ``` -> **ę–°åŠŸčƒ½**: `model_list` é…ē½®ę ¼å¼ę”ÆęŒé›¶ä»£ē ę·»åŠ  provider。详见[ęØ”åž‹é…ē½®](#-ęØ”åž‹é…ē½®-model_list)ē« čŠ‚ć€‚ +> **ę–°åŠŸčƒ½**: `model_list` é…ē½®ę ¼å¼ę”ÆęŒé›¶ä»£ē ę·»åŠ  provider。详见[ęØ”åž‹é…ē½®](#ęØ”åž‹é…ē½®-model_list)ē« čŠ‚ć€‚ **3. čŽ·å– API Key** From c2ace2561cfae81839e977f90da7e29017d7b8e7 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Fri, 20 Feb 2026 22:09:36 +0200 Subject: [PATCH 12/21] feat(ci): Remove fmt from build step --- .github/workflows/build.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 499613625..9b89b69ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: build on: push: - branches: ["main"] + branches: [ "main" ] jobs: build: @@ -16,10 +16,5 @@ jobs: with: go-version-file: go.mod - - name: fmt - run: | - make fmt - git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1) - - name: Build run: make build-all From 02b4d9fbe2dea85fb032ceba7d4c64f1499ba95a Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Fri, 20 Feb 2026 22:35:16 +0200 Subject: [PATCH 13/21] feat(linter): Fix govet linter --- .github/workflows/pr.yml | 19 ------------------- .golangci.yaml | 1 - cmd/picoclaw/cmd_auth.go | 6 +++--- cmd/picoclaw/cmd_gateway.go | 3 ++- cmd/picoclaw/cmd_skills.go | 4 ++-- pkg/channels/telegram.go | 4 ++-- pkg/channels/wecom.go | 2 +- pkg/channels/wecom_app.go | 2 +- pkg/channels/wecom_app_test.go | 13 ------------- pkg/channels/wecom_test.go | 4 +--- pkg/migrate/migrate.go | 2 +- pkg/migrate/migrate_test.go | 10 +++++----- pkg/providers/codex_cli_credentials.go | 2 +- pkg/tools/edit.go | 2 +- pkg/tools/filesystem.go | 8 +++++--- pkg/tools/i2c_linux.go | 2 +- pkg/voice/transcriber.go | 6 +++--- 17 files changed, 29 insertions(+), 61 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 27782ced2..be1c10c52 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,25 +24,6 @@ jobs: with: version: v2.10.1 - # TODO: Remove once linter is properly configured - vet: - name: Vet - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Run go generate - run: go generate ./... - - - name: Run go vet - run: go vet ./... - test: name: Tests runs-on: ubuntu-latest diff --git a/.golangci.yaml b/.golangci.yaml index 6dafb6b56..d45d69e67 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -47,7 +47,6 @@ linters: - godox - goprintffuncname - gosec - - govet - ineffassign - lll - maintidx diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go index 5bed7f116..729c56177 100644 --- a/cmd/picoclaw/cmd_auth.go +++ b/cmd/picoclaw/cmd_auth.go @@ -114,7 +114,7 @@ func authLoginOpenAI(useDeviceCode bool) { os.Exit(1) } - if err := auth.SetCredential("openai", cred); err != nil { + if err = auth.SetCredential("openai", cred); err != nil { fmt.Printf("Failed to save credentials: %v\n", err) os.Exit(1) } @@ -188,7 +188,7 @@ func authLoginGoogleAntigravity() { fmt.Printf("Project: %s\n", projectID) } - if err := auth.SetCredential("google-antigravity", cred); err != nil { + if err = auth.SetCredential("google-antigravity", cred); err != nil { fmt.Printf("Failed to save credentials: %v\n", err) os.Exit(1) } @@ -265,7 +265,7 @@ func authLoginPasteToken(provider string) { os.Exit(1) } - if err := auth.SetCredential(provider, cred); err != nil { + if err = auth.SetCredential(provider, cred); err != nil { fmt.Printf("Failed to save credentials: %v\n", err) os.Exit(1) } diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 00ec0f96d..9a3b6aa19 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -98,7 +98,8 @@ func gatewayCmd() { channel, chatID = "cli", "direct" } // Use ProcessHeartbeat - no session history, each heartbeat is independent - response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + var response string + response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) if err != nil { return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) } diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go index 2dd46756a..0814494b3 100644 --- a/cmd/picoclaw/cmd_skills.go +++ b/cmd/picoclaw/cmd_skills.go @@ -118,7 +118,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { workspace := cfg.WorkspacePath() targetDir := filepath.Join(workspace, "skills", slug) - if _, err := os.Stat(targetDir); err == nil { + if _, err = os.Stat(targetDir); err == nil { fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir) os.Exit(1) } @@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil { + if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil { fmt.Printf("\u2717 Failed to create skills directory: %v\n", err) os.Exit(1) } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 2a971e147..a0a1c8d0a 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -267,10 +267,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes transcribedText := "" if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - result, err := c.transcriber.Transcribe(ctx, voicePath) + result, err := c.transcriber.Transcribe(transcriberCtx, voicePath) if err != nil { logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ "error": err.Error(), diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go index 07bd8488c..f8daf89de 100644 --- a/pkg/channels/wecom.go +++ b/pkg/channels/wecom.go @@ -272,7 +272,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp AgentID string `xml:"AgentID"` } - if err := xml.Unmarshal(body, &encryptedMsg); err != nil { + if err = xml.Unmarshal(body, &encryptedMsg); err != nil { logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{ "error": err.Error(), }) diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go index 878504106..715c48707 100644 --- a/pkg/channels/wecom_app.go +++ b/pkg/channels/wecom_app.go @@ -348,7 +348,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp AgentID string `xml:"AgentID"` } - if err := xml.Unmarshal(body, &encryptedMsg); err != nil { + if err = xml.Unmarshal(body, &encryptedMsg); err != nil { logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ "error": err.Error(), }) diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go index 6778520f3..abf15c52b 100644 --- a/pkg/channels/wecom_app_test.go +++ b/pkg/channels/wecom_app_test.go @@ -852,19 +852,6 @@ func TestWeComAppMessageStructures(t *testing.T) { } }) - t.Run("WeComImageMessage structure", func(t *testing.T) { - msg := WeComImageMessage{ - ToUser: "user123", - MsgType: "image", - AgentID: 1000002, - } - msg.Image.MediaID = "media_123456" - - if msg.Image.MediaID != "media_123456" { - t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456") - } - }) - t.Run("WeComAccessTokenResponse structure", func(t *testing.T) { jsonData := `{ "errcode": 0, diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go index 53cde2693..8afa7e8c3 100644 --- a/pkg/channels/wecom_test.go +++ b/pkg/channels/wecom_test.go @@ -198,10 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) { Token: "", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } - base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom) chEmpty := &WeComBotChannel{ - BaseChannel: base, - config: cfgEmpty, + config: cfgEmpty, } if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go index ab2635890..cfa82b7d7 100644 --- a/pkg/migrate/migrate.go +++ b/pkg/migrate/migrate.go @@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) { return nil, err } - if _, err := os.Stat(openclawHome); os.IsNotExist(err) { + if _, err = os.Stat(openclawHome); os.IsNotExist(err) { return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome) } diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index ccc00f72c..b6b3d70aa 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -58,10 +58,10 @@ func TestConvertKeysToSnake(t *testing.T) { t.Fatal("expected map[string]interface{}") } - if _, ok := m["api_key"]; !ok { + if _, ok = m["api_key"]; !ok { t.Error("expected key 'api_key' after conversion") } - if _, ok := m["api_base"]; !ok { + if _, ok = m["api_base"]; !ok { t.Error("expected key 'api_base' after conversion") } @@ -69,10 +69,10 @@ func TestConvertKeysToSnake(t *testing.T) { if !ok { t.Fatal("expected nested map") } - if _, ok := nested["max_tokens"]; !ok { + if _, ok = nested["max_tokens"]; !ok { t.Error("expected key 'max_tokens' in nested map") } - if _, ok := nested["allow_from"]; !ok { + if _, ok = nested["allow_from"]; !ok { t.Error("expected key 'allow_from' in nested map") } @@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(configPath, data, 0o644); err != nil { + if err = os.WriteFile(configPath, data, 0o644); err != nil { t.Fatal(err) } diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go index 46ba24b12..40f3ee2a1 100644 --- a/pkg/providers/codex_cli_credentials.go +++ b/pkg/providers/codex_cli_credentials.go @@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti } var auth CodexCliAuth - if err := json.Unmarshal(data, &auth); err != nil { + if err = json.Unmarshal(data, &auth); err != nil { return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err) } diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 39d2642d4..c28ca6ca2 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -72,7 +72,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult(err.Error()) } - if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { + if _, err = os.Stat(resolvedPath); os.IsNotExist(err) { return ErrorResult(fmt.Sprintf("file not found: %s", path)) } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index dd996bc0d..1bf50906e 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -34,17 +34,19 @@ func validatePath(path, workspace string, restrict bool) (string, error) { return "", fmt.Errorf("access denied: path is outside the workspace") } + var resolved string workspaceReal := absWorkspace - if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil { + if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { workspaceReal = resolved } - if resolved, err := filepath.EvalSymlinks(absPath); err == nil { + if resolved, err = filepath.EvalSymlinks(absPath); err == nil { if !isWithinWorkspace(resolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } } else if os.IsNotExist(err) { - if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil { + var parentResolved string + if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { if !isWithinWorkspace(parentResolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index 2a0626340..4eaaf8f09 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -182,7 +182,7 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult { if reg < 0 || reg > 255 { return ErrorResult("register must be between 0x00 and 0xFF") } - _, err := syscall.Write(fd, []byte{byte(reg)}) + _, err = syscall.Write(fd, []byte{byte(reg)}) if err != nil { return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err)) } diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index ad8767d40..f973e77fe 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -79,17 +79,17 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) - if err := writer.WriteField("model", "whisper-large-v3"); err != nil { + if err = writer.WriteField("model", "whisper-large-v3"); err != nil { logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write model field: %w", err) } - if err := writer.WriteField("response_format", "json"); err != nil { + if err = writer.WriteField("response_format", "json"); err != nil { logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write response_format field: %w", err) } - if err := writer.Close(); err != nil { + if err = writer.Close(); err != nil { logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) return nil, fmt.Errorf("failed to close multipart writer: %w", err) } From 244eb0b47d0f694df4bdc96c316b23698eab7407 Mon Sep 17 00:00:00 2001 From: Goksu Ceylan <79890826+GoCeylan@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:15:46 -0500 Subject: [PATCH 14/21] fix (security): ExecTool `working_dir` sandbox escape (#478) * fix (security) Shell working_dir bypass * Feedback from @mengzhuo & Discord - reuse internal security package to validate path - add tests for workspace escape --- pkg/tools/shell.go | 10 ++++++- pkg/tools/shell_test.go | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d2adb6468..a1ee0b6e1 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -144,7 +144,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult cwd := t.workingDir if wd, ok := args["working_dir"].(string); ok && wd != "" { - cwd = wd + if t.restrictToWorkspace && t.workingDir != "" { + resolvedWD, err := validatePath(wd, t.workingDir, true) + if err != nil { + return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") + } + cwd = resolvedWD + } else { + cwd = wd + } } if cwd == "" { diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index f85b5a008..60f2b7b91 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -186,6 +186,66 @@ func TestShellTool_OutputTruncation(t *testing.T) { } } +// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly +func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + outsideDir := filepath.Join(root, "outside") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + if err := os.MkdirAll(outsideDir, 0755); err != nil { + t.Fatalf("failed to create outside dir: %v", err) + } + + tool := NewExecTool(workspace, true) + result := tool.Execute(context.Background(), map[string]interface{}{ + "command": "pwd", + "working_dir": outsideDir, + }) + + if !result.IsError { + t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "blocked") { + t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) + } +} + +// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace +// pointing outside cannot be used as working_dir to escape the sandbox. +func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + secretDir := filepath.Join(root, "secret") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + if err := os.MkdirAll(secretDir, 0755); err != nil { + t.Fatalf("failed to create secret dir: %v", err) + } + os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0644) + + // symlink lives inside the workspace but resolves to secretDir outside it + link := filepath.Join(workspace, "escape") + if err := os.Symlink(secretDir, link); err != nil { + t.Skipf("symlinks not supported in this environment: %v", err) + } + + tool := NewExecTool(workspace, true) + result := tool.Execute(context.Background(), map[string]interface{}{ + "command": "cat secret.txt", + "working_dir": link, + }) + + if !result.IsError { + t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "blocked") { + t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) + } +} + // TestShellTool_RestrictToWorkspace verifies workspace restriction func TestShellTool_RestrictToWorkspace(t *testing.T) { tmpDir := t.TempDir() From 80c8b5753338dc75286d93d8d62fa01654b1a2f0 Mon Sep 17 00:00:00 2001 From: Luke Milby Date: Fri, 20 Feb 2026 19:21:38 -0500 Subject: [PATCH 15/21] Fix Memory Write (#557) * fix issue where memory will only trigger when asked to remember something * updated prompt for memory usage --- pkg/agent/context.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index e989ffaaf..a9db5afdd 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -80,7 +80,7 @@ Your workspace is at: %s 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. -3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`, +3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`, now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) } From 3df7f705408d5767e43a22975fd2d093f33b7705 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 21 Feb 2026 16:05:39 +0800 Subject: [PATCH 16/21] fix: golangci-lint fmt --- pkg/tools/shell_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 60f2b7b91..d0a300c6c 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -191,10 +191,10 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "workspace") outsideDir := filepath.Join(root, "outside") - if err := os.MkdirAll(workspace, 0755); err != nil { + if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } - if err := os.MkdirAll(outsideDir, 0755); err != nil { + if err := os.MkdirAll(outsideDir, 0o755); err != nil { t.Fatalf("failed to create outside dir: %v", err) } @@ -218,13 +218,13 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "workspace") secretDir := filepath.Join(root, "secret") - if err := os.MkdirAll(workspace, 0755); err != nil { + if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } - if err := os.MkdirAll(secretDir, 0755); err != nil { + if err := os.MkdirAll(secretDir, 0o755); err != nil { t.Fatalf("failed to create secret dir: %v", err) } - os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0644) + os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644) // symlink lives inside the workspace but resolves to secretDir outside it link := filepath.Join(workspace, "escape") From 00666022949fb993f9b07ae8c2bb844fde977b23 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 21 Feb 2026 16:20:15 +0800 Subject: [PATCH 17/21] fix: golangci-lint run --fix --- pkg/tools/shell_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index d0a300c6c..6d35815e8 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -199,7 +199,7 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { } tool := NewExecTool(workspace, true) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "command": "pwd", "working_dir": outsideDir, }) @@ -233,7 +233,7 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { } tool := NewExecTool(workspace, true) - result := tool.Execute(context.Background(), map[string]interface{}{ + result := tool.Execute(context.Background(), map[string]any{ "command": "cat secret.txt", "working_dir": link, }) From 023b245a285780edfcfbe90ae81b4c9cd7e7913d Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 21 Feb 2026 15:33:35 +0800 Subject: [PATCH 18/21] docs: add Chinese channel documentation --- README.zh.md | 458 ++++++--------------- docs/channels/dingtalk/README.zh.md | 33 ++ docs/channels/discord/README.zh.md | 35 ++ docs/channels/feishu/README.zh.md | 37 ++ docs/channels/line/README.zh.md | 41 ++ docs/channels/maixcam/README.zh.md | 31 ++ docs/channels/onebot/README.zh.md | 31 ++ docs/channels/qq/README.zh.md | 32 ++ docs/channels/slack/README.zh.md | 33 ++ docs/channels/telegram/README.zh.md | 33 ++ docs/channels/wecom/wecom_app/README.zh.md | 47 +++ docs/channels/wecom/wecom_bot/README.zh.md | 41 ++ 12 files changed, 510 insertions(+), 342 deletions(-) create mode 100644 docs/channels/dingtalk/README.zh.md create mode 100644 docs/channels/discord/README.zh.md create mode 100644 docs/channels/feishu/README.zh.md create mode 100644 docs/channels/line/README.zh.md create mode 100644 docs/channels/maixcam/README.zh.md create mode 100644 docs/channels/onebot/README.zh.md create mode 100644 docs/channels/qq/README.zh.md create mode 100644 docs/channels/slack/README.zh.md create mode 100644 docs/channels/telegram/README.zh.md create mode 100644 docs/channels/wecom/wecom_app/README.zh.md create mode 100644 docs/channels/wecom/wecom_bot/README.zh.md diff --git a/README.zh.md b/README.zh.md index ab896b6c0..4d739c5eb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,8 @@ Twitter

- **äø­ę–‡** | [ę—„ęœ¬čŖž](README.ja.md) | [PortuguĆŖs](README.pt-br.md) | [Tiįŗæng Việt](README.vi.md) | [FranƧais](README.fr.md) | [English](README.md) +**äø­ę–‡** | [ę—„ęœ¬čŖž](README.ja.md) | [PortuguĆŖs](README.pt-br.md) | [Tiįŗæng Việt](README.vi.md) | [FranƧais](README.fr.md) | [English](README.md) + --- @@ -42,14 +43,15 @@ > [!CAUTION] > **🚨 SECURITY & OFFICIAL CHANNELS / å®‰å…Øå£°ę˜Ž** -> * **ę— åŠ åÆ†č“§åø (NO CRYPTO):** PicoClaw **ę²”ęœ‰** å‘č”Œä»»ä½•å®˜ę–¹ä»£åøć€Token ęˆ–č™šę‹Ÿč“§åøć€‚ę‰€ęœ‰åœØ `pump.fun` ęˆ–å…¶ä»–äŗ¤ę˜“å¹³å°äøŠēš„ē›øå…³å£°ē§°å‡äøŗ **čÆˆéŖ—**怂 -> * **å®˜ę–¹åŸŸå:** å”Æäø€ēš„å®˜ę–¹ē½‘ē«™ę˜Æ **[picoclaw.io](https://picoclaw.io)**ļ¼Œå…¬åøå®˜ē½‘ę˜Æ **[sipeed.com](https://sipeed.com)**怂 -> * **č­¦ęƒ•:** 许多 `.ai/.org/.com/.net/...` åŽē¼€ēš„åŸŸåč¢«ē¬¬äø‰ę–¹ęŠ¢ę³Øļ¼ŒčÆ·å‹æč½»äæ”ć€‚ -> * **ę³Øę„:** picoclawę­£åœØåˆęœŸēš„åæ«é€ŸåŠŸčƒ½å¼€å‘é˜¶ę®µļ¼ŒåÆčƒ½ęœ‰å°šęœŖäæ®å¤ēš„ē½‘ē»œå®‰å…Øé—®é¢˜ļ¼ŒåœØ1.0ę­£å¼ē‰ˆå‘åøƒå‰ļ¼ŒčÆ·äøč¦å°†å…¶éƒØē½²åˆ°ē”Ÿäŗ§ēŽÆå¢ƒäø­ -> * **ę³Øę„:** picoclawęœ€čæ‘åˆå¹¶äŗ†å¤§é‡PRsļ¼Œčæ‘ęœŸē‰ˆęœ¬åÆčƒ½å†…å­˜å ē”Øč¾ƒå¤§(10~20MB)ļ¼Œęˆ‘ä»¬å°†åœØåŠŸčƒ½č¾ƒäøŗę”¶ę•›åŽčæ›č”Œčµ„ęŗå ē”Øä¼˜åŒ–. - +> +> - **ę— åŠ åÆ†č“§åø (NO CRYPTO):** PicoClaw **ę²”ęœ‰** å‘č”Œä»»ä½•å®˜ę–¹ä»£åøć€Token ęˆ–č™šę‹Ÿč“§åøć€‚ę‰€ęœ‰åœØ `pump.fun` ęˆ–å…¶ä»–äŗ¤ę˜“å¹³å°äøŠēš„ē›øå…³å£°ē§°å‡äøŗ **čÆˆéŖ—**怂 +> - **å®˜ę–¹åŸŸå:** å”Æäø€ēš„å®˜ę–¹ē½‘ē«™ę˜Æ **[picoclaw.io](https://picoclaw.io)**ļ¼Œå…¬åøå®˜ē½‘ę˜Æ **[sipeed.com](https://sipeed.com)**怂 +> - **č­¦ęƒ•:** 许多 `.ai/.org/.com/.net/...` åŽē¼€ēš„åŸŸåč¢«ē¬¬äø‰ę–¹ęŠ¢ę³Øļ¼ŒčÆ·å‹æč½»äæ”ć€‚ +> - **ę³Øę„:** picoclawę­£åœØåˆęœŸēš„åæ«é€ŸåŠŸčƒ½å¼€å‘é˜¶ę®µļ¼ŒåÆčƒ½ęœ‰å°šęœŖäæ®å¤ēš„ē½‘ē»œå®‰å…Øé—®é¢˜ļ¼ŒåœØ1.0ę­£å¼ē‰ˆå‘åøƒå‰ļ¼ŒčÆ·äøč¦å°†å…¶éƒØē½²åˆ°ē”Ÿäŗ§ēŽÆå¢ƒäø­ +> - **ę³Øę„:** picoclawęœ€čæ‘åˆå¹¶äŗ†å¤§é‡PRsļ¼Œčæ‘ęœŸē‰ˆęœ¬åÆčƒ½å†…å­˜å ē”Øč¾ƒå¤§(10~20MB)ļ¼Œęˆ‘ä»¬å°†åœØåŠŸčƒ½č¾ƒäøŗę”¶ę•›åŽčæ›č”Œčµ„ęŗå ē”Øä¼˜åŒ–. ## šŸ“¢ ę–°é—» (News) + 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 ēš„å¼€å‘ć€‚ @@ -69,12 +71,12 @@ šŸ¤– **AI 自举**: ēŗÆ Go čÆ­čØ€åŽŸē”Ÿå®žēŽ° — 95% ēš„ę øåæƒä»£ē ē”± Agent ē”Ÿęˆļ¼Œå¹¶ē»ē”±ā€œäŗŗęœŗå›žēŽÆ (Human-in-the-loop)ā€å¾®č°ƒć€‚ -| | OpenClaw | NanoBot | **PicoClaw** | -| --- | --- | --- | --- | -| **语言** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | -| **åÆåŠØę—¶é—“**
(0.8GHz core) | >500s | >30s | **<1s** | -| **成本** | Mac Mini $599 | å¤§å¤šę•° Linux å¼€å‘ęæ ~$50 | **ä»»ę„ Linux å¼€å‘ęæ**
**ä½Žč‡³ $10** | +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **语言** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **åÆåŠØę—¶é—“**
(0.8GHz core) | >500s | >30s | **<1s** | +| **成本** | Mac Mini $599 | å¤§å¤šę•° Linux å¼€å‘ęæ ~$50 | **ä»»ę„ Linux å¼€å‘ęæ**
**ä½Žč‡³ $10** | PicoClaw @@ -101,9 +103,12 @@ ### šŸ“± åœØę‰‹ęœŗäøŠč½»ę¾čæč”Œ + picoclaw åÆä»„å°†ä½ 10å¹“å‰ēš„č€ę—§ę‰‹ęœŗåŗŸē‰©åˆ©ē”Øļ¼Œå˜čŗ«ęˆäøŗä½ ēš„AIåŠ©ē†ļ¼åæ«é€ŸęŒ‡å—: + 1. å…ˆåŽ»åŗ”ē”Øå•†åŗ—äø‹č½½å®‰č£…Termux 2. ę‰“å¼€åŽę‰§č”ŒęŒ‡ä»¤ + ```bash # ę³Øę„: äø‹é¢ēš„v0.1.1 åÆä»„ę¢äøŗä½ å®žé™…ēœ‹åˆ°ēš„ęœ€ę–°ē‰ˆęœ¬ wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 @@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64 pkg install proot termux-chroot ./picoclaw-linux-arm64 onboard ``` -ē„¶åŽč·Ÿéšäø‹é¢ēš„ā€œåæ«é€Ÿå¼€å§‹ā€ē« čŠ‚ē»§ē»­é…ē½®picoclawå³åÆä½æē”Øļ¼ + +ē„¶åŽč·Ÿéšäø‹é¢ēš„ā€œåæ«é€Ÿå¼€å§‹ā€ē« čŠ‚ē»§ē»­é…ē½®picoclawå³åÆä½æē”Øļ¼ PicoClaw - - - ### 🐜 åˆ›ę–°ēš„ä½Žå ē”ØéƒØē½² PicoClaw å‡ ä¹ŽåÆä»„éƒØē½²åœØä»»ä½• Linux č®¾å¤‡äøŠļ¼ -* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(ē½‘å£) ꈖ W(WiFi6) ē‰ˆęœ¬ļ¼Œē”ØäŗŽęžē®€å®¶åŗ­åŠ©ę‰‹ć€‚ -* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html)ļ¼Œęˆ– $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)ļ¼Œē”ØäŗŽč‡ŖåŠØåŒ–ęœåŠ”å™Øčæē»“ć€‚ -* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ꈖ $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)ļ¼Œē”ØäŗŽę™ŗčƒ½ē›‘ęŽ§ć€‚ +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(ē½‘å£) ꈖ W(WiFi6) ē‰ˆęœ¬ļ¼Œē”ØäŗŽęžē®€å®¶åŗ­åŠ©ę‰‹ć€‚ +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html)ļ¼Œęˆ– $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)ļ¼Œē”ØäŗŽč‡ŖåŠØåŒ–ęœåŠ”å™Øčæē»“ć€‚ +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ꈖ $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)ļ¼Œē”ØäŗŽę™ŗčƒ½ē›‘ęŽ§ć€‚ [https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4) @@ -253,15 +256,14 @@ picoclaw onboard } } } - ``` > **ę–°åŠŸčƒ½**: `model_list` é…ē½®ę ¼å¼ę”ÆęŒé›¶ä»£ē ę·»åŠ  provider。详见[ęØ”åž‹é…ē½®](#ęØ”åž‹é…ē½®-model_list)ē« čŠ‚ć€‚ **3. čŽ·å– API Key** -* **LLM ęä¾›å•†**: [OpenRouter](https://openrouter.ai/keys) Ā· [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) Ā· [Anthropic](https://console.anthropic.com) Ā· [OpenAI](https://platform.openai.com) Ā· [Gemini](https://aistudio.google.com/api-keys) -* **ē½‘ē»œęœē“¢** (åÆé€‰): [Brave Search](https://brave.com/search/api) - ęä¾›å…č“¹å±‚ēŗ§ (2000 请求/月) +- **LLM ęä¾›å•†**: [OpenRouter](https://openrouter.ai/keys) Ā· [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) Ā· [Anthropic](https://console.anthropic.com) Ā· [OpenAI](https://platform.openai.com) Ā· [Gemini](https://aistudio.google.com/api-keys) +- **ē½‘ē»œęœē“¢** (åÆé€‰): [Brave Search](https://brave.com/search/api) - ęä¾›å…č“¹å±‚ēŗ§ (2000 请求/月) > **ę³Øę„**: å®Œę•“ēš„é…ē½®ęØ”ęæčÆ·å‚č€ƒ `config.example.json`怂 @@ -278,260 +280,28 @@ picoclaw agent -m "2+2 ē­‰äŗŽå‡ ļ¼Ÿ" ## šŸ’¬ čŠå¤©åŗ”ē”Øé›†ęˆ (Chat Apps) -é€ščæ‡ Telegram, Discord, é’‰é’‰ęˆ–ä¼äøšå¾®äæ”äøŽę‚Øēš„ PicoClaw åÆ¹čÆć€‚ - -| 渠道 | 设置难度 | -| --- | --- | -| **Telegram** | ē®€å• (仅需 token) | -| **Discord** | ē®€å• (bot token + intents) | -| **QQ** | ē®€å• (AppID + AppSecret) | -| **钉钉 (DingTalk)** | äø­ē­‰ (应用凭证) | -| **企业微俔 (WeCom)** | äø­ē­‰ (企业ID + Webhooké…ē½®) | - -
-Telegram (ęŽØč) - -**1. åˆ›å»ŗęœŗå™Øäŗŗ** - -* 打开 Telegram,搜瓢 `@BotFather` -* 发送 `/newbot`ļ¼ŒęŒ‰ē…§ęē¤ŗę“ä½œ -* 复制 token - -**2. é…ē½®** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} - -``` - -> 从 Telegram äøŠēš„ `@userinfobot` čŽ·å–ę‚Øēš„ē”Øęˆ· ID怂 - -**3. 运蔌** - -```bash -picoclaw gateway - -``` - -
- -
-Discord - -**1. åˆ›å»ŗęœŗå™Øäŗŗ** - -* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications) -* Create an application → Bot → Add Bot -* 复制 bot token - -**2. 开启 Intents** - -* 在 Bot č®¾ē½®äø­ļ¼Œå¼€åÆ **MESSAGE CONTENT INTENT** -* (åÆé€‰) å¦‚ęžœč®”åˆ’åŸŗäŗŽęˆå‘˜ę•°ę®ä½æē”Øē™½åå•ļ¼Œå¼€åÆ **SERVER MEMBERS INTENT** - -**3. čŽ·å–ę‚Øēš„ User ID** - -* Discord 设置 → Advanced → 开启 **Developer Mode** -* å³é”®ē‚¹å‡»ę‚Øēš„å¤“åƒ → **Copy User ID** - -**4. é…ē½®** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"], - "mention_only": false - } - } -} - -``` - -**5. é‚€čÆ·ęœŗå™Øäŗŗ** - -* OAuth2 → URL Generator -* Scopes: `bot` -* Bot Permissions: `Send Messages`, `Read Message History` -* ę‰“å¼€ē”Ÿęˆēš„é‚€čÆ· URLļ¼Œå°†ęœŗå™Øäŗŗę·»åŠ åˆ°ę‚Øēš„ęœåŠ”å™Ø - -**6. 运蔌** - -```bash -picoclaw gateway - -``` - -
- -
-QQ - -**1. åˆ›å»ŗęœŗå™Øäŗŗ** - -* 前往 [QQ å¼€ę”¾å¹³å°](https://q.qq.com/#) -* åˆ›å»ŗåŗ”ē”Ø → čŽ·å– **AppID** 和 **AppSecret** - -**2. é…ē½®** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "YOUR_APP_ID", - "app_secret": "YOUR_APP_SECRET", - "allow_from": [] - } - } -} - -``` - -> 将 `allow_from` č®¾äøŗē©ŗä»„å…č®øę‰€ęœ‰ē”Øęˆ·ļ¼Œęˆ–ęŒ‡å®š QQ å·ä»„é™åˆ¶č®æé—®ć€‚ - -**3. 运蔌** - -```bash -picoclaw gateway - -``` - -
- -
-钉钉 (DingTalk) - -**1. åˆ›å»ŗęœŗå™Øäŗŗ** - -* 前往 [å¼€ę”¾å¹³å°](https://open.dingtalk.com/) -* åˆ›å»ŗå†…éƒØåŗ”ē”Ø -* 复制 Client ID 和 Client Secret - -**2. é…ē½®** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - } - } -} - -``` - -> 将 `allow_from` č®¾äøŗē©ŗä»„å…č®øę‰€ęœ‰ē”Øęˆ·ļ¼Œęˆ–ęŒ‡å®š ID ä»„é™åˆ¶č®æé—®ć€‚ - -**3. 运蔌** - -```bash -picoclaw gateway - -``` - -
- -
-企业微俔 (WeCom) - -PicoClaw ę”ÆęŒäø¤ē§ä¼äøšå¾®äæ”é›†ęˆę–¹å¼ļ¼š - -**选锹1: ę™ŗčƒ½ęœŗå™Øäŗŗ (WeCom Bot)** - č®¾ē½®ę›“ē®€å•ļ¼Œę”ÆęŒē¾¤čŠ -**选锹2: 自建应用 (WeCom App)** - åŠŸčƒ½ę›“äø°åÆŒļ¼Œę”ÆęŒäø»åŠØęŽØé€ę¶ˆęÆ - -详见 [ä¼äøšå¾®äæ”č‡Ŗå»ŗåŗ”ē”Øé…ē½®ęŒ‡å—](docs/wecom-app-configuration.md)怂 - -**åæ«é€Ÿč®¾ē½® - ę™ŗčƒ½ęœŗå™Øäŗŗļ¼š** - -**1. åˆ›å»ŗęœŗå™Øäŗŗ** - -* å‰å¾€ä¼äøšå¾®äæ”ē®”ē†åŽå° → 群聊 → ę·»åŠ ē¾¤ęœŗå™Øäŗŗ -* 复制 Webhook URL (ę ¼å¼: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. é…ē½®** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -**åæ«é€Ÿč®¾ē½® - č‡Ŗå»ŗåŗ”ē”Øļ¼š** - -**1. åˆ›å»ŗåŗ”ē”Ø** - -* å‰å¾€ä¼äøšå¾®äæ”ē®”ē†åŽå° → 应用箔理 → åˆ›å»ŗåŗ”ē”Ø -* 复制 **AgentId** 和 **Secret** -* 前往"ęˆ‘ēš„ä¼äøš"é”µé¢ļ¼Œå¤åˆ¶ **CorpID** - -**2. é…ē½®ęŽ„ę”¶ę¶ˆęÆ** - -* åœØåŗ”ē”ØčÆ¦ęƒ…é”µļ¼Œē‚¹å‡»"ęŽ„ę”¶ę¶ˆęÆ" → "设置API" -* 设置 URL äøŗ `http://your-server:18792/webhook/wecom-app` -* ē”Ÿęˆ **Token** 和 **EncodingAESKey** - -**3. é…ē½®** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. 运蔌** - -```bash -picoclaw gateway - -``` - -> **ę³Øę„**: č‡Ŗå»ŗåŗ”ē”Øéœ€č¦å¼€ę”¾ 18792 ē«Æå£ē”ØäŗŽęŽ„ę”¶ Webhook å›žč°ƒć€‚ē”Ÿäŗ§ēŽÆå¢ƒå»ŗč®®ä½æē”Øåå‘ä»£ē†é…ē½® HTTPS怂 - -
+PicoClaw ę”ÆęŒå¤šē§čŠå¤©å¹³å°ļ¼Œä½æę‚Øēš„ Agent čƒ½å¤ŸčæžęŽ„åˆ°ä»»ä½•åœ°ę–¹ć€‚ + +### ę øåæƒęø é“ + +| 渠道 | 设置难度 | ē‰¹ę€§čÆ“ę˜Ž | ę–‡ę”£é“¾ęŽ„ | +| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ ē®€å• | ęŽØčļ¼Œę”ÆęŒčÆ­éŸ³č½¬ę–‡å­—ļ¼Œé•æč½®čÆ¢ę— éœ€å…¬ē½‘ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/telegram/README.zh.md) | +| **Discord** | ⭐ ē®€å• | Socket Modeļ¼Œę”ÆęŒē¾¤ē»„/私俔,Bot ē”Ÿę€ęˆē†Ÿ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/discord/README.zh.md) | +| **Slack** | ⭐ ē®€å• | **Socket Mode** (ę— éœ€å…¬ē½‘ IP)ļ¼Œä¼äøšēŗ§ę”ÆęŒ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/slack/README.zh.md) | +| **QQ** | ⭐⭐ äø­ē­‰ | å®˜ę–¹ęœŗå™Øäŗŗ APIļ¼Œé€‚åˆå›½å†…ē¤¾ē¾¤ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/qq/README.zh.md) | +| **钉钉 (DingTalk)** | ⭐⭐ äø­ē­‰ | Stream ęØ”å¼ę— éœ€å…¬ē½‘ļ¼Œä¼äøšåŠžå…¬é¦–é€‰ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/dingtalk/README.zh.md) | +| **企业微俔 (WeCom)** | ⭐⭐⭐ 较难 | ę”ÆęŒē¾¤ęœŗå™Øäŗŗ(Webhook)å’Œč‡Ŗå»ŗåŗ”ē”Ø(API) | [Bot 文攣](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文攣](docs/channels/wecom/wecom_app/README.zh.md) | +| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | ä¼äøšēŗ§åä½œļ¼ŒåŠŸčƒ½äø°åÆŒ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/feishu/README.zh.md) | +| **Line** | ⭐⭐⭐ 较难 | éœ€č¦ HTTPS Webhook | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/line/README.zh.md) | +| **OneBot** | ⭐⭐ äø­ē­‰ | 兼容 NapCat/Go-CQHTTPļ¼Œē¤¾åŒŗē”Ÿę€äø°åÆŒ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/onebot/README.zh.md) | +| **MaixCam** | ⭐ ē®€å• | äø“äøŗ AI ę‘„åƒå¤“č®¾č®”ēš„ē”¬ä»¶é›†ęˆé€šé“ | [ęŸ„ēœ‹ę–‡ę”£](docs/channels/maixcam/README.zh.md) | ## ClawdChat 加兄 Agent ē¤¾äŗ¤ē½‘ē»œ åŖéœ€é€ščæ‡ CLI ęˆ–ä»»ä½•é›†ęˆēš„čŠå¤©åŗ”ē”Øå‘é€äø€ę”ę¶ˆęÆļ¼Œå³åÆå°† PicoClaw čæžęŽ„åˆ° Agent ē¤¾äŗ¤ē½‘ē»œć€‚ -**é˜…čÆ» `https://clawdchat.ai/skill.md` å¹¶ęŒ‰ē…§čÆ“ę˜ŽåŠ å…„ [ClawdChat.ai](https://clawdchat.ai) +\*\*é˜…čÆ» `https://clawdchat.ai/skill.md` å¹¶ęŒ‰ē…§čÆ“ę˜ŽåŠ å…„ [ClawdChat.ai](https://clawdchat.ai) ## āš™ļø é…ē½®čÆ¦č§£ @@ -567,7 +337,6 @@ PicoClaw åÆä»„č‡ŖåŠØę‰§č”Œå‘ØęœŸę€§ä»»åŠ”ć€‚åœØå·„ä½œåŒŗåˆ›å»ŗ `HEARTBEAT.md` - Check my email for important messages - Review my calendar for upcoming events - Check the weather forecast - ``` Agent å°†ęÆéš” 30 åˆ†é’Ÿļ¼ˆåÆé…ē½®ļ¼‰čÆ»å–ę­¤ę–‡ä»¶ļ¼Œå¹¶ä½æē”ØåÆē”Øå·„å…·ę‰§č”Œä»»åŠ”ć€‚ @@ -580,22 +349,23 @@ Agent å°†ęÆéš” 30 åˆ†é’Ÿļ¼ˆåÆé…ē½®ļ¼‰čÆ»å–ę­¤ę–‡ä»¶ļ¼Œå¹¶ä½æē”ØåÆē”Øå·„å…· # Periodic Tasks ## Quick Tasks (respond directly) + - Report current time ## Long Tasks (use spawn for async) + - Search the web for AI news and summarize - Check email and report important messages - ``` **å…³é”®č”Œäøŗļ¼š** -| 特性 | ęčæ° | -| --- | --- | -| **spawn** | åˆ›å»ŗå¼‚ę­„å­ Agentļ¼Œäøé˜»å”žäø»åæƒč·³čæ›ēØ‹ | -| **ē‹¬ē«‹äøŠäø‹ę–‡** | 子 Agent ę‹„ęœ‰ē‹¬ē«‹äøŠäø‹ę–‡ļ¼Œę— ä¼ščÆåŽ†å² | +| 特性 | ęčæ° | +| ---------------- | ---------------------------------------- | +| **spawn** | åˆ›å»ŗå¼‚ę­„å­ Agentļ¼Œäøé˜»å”žäø»åæƒč·³čæ›ēØ‹ | +| **ē‹¬ē«‹äøŠäø‹ę–‡** | 子 Agent ę‹„ęœ‰ē‹¬ē«‹äøŠäø‹ę–‡ļ¼Œę— ä¼ščÆåŽ†å² | | **message tool** | 子 Agent é€ščæ‡ message å·„å…·ē›“ęŽ„äøŽē”Øęˆ·é€šäæ” | -| **非阻唞** | spawn åŽļ¼Œåæƒč·³ē»§ē»­å¤„ē†äø‹äø€äøŖä»»åŠ” | +| **非阻唞** | spawn åŽļ¼Œåæƒč·³ē»§ē»­å¤„ē†äø‹äø€äøŖä»»åŠ” | #### 子 Agent é€šäæ”åŽŸē† @@ -625,35 +395,34 @@ Agent čÆ»å– HEARTBEAT.md "interval": 30 } } - ``` -| 选锹 | é»˜č®¤å€¼ | ęčæ° | -| --- | --- | --- | -| `enabled` | `true` | 启用/ē¦ē”Øåæƒč·³ | -| `interval` | `30` | ę£€ęŸ„é—“éš”ļ¼Œå•ä½åˆ†é’Ÿ (ęœ€å°: 5) | +| 选锹 | é»˜č®¤å€¼ | ęčæ° | +| ---------- | ------ | ---------------------------- | +| `enabled` | `true` | 启用/ē¦ē”Øåæƒč·³ | +| `interval` | `30` | ę£€ęŸ„é—“éš”ļ¼Œå•ä½åˆ†é’Ÿ (ęœ€å°: 5) | **ēŽÆå¢ƒå˜é‡:** -* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 -* `PICOCLAW_HEARTBEAT_INTERVAL=60` ę›“ę”¹é—“éš” +- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` ę›“ę”¹é—“éš” ### ęä¾›å•† (Providers) > [!NOTE] > Groq é€ščæ‡ Whisper ęä¾›å…č“¹ēš„čÆ­éŸ³č½¬å½•ć€‚å¦‚ęžœé…ē½®äŗ† Groq,Telegram čÆ­éŸ³ę¶ˆęÆå°†č¢«č‡ŖåŠØč½¬å½•äøŗę–‡å­—ć€‚ -| ęä¾›å•† | 用途 | čŽ·å– API Key | -| --- | --- | --- | -| `gemini` | LLM (Gemini ē›“čæž) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (ę™ŗč°±ē›“čæž) | [bigmodel.cn](bigmodel.cn) | -| `openrouter(待测试)` | LLM (ęŽØčļ¼ŒåÆč®æé—®ę‰€ęœ‰ęØ”åž‹) | [openrouter.ai](https://openrouter.ai) | -| `anthropic(待测试)` | LLM (Claude ē›“čæž) | [console.anthropic.com](https://console.anthropic.com) | -| `openai(待测试)` | LLM (GPT ē›“čæž) | [platform.openai.com](https://platform.openai.com) | -| `deepseek(待测试)` | LLM (DeepSeek ē›“čæž) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `groq` | LLM + **čÆ­éŸ³č½¬å½•** (Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM (Cerebras ē›“čæž) | [cerebras.ai](https://cerebras.ai) | +| ęä¾›å•† | 用途 | čŽ·å– API Key | +| -------------------- | ---------------------------- | -------------------------------------------------------------------- | +| `gemini` | LLM (Gemini ē›“čæž) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (ę™ŗč°±ē›“čæž) | [bigmodel.cn](bigmodel.cn) | +| `openrouter(待测试)` | LLM (ęŽØčļ¼ŒåÆč®æé—®ę‰€ęœ‰ęØ”åž‹) | [openrouter.ai](https://openrouter.ai) | +| `anthropic(待测试)` | LLM (Claude ē›“čæž) | [console.anthropic.com](https://console.anthropic.com) | +| `openai(待测试)` | LLM (GPT ē›“čæž) | [platform.openai.com](https://platform.openai.com) | +| `deepseek(待测试)` | LLM (DeepSeek ē›“čæž) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **čÆ­éŸ³č½¬å½•** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras ē›“čæž) | [cerebras.ai](https://cerebras.ai) | ### ęØ”åž‹é…ē½® (model_list) @@ -668,25 +437,25 @@ Agent čÆ»å– HEARTBEAT.md #### šŸ“‹ ę‰€ęœ‰ę”ÆęŒēš„åŽ‚å•† -| 厂商 | `model` å‰ē¼€ | 默认 API Base | åč®® | čŽ·å– API Key | -|------|-------------|---------------|------|--------------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [čŽ·å–åÆ†é’„](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [čŽ·å–åÆ†é’„](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [čŽ·å–åÆ†é’„](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ęœ¬åœ°ļ¼ˆę— éœ€åÆ†é’„ļ¼‰ | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://cerebras.ai) | -| **ē«å±±å¼•ę“Ž** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [čŽ·å–åÆ†é’„](https://console.volcengine.com) | -| **ē„žē®—äŗ‘** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | č‡Ŗå®šä¹‰ | 仅 OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | +| 厂商 | `model` å‰ē¼€ | 默认 API Base | åč®® | čŽ·å– API Key | +| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [čŽ·å–åÆ†é’„](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [čŽ·å–åÆ†é’„](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [čŽ·å–åÆ†é’„](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ęœ¬åœ°ļ¼ˆę— éœ€åÆ†é’„ļ¼‰ | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://openrouter.ai/keys) | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [čŽ·å–åÆ†é’„](https://cerebras.ai) | +| **ē«å±±å¼•ę“Ž** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [čŽ·å–åÆ†é’„](https://console.volcengine.com) | +| **ē„žē®—äŗ‘** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **Antigravity** | `antigravity/` | Google Cloud | č‡Ŗå®šä¹‰ | 仅 OAuth | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### åŸŗē”€é…ē½®ē¤ŗä¾‹ @@ -720,6 +489,7 @@ Agent čÆ»å– HEARTBEAT.md #### å„åŽ‚å•†é…ē½®ē¤ŗä¾‹ **OpenAI** + ```json { "model_name": "gpt-5.2", @@ -729,6 +499,7 @@ Agent čÆ»å– HEARTBEAT.md ``` **智谱 AI (GLM)** + ```json { "model_name": "glm-4.7", @@ -738,6 +509,7 @@ Agent čÆ»å– HEARTBEAT.md ``` **DeepSeek** + ```json { "model_name": "deepseek-chat", @@ -747,6 +519,7 @@ Agent čÆ»å– HEARTBEAT.md ``` **Anthropic (使用 OAuth)** + ```json { "model_name": "claude-sonnet-4.6", @@ -754,9 +527,11 @@ Agent čÆ»å– HEARTBEAT.md "auth_method": "oauth" } ``` + > 运蔌 `picoclaw auth login --provider anthropic` ę„č®¾ē½® OAuth 凭证。 **Ollama (本地)** + ```json { "model_name": "llama3", @@ -765,6 +540,7 @@ Agent čÆ»å– HEARTBEAT.md ``` **č‡Ŗå®šä¹‰ä»£ē†/API** + ```json { "model_name": "my-custom-model", @@ -802,6 +578,7 @@ Agent čÆ»å– HEARTBEAT.md ę—§ēš„ `providers` é…ē½®ę ¼å¼**å·²å¼ƒē”Ø**ļ¼Œä½†äøŗå‘åŽå…¼å®¹ä»ę”ÆęŒć€‚ **ę—§é…ē½®ļ¼ˆå·²å¼ƒē”Øļ¼‰ļ¼š** + ```json { "providers": { @@ -820,6 +597,7 @@ Agent čÆ»å– HEARTBEAT.md ``` **ę–°é…ē½®ļ¼ˆęŽØčļ¼‰ļ¼š** + ```json { "model_list": [ @@ -844,7 +622,7 @@ Agent čÆ»å– HEARTBEAT.md **1. čŽ·å– API key 和 base URL** -* čŽ·å– [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) +- čŽ·å– [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. é…ē½®** @@ -866,7 +644,6 @@ Agent čÆ»å– HEARTBEAT.md } } } - ``` **3. 运蔌** @@ -946,30 +723,29 @@ picoclaw agent -m "你儽" "interval": 30 } } - ```
## CLI å‘½ä»¤č”Œå‚č€ƒ -| 命令 | ęčæ° | -| --- | --- | -| `picoclaw onboard` | åˆå§‹åŒ–é…ē½®å’Œå·„ä½œåŒŗ | -| `picoclaw agent -m "..."` | äøŽ Agent åÆ¹čÆ | -| `picoclaw agent` | äŗ¤äŗ’å¼čŠå¤©ęØ”å¼ | -| `picoclaw gateway` | åÆåŠØē½‘å…³ (Gateway) | -| `picoclaw status` | ę˜¾ē¤ŗēŠ¶ę€ | -| `picoclaw cron list` | åˆ—å‡ŗę‰€ęœ‰å®šę—¶ä»»åŠ” | -| `picoclaw cron add ...` | ę·»åŠ å®šę—¶ä»»åŠ” | +| 命令 | ęčæ° | +| ------------------------- | ------------------ | +| `picoclaw onboard` | åˆå§‹åŒ–é…ē½®å’Œå·„ä½œåŒŗ | +| `picoclaw agent -m "..."` | äøŽ Agent åÆ¹čÆ | +| `picoclaw agent` | äŗ¤äŗ’å¼čŠå¤©ęØ”å¼ | +| `picoclaw gateway` | åÆåŠØē½‘å…³ (Gateway) | +| `picoclaw status` | ę˜¾ē¤ŗēŠ¶ę€ | +| `picoclaw cron list` | åˆ—å‡ŗę‰€ęœ‰å®šę—¶ä»»åŠ” | +| `picoclaw cron add ...` | ę·»åŠ å®šę—¶ä»»åŠ” | ### å®šę—¶ä»»åŠ” / ꏐ醒 (Scheduled Tasks) PicoClaw é€ščæ‡ `cron` å·„å…·ę”ÆęŒå®šę—¶ęé†’å’Œé‡å¤ä»»åŠ”ļ¼š -* **äø€ę¬”ę€§ęé†’**: "Remind me in 10 minutes" (10åˆ†é’ŸåŽęé†’ęˆ‘) → 10åˆ†é’ŸåŽč§¦å‘äø€ę¬” -* **重复任劔**: "Remind me every 2 hours" (ęÆ2å°ę—¶ęé†’ęˆ‘) → ęÆ2å°ę—¶č§¦å‘ -* **Cron č”Øč¾¾å¼**: "Remind me at 9am daily" (ęÆå¤©äøŠåˆ9ē‚¹ęé†’ęˆ‘) → 使用 cron č”Øč¾¾å¼ +- **äø€ę¬”ę€§ęé†’**: "Remind me in 10 minutes" (10åˆ†é’ŸåŽęé†’ęˆ‘) → 10åˆ†é’ŸåŽč§¦å‘äø€ę¬” +- **重复任劔**: "Remind me every 2 hours" (ęÆ2å°ę—¶ęé†’ęˆ‘) → ęÆ2å°ę—¶č§¦å‘ +- **Cron č”Øč¾¾å¼**: "Remind me at 9am daily" (ęÆå¤©äøŠåˆ9ē‚¹ęé†’ęˆ‘) → 使用 cron č”Øč¾¾å¼ ä»»åŠ”å­˜å‚ØåœØ `~/.picoclaw/workspace/cron/` äø­å¹¶č‡ŖåŠØå¤„ē†ć€‚ @@ -983,7 +759,7 @@ PicoClaw é€ščæ‡ `cron` å·„å…·ę”ÆęŒå®šę—¶ęé†’å’Œé‡å¤ä»»åŠ”ļ¼š ē”Øęˆ·ē¾¤ē»„ļ¼š -Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) +Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) PicoClaw @@ -997,6 +773,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) 1. 在 [https://brave.com/search/api](https://brave.com/search/api) čŽ·å–å…č“¹ API Key (ęÆęœˆ 2000 ę¬”å…č“¹ęŸ„čÆ¢) 2. 添加到 `~/.picoclaw/config.json`: + ```json { "tools": { @@ -1013,11 +790,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) } } } - ``` - - ### é‡åˆ°å†…å®¹čæ‡ę»¤é”™čÆÆ (Content Filtering Errors) ęŸäŗ›ęä¾›å•†ļ¼ˆå¦‚ę™ŗč°±ļ¼‰ęœ‰äø„ę ¼ēš„å†…å®¹čæ‡ę»¤ć€‚å°čÆ•ę”¹å†™ę‚Øēš„é—®é¢˜ęˆ–ä½æē”Øå…¶ä»–ęØ”åž‹ć€‚ @@ -1030,10 +804,10 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) ## šŸ“ API Key 对比 -| ęœåŠ” | å…č“¹å±‚ēŗ§ | é€‚ē”Øåœŗę™Æ | -| --- | --- | --- | -| **OpenRouter** | 200K tokens/月 | å¤šęØ”åž‹čšåˆ (Claude, GPT-4 ē­‰) | -| **智谱 (Zhipu)** | 200K tokens/月 | ęœ€é€‚åˆäø­å›½ē”Øęˆ· | -| **Brave Search** | 2000 欔柄询/月 | ē½‘ē»œęœē“¢åŠŸčƒ½ | -| **Groq** | ęä¾›å…č“¹å±‚ēŗ§ | ęžé€ŸęŽØē† (Llama, Mixtral) | -| **Cerebras** | ęä¾›å…č“¹å±‚ēŗ§ | ęžé€ŸęŽØē† (Llama, Qwen ē­‰) | \ No newline at end of file +| ęœåŠ” | å…č“¹å±‚ēŗ§ | é€‚ē”Øåœŗę™Æ | +| ---------------- | -------------- | ----------------------------- | +| **OpenRouter** | 200K tokens/月 | å¤šęØ”åž‹čšåˆ (Claude, GPT-4 ē­‰) | +| **智谱 (Zhipu)** | 200K tokens/月 | ęœ€é€‚åˆäø­å›½ē”Øęˆ· | +| **Brave Search** | 2000 欔柄询/月 | ē½‘ē»œęœē“¢åŠŸčƒ½ | +| **Groq** | ęä¾›å…č“¹å±‚ēŗ§ | ęžé€ŸęŽØē† (Llama, Mixtral) | +| **Cerebras** | ęä¾›å…č“¹å±‚ēŗ§ | ęžé€ŸęŽØē† (Llama, Qwen ē­‰) | diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md new file mode 100644 index 000000000..1e445d0b0 --- /dev/null +++ b/docs/channels/dingtalk/README.zh.md @@ -0,0 +1,33 @@ +# 钉钉 + +é’‰é’‰ę˜Æé˜æé‡Œå·“å·“ēš„ä¼äøšé€šč®Æå¹³å°ļ¼ŒåœØäø­å›½čŒåœŗäø­å¹æå—ę¬¢čæŽć€‚å®ƒé‡‡ē”Øęµå¼ SDK ę„ē»“ęŒęŒä¹…čæžęŽ„ć€‚ + +## é…ē½® + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ------------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Øé’‰é’‰é¢‘é“ | +| client_id | string | 是 | é’‰é’‰åŗ”ē”Øēš„ Client ID | +| client_secret | string | 是 | é’‰é’‰åŗ”ē”Øēš„ Client Secret | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | + +## 设置流程 + +1. 前往 [é’‰é’‰å¼€ę”¾å¹³å°](https://open.dingtalk.com/) +2. åˆ›å»ŗäø€äøŖä¼äøšå†…éƒØåŗ”ē”Ø +3. ä»Žåŗ”ē”Øč®¾ē½®äø­čŽ·å– Client ID 和 Client Secret +4. é…ē½®OAuthå’Œäŗ‹ä»¶č®¢é˜…(å¦‚éœ€č¦) +5. 将 Client ID 和 Client Secret å”«å…„é…ē½®ę–‡ä»¶äø­ diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md new file mode 100644 index 000000000..5b597eced --- /dev/null +++ b/docs/channels/discord/README.zh.md @@ -0,0 +1,35 @@ +# Discord + +Discord ę˜Æäø€äøŖäø“äøŗē¤¾åŒŗč®¾č®”ēš„å…č“¹čÆ­éŸ³ć€č§†é¢‘å’Œę–‡ęœ¬čŠå¤©åŗ”ē”Øć€‚PicoClaw é€ščæ‡ Discord Bot API čæžęŽ„åˆ° Discord ęœåŠ”å™Øļ¼Œę”ÆęŒęŽ„ę”¶å’Œå‘é€ę¶ˆęÆć€‚ + +## é…ē½® + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "mention_only": false + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ------------ | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Ø Discord 频道 | +| token | string | 是 | Discord ęœŗå™Øäŗŗ Token | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | +| mention_only | bool | 否 | ę˜Æå¦ä»…å“åŗ”ęåŠęœŗå™Øäŗŗēš„ę¶ˆęÆ | + +## 设置流程 + +1. 前往 [Discord å¼€å‘č€…é—Øęˆ·](https://discord.com/developers/applications) åˆ›å»ŗäø€äøŖę–°ēš„åŗ”ē”Ø +2. 启用 Intents: + - Message Content Intent + - Server Members Intent +3. čŽ·å– Bot Token +4. 将 Bot Token å”«å…„é…ē½®ę–‡ä»¶äø­ +5. é‚€čÆ·ęœŗå™ØäŗŗåŠ å…„ęœåŠ”å™Øå¹¶ęŽˆäŗˆåæ…č¦ęƒé™(ä¾‹å¦‚å‘é€ę¶ˆęÆć€čÆ»å–ę¶ˆęÆåŽ†å²ē­‰) diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md new file mode 100644 index 000000000..310827723 --- /dev/null +++ b/docs/channels/feishu/README.zh.md @@ -0,0 +1,37 @@ +# 飞书 + +é£žä¹¦ļ¼ˆå›½é™…ē‰ˆåē§°ļ¼šLarkļ¼‰ę˜Æå­—čŠ‚č·³åŠØę——äø‹ēš„ä¼äøšåä½œå¹³å°ć€‚å®ƒé€ščæ‡äŗ‹ä»¶é©±åŠØēš„ Webhook åŒę—¶ę”ÆęŒäø­å›½å’Œå…Øēƒåø‚åœŗć€‚ + +## é…ē½® + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ------------------ | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Øé£žä¹¦é¢‘é“ | +| app_id | string | 是 | é£žä¹¦åŗ”ē”Øēš„ App ID(仄cli\_开夓) | +| app_secret | string | 是 | é£žä¹¦åŗ”ē”Øēš„ App Secret | +| encrypt_key | string | 否 | äŗ‹ä»¶å›žč°ƒåŠ åÆ†åÆ†é’„ | +| verification_token | string | 否 | ē”ØäŗŽWebhookäŗ‹ä»¶éŖŒčÆēš„Token | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | + +## 设置流程 + +1. 前往 [é£žä¹¦å¼€ę”¾å¹³å°](https://open.feishu.cn/)åˆ›å»ŗåŗ”ē”ØēØ‹åŗ +2. čŽ·å– App ID 和 App Secret +3. é…ē½®äŗ‹ä»¶č®¢é˜…å’ŒWebhook URL +4. č®¾ē½®åŠ åÆ†(åÆé€‰,ē”Ÿäŗ§ēŽÆå¢ƒå»ŗč®®åÆē”Ø) +5. 将 App ID态App Secret态Encrypt Key 和 Verification Token(å¦‚ęžœåÆē”ØåŠ åÆ†) å”«å…„é…ē½®ę–‡ä»¶äø­ diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md new file mode 100644 index 000000000..fd3aa80da --- /dev/null +++ b/docs/channels/line/README.zh.md @@ -0,0 +1,41 @@ +# Line + +PicoClaw é€ščæ‡ LINE Messaging API 配合 Webhook å›žč°ƒåŠŸčƒ½å®žēŽ°åÆ¹ LINE ēš„ę”ÆęŒć€‚ + +## é…ē½® + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| -------------------- | ------ | ---- | ------------------------------------------ | +| enabled | bool | 是 | ę˜Æå¦åÆē”Ø LINE Channel | +| channel_secret | string | 是 | LINE Messaging API ēš„ Channel Secret | +| channel_access_token | string | 是 | LINE Messaging API ēš„ Channel Access Token | +| webhook_host | string | 是 | Webhook ē›‘å¬ēš„äø»ęœŗåœ°å€ (é€šåøøäøŗ 0.0.0.0) | +| webhook_port | int | 是 | Webhook ē›‘å¬ēš„ē«Æå£ (默认为 18791) | +| webhook_path | string | 是 | Webhook ēš„č·Æå¾„ (默认为 /webhook/line) | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | + +## 设置流程 + +1. 前往 [LINE Developers Console](https://developers.line.biz/console/) åˆ›å»ŗäø€äøŖęœåŠ”ęä¾›å•†å’Œäø€äøŖ Messaging API Channel +2. čŽ·å– Channel Secret 和 Channel Access Token +3. é…ē½®Webhook: + - Line要걂Webhook必锻使用HTTPSåč®®ļ¼Œå› ę­¤éœ€č¦éƒØē½²äø€äøŖę”ÆęŒHTTPSēš„ęœåŠ”å™Øļ¼Œęˆ–č€…ä½æē”Øåå‘ä»£ē†å·„å…·å¦‚ngrokå°†ęœ¬åœ°ęœåŠ”å™Øęš“éœ²åˆ°å…¬ē½‘ + - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line` + - 启用 Webhook 并验证 URL +4. 将 Channel Secret 和 Channel Access Token å”«å…„é…ē½®ę–‡ä»¶äø­ diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md new file mode 100644 index 000000000..8d53d4bef --- /dev/null +++ b/docs/channels/maixcam/README.zh.md @@ -0,0 +1,31 @@ +# MaixCam + +MaixCam ę˜Æäø“ē”ØäŗŽčæžęŽ„ēŸ½é€Ÿē§‘ęŠ€ MaixCAM äøŽ MaixCAM2 AI ę‘„åƒč®¾å¤‡ēš„é€šé“ć€‚å®ƒé‡‡ē”Ø TCP å„—ęŽ„å­—å®žēŽ°åŒå‘é€šäæ”ļ¼Œę”ÆęŒč¾¹ē¼˜ AI éƒØē½²åœŗę™Æć€‚ + +## é…ē½® + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "server_address": "0.0.0.0:8899", + "allow_from": [] + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| -------------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Ø MaixCam 频道 | +| server_address | string | 是 | TCP ęœåŠ”å™Øē›‘å¬åœ°å€å’Œē«Æå£ | +| allow_from | array | 否 | 设备IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰č®¾å¤‡ | + +## ä½æē”Øåœŗę™Æ + +MaixCam é€šé“ä½æ PicoClaw čƒ½å¤Ÿä½œäøŗč¾¹ē¼˜č®¾å¤‡ēš„ AI åŽē«Æčæč”Œļ¼š + +- **ę™ŗčƒ½ē›‘ęŽ§** :MaixCAM å‘é€å›¾åƒåø§ļ¼ŒPicoClaw é€ščæ‡č§†č§‰ęØ”åž‹čæ›č”Œåˆ†ęž +- **ē‰©č”ē½‘ęŽ§åˆ¶** ļ¼šč®¾å¤‡å‘é€ä¼ ę„Ÿå™Øę•°ę®ļ¼ŒPicoClaw åč°ƒå“åŗ” +- **离线AI** ļ¼šåœØęœ¬åœ°ē½‘ē»œéƒØē½² PicoClaw å®žēŽ°ä½Žå»¶čæŸęŽØē† diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md new file mode 100644 index 000000000..6195f1c98 --- /dev/null +++ b/docs/channels/onebot/README.zh.md @@ -0,0 +1,31 @@ +# OneBot + +OneBot ę˜Æäø€äøŖé¢å‘ QQ ęœŗå™Øäŗŗēš„å¼€ę”¾åč®®ę ‡å‡†ļ¼Œäøŗå¤šē§ QQ ęœŗå™Øäŗŗå®žēŽ°ļ¼ˆä¾‹å¦‚ go-cqhttp态Miraiļ¼‰ęä¾›äŗ†ē»Ÿäø€ēš„ęŽ„å£ć€‚å®ƒä½æē”Ø WebSocket čæ›č”Œé€šäæ”ć€‚ + +## é…ē½® + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ------------ | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Ø OneBot 频道 | +| ws_url | string | 是 | OneBot ęœåŠ”å™Øēš„ WebSocket URL | +| access_token | string | 否 | čæžęŽ„ OneBot ęœåŠ”å™Øēš„č®æé—®ä»¤ē‰Œ | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | + +## 设置流程 + +1. éƒØē½²äø€äøŖ OneBot å…¼å®¹ēš„å®žēŽ°(例如napcat) +2. é…ē½® OneBot å®žēŽ°ä»„åÆē”Ø WebSocket ęœåŠ”å¹¶č®¾ē½®č®æé—®ä»¤ē‰Œ(å¦‚ęžœéœ€č¦) +3. 将 WebSocket URL å’Œč®æé—®ä»¤ē‰Œå”«å…„é…ē½®ę–‡ä»¶äø­ diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md new file mode 100644 index 000000000..bd774960f --- /dev/null +++ b/docs/channels/qq/README.zh.md @@ -0,0 +1,32 @@ +# QQ + +PicoClaw é€ščæ‡ QQ å¼€ę”¾å¹³å°ēš„å®˜ę–¹ęœŗå™Øäŗŗ API ęä¾›åÆ¹ QQ ēš„ę”ÆęŒć€‚ + +## é…ē½® + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ---------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Ø QQ Channel | +| app_id | string | 是 | QQ ęœŗå™Øäŗŗåŗ”ē”Øēš„ App ID | +| app_secret | string | 是 | QQ ęœŗå™Øäŗŗåŗ”ē”Øēš„ App Secret | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | + +## 设置流程 + +1. 前往 [QQ å¼€ę”¾å¹³å°](https://q.qq.com/) åˆ›å»ŗäø€äøŖęœŗå™Øäŗŗ +2. é€ščæ‡ä»Ŗč”Øē›˜čŽ·å– App ID 和 App Secret +3. å¼€åÆęœŗå™Øäŗŗę²™ē®±ęØ”å¼, å°†ē”Øęˆ·å’Œē¾¤ę·»åŠ åˆ°ę²™ē®±äø­ +4. 将 App ID 和 App Secret å”«å…„é…ē½®ę–‡ä»¶äø­ diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md new file mode 100644 index 000000000..58ebcb566 --- /dev/null +++ b/docs/channels/slack/README.zh.md @@ -0,0 +1,33 @@ +# Slack + +Slack ę˜Æå…Øēƒé¢†å…ˆēš„ä¼äøšēŗ§å³ę—¶é€šč®Æå¹³å°ć€‚PicoClaw 采用 Slack ēš„ Socket Mode å®žēŽ°å®žę—¶åŒå‘é€šäæ”ļ¼Œę— éœ€é…ē½®å…¬å¼€ēš„ Webhook 端点。 + +## é…ē½® + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ---------- | ------ | ---- | -------------------------------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Ø Slack 频道 | +| bot_token | string | 是 | Slack ęœŗå™Øäŗŗēš„ Bot User OAuth Token (仄 xoxb- 开夓) | +| app_token | string | 是 | Slack åŗ”ē”Øēš„ Socket Mode App Level Token (仄 xapp- 开夓) | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | + +## 设置流程 + +1. 前往 [Slack API](https://api.slack.com/) åˆ›å»ŗäø€äøŖę–°ēš„ Slack 应用 +2. 启用 Socket Mode å¹¶čŽ·å– App Level Token +3. 添加 Bot Token Scopes(例如`chat:write`态`im:history`ē­‰) +4. å®‰č£…åŗ”ē”Øåˆ°å·„ä½œåŒŗå¹¶čŽ·å– Bot User OAuth Token +5. 将 Bot Token 和 App Token å”«å…„é…ē½®ę–‡ä»¶äø­ diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md new file mode 100644 index 000000000..d453c68fa --- /dev/null +++ b/docs/channels/telegram/README.zh.md @@ -0,0 +1,33 @@ +# Telegram + +Telegram Channel é€ščæ‡ Telegram ęœŗå™Øäŗŗ API ä½æē”Øé•æč½®čÆ¢å®žēŽ°åŸŗäŗŽęœŗå™Øäŗŗēš„é€šäæ”ć€‚å®ƒę”ÆęŒę–‡ęœ¬ę¶ˆęÆć€åŖ’ä½“é™„ä»¶ļ¼ˆē…§ē‰‡ć€čÆ­éŸ³ć€éŸ³é¢‘ć€ę–‡ę”£ļ¼‰ć€é€ščæ‡ Groq Whisper čæ›č”ŒčÆ­éŸ³č½¬å½•ä»„åŠå†…ē½®å‘½ä»¤å¤„ē†å™Øć€‚ + +## é…ē½® + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "" + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ---------- | ------ | ---- | --------------------------------------------------------- | +| enabled | bool | 是 | ę˜Æå¦åÆē”Ø Telegram 频道 | +| token | string | 是 | Telegram ęœŗå™Øäŗŗ API Token | +| allow_from | array | 否 | ē”Øęˆ·IDē™½åå•ļ¼Œē©ŗč”Øē¤ŗå…č®øę‰€ęœ‰ē”Øęˆ· | +| proxy | string | 否 | čæžęŽ„ Telegram API ēš„ä»£ē† URL (例如 http://127.0.0.1:7890) | + +## 设置流程 + +1. 在 Telegram 中搜瓢 `@BotFather` +2. 发送 `/newbot` å‘½ä»¤å¹¶ęŒ‰ē…§ęē¤ŗåˆ›å»ŗę–°ęœŗå™Øäŗŗ +3. čŽ·å– HTTP API Token +4. 将 Token å”«å…„é…ē½®ę–‡ä»¶äø­ +5. (åÆé€‰) é…ē½® `allow_from` ä»„é™åˆ¶å…č®øäŗ’åŠØēš„ē”Øęˆ· ID (åÆé€ščæ‡ `@userinfobot` čŽ·å– ID) diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md new file mode 100644 index 000000000..1e6a0e2b3 --- /dev/null +++ b/docs/channels/wecom/wecom_app/README.zh.md @@ -0,0 +1,47 @@ +# ä¼äøšå¾®äæ”č‡Ŗå»ŗåŗ”ē”Ø + +ä¼äøšå¾®äæ”č‡Ŗå»ŗåŗ”ē”Øę˜ÆęŒ‡ä¼äøšåœØä¼äøšå¾®äæ”äø­åˆ›å»ŗēš„åŗ”ē”Øļ¼Œäø»č¦ē”ØäŗŽä¼äøšå†…éƒØä½æē”Øć€‚é€ščæ‡ä¼äøšå¾®äæ”č‡Ŗå»ŗåŗ”ē”Øļ¼Œä¼äøšåÆä»„å®žēŽ°äøŽå‘˜å·„ēš„é«˜ę•ˆę²Ÿé€šå’Œåä½œļ¼Œęé«˜å·„ä½œę•ˆēŽ‡ć€‚ + +## é…ē½® + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18792, + "webhook_path": "/webhook/wecom-app", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ---------------- | ------ | ---- | ---------------------------------------- | +| corp_id | string | 是 | 企业 ID | +| corp_secret | string | 是 | åŗ”ē”ØēØ‹åŗåÆ†é’„ | +| agent_id | int | 是 | åŗ”ē”ØēØ‹åŗä»£ē† ID | +| token | string | 是 | å›žč°ƒéŖŒčÆä»¤ē‰Œ | +| encoding_aes_key | string | 是 | 43 字符 AES 密钄 | +| webhook_host | string | 否 | HTTP ęœåŠ”å™Øē»‘å®šåœ°å€ | +| webhook_port | int | 否 | HTTP ęœåŠ”å™Øē«Æå£ļ¼ˆé»˜č®¤ļ¼š18792) | +| webhook_path | string | 否 | Webhook č·Æå¾„ļ¼ˆé»˜č®¤ļ¼š/webhook/wecom-app) | +| allow_from | array | 否 | ē”Øęˆ· ID ē™½åå• | +| reply_timeout | int | 否 | å›žå¤č¶…ę—¶ę—¶é—“ļ¼ˆē§’ļ¼‰ | + +## 设置流程 + +1. 登录 [ä¼äøšå¾®äæ”ē®”ē†åŽå°](https://work.weixin.qq.com/) +2. čæ›å…„ā€œåŗ”ē”Øē®”ē†ā€ -> ā€œåˆ›å»ŗåŗ”ē”Øā€ +3. čŽ·å–ä¼äøš ID (CorpID) å’Œåŗ”ē”Ø Secret +4. åœØåŗ”ē”Øč®¾ē½®äø­é…ē½®ā€œęŽ„ę”¶ę¶ˆęÆā€ļ¼ŒčŽ·å– Token 和 EncodingAESKey +5. č®¾ē½®å›žč°ƒ URL äøŗ `http://:/webhook/wecom-app` +6. 将 CorpID, Secret, AgentID ē­‰äæ”ęÆå”«å…„é…ē½®ę–‡ä»¶ diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md new file mode 100644 index 000000000..c4bb1c87e --- /dev/null +++ b/docs/channels/wecom/wecom_bot/README.zh.md @@ -0,0 +1,41 @@ +# ä¼äøšå¾®äæ”ęœŗå™Øäŗŗ + +ä¼äøšå¾®äæ”ęœŗå™Øäŗŗę˜Æä¼äøšå¾®äæ”ęä¾›ēš„äø€ē§åæ«é€ŸęŽ„å…„ę–¹å¼ļ¼ŒåÆä»„é€ščæ‡ Webhook URL ęŽ„ę”¶ę¶ˆęÆć€‚ + +## é…ē½® + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18793, + "webhook_path": "/webhook/wecom", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| 字段 | ē±»åž‹ | 必唫 | ęčæ° | +| ---------------- | ------ | ---- | -------------------------------------------- | +| token | string | 是 | ē­¾åéŖŒčÆä»£åø | +| encoding_aes_key | string | 是 | ē”ØäŗŽč§£åÆ†ēš„ 43 字符 AES 密钄 | +| webhook_url | string | 是 | ē”ØäŗŽå‘é€å›žå¤ēš„ä¼äøšå¾®äæ”ē¾¤čŠęœŗå™Øäŗŗ Webhook URL | +| webhook_host | string | 否 | HTTP ęœåŠ”å™Øē»‘å®šåœ°å€ļ¼ˆé»˜č®¤ļ¼š0.0.0.0) | +| webhook_port | int | 否 | HTTP ęœåŠ”å™Øē«Æå£ļ¼ˆé»˜č®¤ļ¼š18793) | +| webhook_path | string | 否 | Webhook ē«Æē‚¹č·Æå¾„ļ¼ˆé»˜č®¤ļ¼š/webhook/wecom) | +| allow_from | array | 否 | ē”Øęˆ· ID ē™½åå•ļ¼ˆē©ŗå€¼ = å…č®øę‰€ęœ‰ē”Øęˆ·ļ¼‰ | +| reply_timeout | int | 否 | å›žå¤č¶…ę—¶ę—¶é—“ļ¼ˆå•ä½ļ¼šē§’ļ¼Œé»˜č®¤å€¼ļ¼š5) | + +## 设置流程 + +1. åœØä¼äøšå¾®äæ”ē¾¤äø­ę·»åŠ ęœŗå™Øäŗŗ +2. čŽ·å– Webhook URL +3. (å¦‚éœ€ęŽ„ę”¶ę¶ˆęÆ) åœØęœŗå™Øäŗŗé…ē½®é”µé¢č®¾ē½®ęŽ„ę”¶ę¶ˆęÆēš„ API åœ°å€ļ¼ˆå›žč°ƒåœ°å€ļ¼‰ä»„åŠ Token 和 EncodingAESKey +4. å°†ē›øå…³äæ”ęÆå”«å…„é…ē½®ę–‡ä»¶ From aea4f25c8387aee5e16b03a126beebbd712ff26d Mon Sep 17 00:00:00 2001 From: zepan Date: Sat, 21 Feb 2026 22:45:47 +0800 Subject: [PATCH 19/21] 1. update wechat qrcode. 2. add CONTRIBUTING.md --- CONTRIBUTING.md | 302 ++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.zh.md | 303 +++++++++++++++++++++++++++++++++++++++++++++ assets/wechat.png | Bin 144319 -> 144045 bytes 3 files changed, 605 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 CONTRIBUTING.zh.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..88227f493 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,302 @@ +# Contributing to PicoClaw + +Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing. + +PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Ways to Contribute](#ways-to-contribute) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Making Changes](#making-changes) +- [AI-Assisted Contributions](#ai-assisted-contributions) +- [Pull Request Process](#pull-request-process) +- [Branch Strategy](#branch-strategy) +- [Code Review](#code-review) +- [Communication](#communication) + +--- + +## Code of Conduct + +We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated. + +--- + +## Ways to Contribute + +- **Bug reports** — Open an issue using the bug report template. +- **Feature requests** — Open an issue using the feature request template; discuss before implementing. +- **Code** — Fix bugs or implement features. See the workflow below. +- **Documentation** — Improve READMEs, docs, inline comments, or translations. +- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results. + +For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction. + +--- + +## Getting Started + +1. **Fork** the repository on GitHub. +2. **Clone** your fork locally: + ```bash + git clone https://github.com//picoclaw.git + cd picoclaw + ``` +3. Add the upstream remote: + ```bash + git remote add upstream https://github.com/sipeed/picoclaw.git + ``` + +--- + +## Development Setup + +### Prerequisites + +- Go 1.25 or later +- `make` + +### Build + +```bash +make build # Build binary (runs go generate first) +make generate # Run go generate only +make check # Full pre-commit check: deps + fmt + vet + test +``` + +### Running Tests + +```bash +make test # Run all tests +go test -run TestName -v ./pkg/session/ # Run a single test +go test -bench=. -benchmem -run='^$' ./... # Run benchmarks +``` + +### Code Style + +```bash +make fmt # Format code +make vet # Static analysis +make lint # Full linter run +``` + +All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early. + +--- + +## Making Changes + +### Branching + +Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch: + +```bash +git checkout main +git pull upstream main +git checkout -b your-feature-branch +``` + +Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`. + +### Commits + +- Write clear, concise commit messages in English. +- Use the imperative mood: "Add retry logic" not "Added retry logic". +- Reference the related issue when relevant: `Fix session leak (#123)`. +- Keep commits focused. One logical change per commit is preferred. +- For minor cleanups or typo fixes, squash them into a single commit before opening a PR. +- Refer toĀ https://www.conventionalcommits.org/zh-hans/v1.0.0/ + +### Keeping Up to Date + +Rebase your branch onto upstream `main` before opening a PR: + +```bash +git fetch upstream +git rebase upstream/main +``` + +--- + +## AI-Assisted Contributions + +PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools. + +### Disclosure Is Required + +Every PR must disclose AI involvement using the PR template's **šŸ¤– AI Code Generation** section. There are three levels: + +| Level | Description | +|---|---| +| šŸ¤– Fully AI-generated | AI wrote the code; contributor reviewed and validated it | +| šŸ› ļø Mostly AI-generated | AI produced the draft; contributor made significant modifications | +| šŸ‘Øā€šŸ’» Mostly Human-written | Contributor led; AI provided suggestions or none at all | + +Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution. + +### You Are Responsible for What You Submit + +Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must: + +- **Read and understand** every line of the generated code. +- **Test it** in a real environment (see the Test Environment section of the PR template). +- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully. +- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax. + +PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review. + +### AI-Generated Code Quality Standards + +AI-generated contributions are held to the **same quality bar** as human-written code: + +- It must pass all CI checks (`make check`). +- It must be idiomatic Go and consistent with the existing codebase style. +- It must not introduce unnecessary abstractions, dead code, or over-engineering. +- It must include or update tests where appropriate. + +### Security Review + +AI-generated code requires extra security scrutiny. Pay special attention to: + +- File path handling and sandbox escapes (see commit `244eb0b` for a real example) +- External input validation in channel handlers and tool implementations +- Credential or secret handling +- Command execution (`exec.Command`, shell invocations) + +If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help. + +--- + +## Pull Request Process + +### Before Opening a PR + +- [ ] Run `make check` and ensure it passes locally. +- [ ] Fill in the PR template completely, including the AI disclosure section. +- [ ] Link any related issue(s) in the PR description. +- [ ] Keep the PR focused. Avoid bundling unrelated changes together. + +### PR Template Sections + +The PR template asks for: + +- **Description** — What does this change do and why? +- **Type of Change** — Bug fix, feature, docs, or refactor. +- **AI Code Generation** — Disclosure of AI involvement (required). +- **Related Issue** — Link to the issue this addresses. +- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs). +- **Test Environment** — Hardware, OS, model/provider, and channels used for testing. +- **Evidence** — Optional logs or screenshots demonstrating the change works. +- **Checklist** — Self-review confirmation. + +### PR Size + +Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs. + +--- + +## Branch Strategy + +### Long-Lived Branches + +- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging. +- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`. + +### Requirements to Merge into `main` + +A PR can only be merged when all of the following are satisfied: + +1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green. +2. **Reviewer approval** — At least one maintainer has approved the PR. +3. **No unresolved review comments** — All review threads must be resolved. +4. **PR template is complete** — Including AI disclosure and test environment. + +### Who Can Merge + +Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access. + +### Merge Strategy + +We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.: + +``` +feat: Add Ollama provider support (#491) +``` + +If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion. + +### Release Branches + +When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point: + +- **New features are not backported.** The release branch receives no new functionality after it is cut. +- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release. + +If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers. + +Release branches have stricter protections than `main` and are never directly pushed to under any circumstances. + +--- + +## Code Review + +### For Contributors + +- Respond to review comments within a reasonable time. If you need more time, say so. +- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested"). +- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too. +- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge. + +### For Reviewers + +Review for: + +1. **Correctness** — Does the code do what it claims? Are there edge cases? +2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers. +3. **Architecture** — Is the approach consistent with the existing design? +4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity? +5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful? + +Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong". + + +### Reviewer List +Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table. + +|Function| Reviewer| +|--- |--- | +|Provider|@yinwm | +|Channel |@yinwm | +|Agent |@lxowalle| +|Tools |@lxowalle| +|SKill || +|MCP || +|Optimization|@lxowalle| +|Security|| +|AI CI |@imguoguo| +|UX || +|Document|| + +--- + +## Communication + +- **GitHub Issues** — Bug reports, feature requests, design discussions. +- **GitHub Discussions** — General questions, ideas, community conversation. +- **Pull Request comments** — Code-specific feedback. +- **Wechat&Discord** — We will invite you when you have at least one merged PR + +When in doubt, open an issue before writing code. It costs little and prevents wasted effort. + +--- + +## A Note on the Project's AI-Driven Origin + +PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome. + +We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict. + +Thank you for contributing! diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md new file mode 100644 index 000000000..01a1abfd5 --- /dev/null +++ b/CONTRIBUTING.zh.md @@ -0,0 +1,303 @@ +# å‚äøŽč“”ēŒ® PicoClaw + +ę„Ÿč°¢ä½ åÆ¹ PicoClaw ēš„å…³ę³Øļ¼ęœ¬é”¹ē›®ę˜Æäø€äøŖē¤¾åŒŗé©±åŠØēš„å¼€ęŗé”¹ē›®ļ¼Œē›®ę ‡ę˜Æęž„å»ŗ č½»é‡ēµę“»,äŗŗäŗŗåÆē”Ø ēš„äøŖäŗŗAIåŠ©ę‰‹ć€‚ęˆ‘ä»¬ę¬¢čæŽäø€åˆ‡å½¢å¼ēš„č“”ēŒ®ļ¼šBug äæ®å¤ć€ę–°åŠŸčƒ½ć€ę–‡ę”£ć€ēæ»čÆ‘å’Œęµ‹čÆ•ć€‚ + +PicoClaw ęœ¬čŗ«åœØå¾ˆå¤§ēØ‹åŗ¦äøŠę˜Æå€ŸåŠ© AI č¾…åŠ©å¼€å‘ēš„ā€”ā€”ęˆ‘ä»¬ę‹„ęŠ±čæ™ē§ę–¹å¼ļ¼Œå¹¶å›“ē»•å®ƒęž„å»ŗäŗ†č“”ēŒ®ęµēØ‹ć€‚ + +## 目录 + +- [č”Œäøŗå‡†åˆ™](#č”Œäøŗå‡†åˆ™) +- [č“”ēŒ®ę–¹å¼](#č“”ēŒ®ę–¹å¼) +- [åæ«é€Ÿå¼€å§‹](#åæ«é€Ÿå¼€å§‹) +- [å¼€å‘ēŽÆå¢ƒé…ē½®](#å¼€å‘ēŽÆå¢ƒé…ē½®) +- [ęäŗ¤äæ®ę”¹](#ęäŗ¤äæ®ę”¹) +- [AI č¾…åŠ©č“”ēŒ®](#ai-č¾…åŠ©č“”ēŒ®) +- [Pull Request 流程](#pull-request-流程) +- [åˆ†ę”Æē­–ē•„](#åˆ†ę”Æē­–ē•„) +- [代码宔柄](#代码宔柄) +- [ę²Ÿé€šęø é“](#ę²Ÿé€šęø é“) + +--- + +## č”Œäøŗå‡†åˆ™ + +ęˆ‘ä»¬č‡“åŠ›äŗŽē»“ęŠ¤äø€äøŖå‹å„½ć€äŗ’ē›øå°Šé‡ēš„ē¤¾åŒŗēŽÆå¢ƒć€‚čÆ·äæęŒå–„ę„ć€å»ŗč®¾ę€§ēš„ę€åŗ¦ļ¼Œå¹¶å–„ę„åœ°ē†č§£ä»–äŗŗć€‚ä»»ä½•å½¢å¼ēš„éŖšę‰°ęˆ–ę­§č§†å‡äøč¢«ęŽ„å—ć€‚ + +--- + +## č“”ēŒ®ę–¹å¼ + +- **Bug 反馈** — 使用 Bug ęŠ„å‘ŠęØ”ęæęäŗ¤ Issue怂 +- **功能建议** — ä½æē”ØåŠŸčƒ½čÆ·ę±‚ęØ”ęæęäŗ¤ Issueļ¼Œå»ŗč®®åœØå¼€å§‹å®žēŽ°å‰å…ˆčæ›č”Œč®Øč®ŗć€‚ +- **代码蓔献** — äæ®å¤ Bug ęˆ–å®žēŽ°ę–°åŠŸčƒ½ļ¼Œå‚č§äø‹ę–¹å·„ä½œęµēØ‹ć€‚ +- **文攣改进** — 完善 READMEć€ę–‡ę”£ć€ä»£ē ę³Øé‡Šęˆ–ēæ»čÆ‘ć€‚ +- **ęµ‹čÆ•äøŽéŖŒčÆ** — åœØę–°ē”¬ä»¶ć€ę–°ęø é“ęˆ–ę–° LLM ęä¾›å•†äøŠčæč”Œ PicoClaw å¹¶åé¦ˆē»“ęžœć€‚ + +åÆ¹äŗŽč¾ƒå¤§ēš„ę–°åŠŸčƒ½ļ¼ŒčÆ·å…ˆęäŗ¤ Issue č®Øč®ŗč®¾č®”ę–¹ę”ˆļ¼Œå†åŠØę‰‹å†™ä»£ē ć€‚čæ™čƒ½éæå…ę— ę•ˆęŠ•å…„ļ¼Œä¹Ÿē”®äæäøŽé”¹ē›®ę–¹å‘äæęŒäø€č‡“ć€‚ + +--- + +## åæ«é€Ÿå¼€å§‹ + +1. 在 GitHub 上 **Fork** ęœ¬ä»“åŗ“ć€‚ +2. å°†ä½ ēš„ Fork **克隆**到本地: + ```bash + git clone https://github.com/<ä½ ēš„ē”Øęˆ·å>/picoclaw.git + cd picoclaw + ``` +3. ę·»åŠ äøŠęøøčæœēØ‹ä»“åŗ“ļ¼š + ```bash + git remote add upstream https://github.com/sipeed/picoclaw.git + ``` + +--- + +## å¼€å‘ēŽÆå¢ƒé…ē½® + +### å‰ē½®ä¾čµ– + +- Go 1.25 ęˆ–ę›“é«˜ē‰ˆęœ¬ +- `make` + +### ęž„å»ŗ + +```bash +make build # ęž„å»ŗäŗŒčæ›åˆ¶ę–‡ä»¶ļ¼ˆä¼šå…ˆę‰§č”Œ go generate) +make generate # ä»…ę‰§č”Œ go generate +make check # å®Œę•“ēš„ęäŗ¤å‰ę£€ęŸ„ļ¼šdeps + fmt + vet + test +``` + +### čæč”Œęµ‹čÆ• + +```bash +make test # čæč”Œę‰€ęœ‰ęµ‹čÆ• +go test -run TestName -v ./pkg/session/ # čæč”Œå•äøŖęµ‹čÆ• +go test -bench=. -benchmem -run='^$' ./... # čæč”ŒåŸŗå‡†ęµ‹čÆ• +``` + +### ä»£ē é£Žę ¼ + +```bash +make fmt # ę ¼å¼åŒ–ä»£ē  +make vet # é™ę€åˆ†ęž +make lint # å®Œę•“ēš„ lint ę£€ęŸ„ +``` + +ꉀ꜉ CI ę£€ęŸ„é€ščæ‡åŽ PR ę‰čƒ½č¢«åˆå¹¶ć€‚ęŽØé€ä»£ē å‰čÆ·å…ˆåœØęœ¬åœ°čæč”Œ `make check`ļ¼Œęå‰å‘ēŽ°é—®é¢˜ć€‚ + +--- + +## ęäŗ¤äæ®ę”¹ + +### åˆ†ę”Æē®”ē† + +å§‹ē»ˆä»Ž `main` åˆ†ę”Æåˆ‡å‡ŗļ¼Œå¹¶åœØ PR 中仄 `main` äøŗē›®ę ‡åˆ†ę”Æć€‚äøč¦ē›“ęŽ„å‘ `main` ęˆ–ä»»ä½• `release/*` åˆ†ę”ÆęŽØé€ä»£ē ļ¼š + +```bash +git checkout main +git pull upstream main +git checkout -b ä½ ēš„åŠŸčƒ½åˆ†ę”Æå +``` + +čÆ·ä½æē”Øęčæ°ę€§ēš„åˆ†ę”Æåļ¼Œä¾‹å¦‚ļ¼š`fix/telegram-timeout`态`feat/ollama-provider`态`docs/contributing-guide`怂 + +### Commit 规范 + +- ä½æē”Øč‹±ę–‡ę’°å†™ęø…ę™°ć€ē®€ę“ēš„ commit 俔息。 +- ä½æē”Øē„ˆä½æå„ļ¼šå†™ "Add retry logic"ļ¼Œč€Œäøę˜Æ "Added retry logic"怂 +- ęœ‰å…³č” Issue ę—¶čÆ·å¼•ē”Øļ¼š`Fix session leak (#123)`怂 +- äæęŒ commit äø“ę³Øļ¼ŒęÆäøŖ commit åŖåšäø€ä»¶äŗ‹ć€‚ +- åÆ¹äŗŽå°ēš„ęø…ē†ęˆ–ę‹¼å†™äæ®ę­£ļ¼Œę PR å‰čÆ·å°†å…¶åˆå¹¶äøŗäø€äøŖ commit怂 +- ęŒ‰ē…§Ā https://www.conventionalcommits.org/zh-hans/v1.0.0/Ā č§„čŒƒę„ę’°å†™ + +### äæęŒäøŽäøŠęøøåŒę­„ + +ꏐ PR å‰ļ¼ŒčÆ·å°†ä½ ēš„åˆ†ę”Æå˜åŸŗåˆ°äøŠęøø `main`: + +```bash +git fetch upstream +git rebase upstream/main +``` + +--- + +## AI č¾…åŠ©č“”ēŒ® + +PicoClaw åœØå¾ˆå¤§ēØ‹åŗ¦äøŠå€ŸåŠ© AI č¾…åŠ©å¼€å‘ļ¼Œęˆ‘ä»¬å®Œå…Øę‹„ęŠ±čæ™ē§å¼€å‘ę–¹å¼ć€‚ä½†č“”ēŒ®č€…åæ…é”»ęø…ę„šåœ°äŗ†č§£č‡Ŗå·±åœØä½æē”Ø AI å·„å…·ę—¶ę‰€ę‰æę‹…ēš„č“£ä»»ć€‚ + +### åæ…é”»ęŠ«éœ² AI ä½æē”Øęƒ…å†µ + +ęÆäøŖ PR éƒ½åæ…é”»é€ščæ‡ PR ęØ”ęæäø­ēš„ **šŸ¤– AI ä»£ē ē”Ÿęˆ** éƒØåˆ†ęŠ«éœ² AI å‚äøŽęƒ…å†µļ¼Œå…±åˆ†äø‰äøŖēŗ§åˆ«ļ¼š + +| 级别 | čÆ“ę˜Ž | +|---|---| +| šŸ¤– å®Œå…Øē”± AI ē”Ÿęˆ | AI ē¼–å†™ä»£ē ļ¼Œč“”ēŒ®č€…č“Ÿč“£å®”ęŸ„å’ŒéŖŒčÆ | +| šŸ› ļø 主要由 AI ē”Ÿęˆ | AI čµ·č‰ļ¼Œč“”ēŒ®č€…åšäŗ†č¾ƒå¤§äæ®ę”¹ | +| šŸ‘Øā€šŸ’» 主要由人巄编写 | č“”ēŒ®č€…äø»åÆ¼ļ¼ŒAI ä»…ęä¾›č¾…åŠ©ęˆ–ęœŖä½æē”Ø AI | + +ęˆ‘ä»¬ęœŸęœ›ä½ čÆšå®žå”«å†™ć€‚äø‰ē§ēŗ§åˆ«å‡åÆęŽ„å—ļ¼Œę²”ęœ‰ä»»ä½•ę­§č§†ā€”ā€”é‡č¦ēš„ę˜Æč“”ēŒ®ēš„č“Øé‡ć€‚ + +### ä½ åÆ¹ęäŗ¤ēš„ä»£ē č“Ÿå…Øč“£ + +使用 AI ē”Ÿęˆä»£ē å¹¶äøčƒ½å‡č½»ä½ ä½œäøŗč“”ēŒ®č€…ēš„č“£ä»»ć€‚åœØęäŗ¤å«ęœ‰ AI ē”Ÿęˆä»£ē ēš„ PR ä¹‹å‰ļ¼Œä½ åæ…é”»ļ¼š + +- **é€č”Œé˜…čÆ»å¹¶ē†č§£**ē”Ÿęˆēš„ä»£ē ć€‚ +- **åœØēœŸå®žēŽÆå¢ƒäø­ęµ‹čÆ•**ļ¼ˆå‚č§ PR ęØ”ęæäø­ēš„ęµ‹čÆ•ēŽÆå¢ƒéƒØåˆ†ļ¼‰ć€‚ +- **ę£€ęŸ„å®‰å…Øé—®é¢˜** — AI ęØ”åž‹åÆčƒ½ē”Ÿęˆå­˜åœØå®‰å…Øéšę‚£ēš„ä»£ē ļ¼ˆå¦‚č·Æå¾„ē©æč¶Šć€ę³Øå…„ę”»å‡»ć€å‡­ę®ę³„éœ²ē­‰ļ¼‰ļ¼ŒčÆ·ä»”ē»†å®”ęŸ„ć€‚ +- **éŖŒčÆę­£ē”®ę€§** — AI ē”Ÿęˆēš„é€»č¾‘åÆčƒ½å¬čµ·ę„åˆē†ä½†å®žé™…äøŠę˜Æé”™čÆÆēš„ļ¼ŒčÆ·éŖŒčÆč”Œäøŗļ¼Œč€Œäøä»…ä»…ę˜ÆčÆ­ę³•ć€‚ + +å¦‚ęžœę˜Žę˜¾åÆä»„ēœ‹å‡ŗč“”ēŒ®č€…ę²”ęœ‰é˜…čÆ»ęˆ–ęµ‹čÆ• AI ē”Ÿęˆēš„ä»£ē ļ¼ŒčÆ„ PR å°†č¢«ē›“ęŽ„å…³é—­ļ¼Œäøäŗˆå®”ęŸ„ć€‚ + +### AI ē”Ÿęˆä»£ē ēš„č“Øé‡ę ‡å‡† + +AI ē”Ÿęˆēš„ä»£ē äøŽäŗŗå·„ē¼–å†™ēš„ä»£ē éµå¾Ŗ**ē›øåŒēš„č“Øé‡č¦ę±‚**: + +- åæ…é”»é€ščæ‡ę‰€ęœ‰ CI ę£€ęŸ„ļ¼ˆ`make check`)。 +- åæ…é”»ē¬¦åˆ Go ęƒÆē”Øå†™ę³•ļ¼Œå¹¶äøŽēŽ°ęœ‰ä»£ē åŗ“ēš„é£Žę ¼äæęŒäø€č‡“ć€‚ +- äøå¾—å¼•å…„äøåæ…č¦ēš„ęŠ½č±”ć€ę­»ä»£ē ęˆ–čæ‡åŗ¦č®¾č®”ć€‚ +- é”»åœØé€‚å½“ēš„åœ°ę–¹åŒ…å«ęˆ–ę›“ę–°ęµ‹čÆ•ć€‚ + +### å®‰å…Øå®”ęŸ„ + +AI ē”Ÿęˆēš„ä»£ē éœ€č¦ę ¼å¤–ä»”ē»†ēš„å®‰å…Øå®”ęŸ„ć€‚čÆ·ē‰¹åˆ«å…³ę³Øä»„äø‹ę–¹é¢ļ¼š + +- ę–‡ä»¶č·Æå¾„å¤„ē†äøŽę²™ē®±é€ƒé€øļ¼ˆé”¹ē›®åŽ†å²äø­ēš„ commit `244eb0b` å°±ę˜ÆēœŸå®žę”ˆä¾‹ļ¼‰ +- channel å¤„ē†å™Øå’Œ tool å®žēŽ°äø­ēš„å¤–éƒØč¾“å…„ę ”éŖŒ +- å‡­ę®ęˆ–åÆ†é’„ēš„å¤„ē† +- å‘½ä»¤ę‰§č”Œļ¼ˆ`exec.Command`态shell č°ƒē”Øē­‰ļ¼‰ + +å¦‚ęžœä½ äøē”®å®šęŸę®µ AI ē”Ÿęˆä»£ē ę˜Æå¦å®‰å…Øļ¼ŒčÆ·åœØ PR äø­čÆ“ę˜Žā€”ā€”å®”ęŸ„č€…ä¼šåø®åŠ©åˆ¤ę–­ć€‚ + +--- + +## Pull Request 流程 + +### ꏐ PR å‰ēš„ę£€ęŸ„ + +- [ ] 在本地运蔌 `make check` å¹¶ē”®č®¤é€ščæ‡ć€‚ +- [ ] å®Œę•“å”«å†™ PR ęØ”ęæļ¼ŒåŒ…ę‹¬ AI ęŠ«éœ²éƒØåˆ†ć€‚ +- [ ] 在 PR ęčæ°äø­å…³č”ē›øå…³ Issue怂 +- [ ] äæęŒ PR äø“ę³Øļ¼Œéæå…å°†äøē›øå…³ēš„äæ®ę”¹ę··åœØäø€čµ·ć€‚ + +### PR ęØ”ęæå„éƒØåˆ†čÆ“ę˜Ž + +PR ęØ”ęæč¦ę±‚å”«å†™ļ¼š + +- **ęčæ°** — čæ™äøŖę”¹åŠØåšäŗ†ä»€ä¹ˆļ¼Œäøŗä»€ä¹ˆč¦åšļ¼Ÿ +- **å˜ę›“ē±»åž‹** — Bug äæ®å¤ć€ę–°åŠŸčƒ½ć€ę–‡ę”£ęˆ–é‡ęž„ć€‚ +- **AI ä»£ē ē”Ÿęˆ** — AI å‚äøŽęƒ…å†µęŠ«éœ²ļ¼ˆåæ…å”«ļ¼‰ć€‚ +- **关联 Issue** — ę­¤ PR č§£å†³ēš„ Issue é“¾ęŽ„ć€‚ +- **ęŠ€ęœÆčƒŒę™Æ** — å‚č€ƒé“¾ęŽ„å’Œč®¾č®”ē†ē”±ļ¼ˆēŗÆę–‡ę”£ē±» PR åÆč·³čæ‡ļ¼‰ć€‚ +- **ęµ‹čÆ•ēŽÆå¢ƒ** — ē”ØäŗŽęµ‹čÆ•ēš„ē”¬ä»¶ć€ę“ä½œē³»ē»Ÿć€ęØ”åž‹/ęä¾›å•†å’Œęø é“ć€‚ +- **éŖŒčÆčÆę®** — åÆé€‰ēš„ę—„åæ—ęˆ–ęˆŖå›¾ļ¼Œē”ØäŗŽčÆę˜Žę”¹åŠØęœ‰ę•ˆć€‚ +- **ę£€ęŸ„ęø…å•** — č‡Ŗęˆ‘å®”ęŸ„ē”®č®¤ć€‚ + +### PR 规樔 + +čÆ·å°½é‡ęäŗ¤å°č€Œę˜“äŗŽå®”ęŸ„ēš„ PRć€‚äø€äøŖę¶‰åŠ 5 个文件共 200 č”Œę”¹åŠØēš„ PRļ¼ŒčæœęÆ”ę¶‰åŠ 30 个文件共 2000 č”Œę”¹åŠØēš„ PR å®¹ę˜“å®”ęŸ„ć€‚å¦‚ęžœä½ ēš„åŠŸčƒ½č¾ƒå¤§ļ¼ŒåÆä»„č€ƒč™‘å°†å…¶ę‹†åˆ†äøŗäø€ē³»åˆ—é€»č¾‘å®Œę•“ēš„å° PR怂 + +--- + +## åˆ†ę”Æē­–ē•„ + +### é•æęœŸåˆ†ę”Æ + +- **`main`** — ę“»č·ƒå¼€å‘åˆ†ę”Æć€‚ę‰€ęœ‰åŠŸčƒ½ PR 均仄 `main` äøŗē›®ę ‡ć€‚čÆ„åˆ†ę”Æå—äæęŠ¤ļ¼šē¦ę­¢ē›“ęŽ„ęŽØé€ļ¼Œåˆå¹¶å‰åæ…é”»čŽ·å¾—č‡³å°‘äø€åē»“ęŠ¤č€…ēš„ę‰¹å‡†ć€‚ +- **`release/x.y`** — ēØ³å®šå‘åøƒåˆ†ę”Æļ¼ŒåœØęŸäøŖē‰ˆęœ¬å‡†å¤‡å‘åøƒę—¶ä»Ž `main` åˆ‡å‡ŗć€‚čæ™äŗ›åˆ†ę”Æēš„äæęŠ¤ēŗ§åˆ«é«˜äŗŽ `main`怂 + +### 合并到 `main` ēš„å‰ęę”ä»¶ + +PR åæ…é”»åŒę—¶ę»”č¶³ä»„äø‹ę‰€ęœ‰ę”ä»¶ļ¼Œę‰čƒ½č¢«åˆå¹¶ļ¼š + +1. **CI å…ØéƒØé€ščæ‡** — ꉀ꜉ GitHub Actions 巄作流(lint态test态buildļ¼‰å‡äøŗē»æč‰²ć€‚ +2. **čŽ·å¾—å®”ęŸ„č€…ę‰¹å‡†** — č‡³å°‘äø€åē»“ęŠ¤č€…å·²ę‰¹å‡†čÆ„ PR怂 +3. **ę— ęœŖč§£å†³ēš„å®”ęŸ„ę„č§** — ę‰€ęœ‰å®”ęŸ„č®Øč®ŗēŗæēØ‹å‡å·²å…³é—­ć€‚ +4. **PR ęØ”ęæå”«å†™å®Œę•“** — åŒ…ę‹¬ AI ęŠ«éœ²å’Œęµ‹čÆ•ēŽÆå¢ƒäæ”ęÆć€‚ + +### č°åÆä»„åˆå¹¶ + +åŖęœ‰ē»“ęŠ¤č€…ę‰čƒ½åˆå¹¶ PRć€‚č“”ēŒ®č€…äøčƒ½åˆå¹¶č‡Ŗå·±ēš„ PRļ¼Œå³ä½æę‹„ęœ‰å†™ęƒé™ä¹Ÿäøč”Œć€‚ + +### åˆå¹¶ē­–ē•„ + +äøŗäæęŒ `main` åŽ†å²ęø…ę™°åÆčÆ»ļ¼Œęˆ‘ä»¬åÆ¹å¤§å¤šę•° PR 使用 **Squash Merge**ć€‚ęÆäøŖåˆå¹¶ēš„ PR å˜äøŗäø€äøŖåŒ…å« PR ē¼–å·ēš„å•ē‹¬ commitļ¼Œä¾‹å¦‚ļ¼š + +``` +feat: Add Ollama provider support (#491) +``` + +å¦‚ęžœäø€äøŖ PR åŒ…å«å¤šäøŖē‹¬ē«‹ć€ē»“ęž„ęø…ę™°ć€čƒ½č®²čæ°å®Œę•“ę•…äŗ‹ēš„ commitļ¼Œē»“ęŠ¤č€…åÆč§†ęƒ…å†µä½æē”Øę™®é€š merge怂 + +### Release åˆ†ę”Æ + +å½“ęŸäøŖē‰ˆęœ¬å‡†å¤‡å°±ē»Ŗę—¶ļ¼Œē»“ęŠ¤č€…ä¼šä»Ž `main` åˆ‡å‡ŗ `release/x.y` åˆ†ę”Æć€‚ę­¤åŽļ¼š + +- **ę–°åŠŸčƒ½äøä¼šč¢«å›žęŗÆļ¼ˆbackport)。** Release åˆ†ę”Æåˆ‡å‡ŗåŽļ¼Œäøå†ęŽ„ę”¶ä»»ä½•ę–°åŠŸčƒ½ć€‚ +- **å®‰å…Øäæ®å¤å’Œå…³é”® Bug äæ®å¤ä¼šč¢« cherry-pick čæ›ę„ć€‚** č‹„ `main` äøŠēš„ęŸäøŖäæ®å¤å±žäŗŽå®‰å…Øę¼ę“žć€ę•°ę®äø¢å¤±ęˆ–å“©ęŗƒē±»é—®é¢˜ļ¼Œē»“ęŠ¤č€…ä¼šå°†ē›øå…³ commit cherry-pick åˆ°å—å½±å“ēš„ `release/x.y` åˆ†ę”Æļ¼Œå¹¶å‘åøƒč”„äøē‰ˆęœ¬ć€‚ + +å¦‚ęžœä½ č®¤äøŗ `main` äøŠēš„ęŸäøŖäæ®å¤åŗ”čÆ„č¢«å›žęŗÆåˆ°ęŸäøŖ release åˆ†ę”Æļ¼ŒčÆ·åœØ PR ęčæ°äø­ę³Øę˜Žļ¼Œęˆ–å•ē‹¬å¼€äø€äøŖ Issue čÆ“ę˜Žć€‚ęœ€ē»ˆå†³å®šē”±ē»“ęŠ¤č€…åšå‡ŗć€‚ + +Release åˆ†ę”Æēš„äæęŠ¤ēŗ§åˆ«é«˜äŗŽ `main`ļ¼ŒåœØä»»ä½•ęƒ…å†µäø‹å‡äøå…č®øē›“ęŽ„ęŽØé€ć€‚ + +--- + +## 代码宔柄 + +### åÆ¹č“”ēŒ®č€…ēš„å»ŗč®® + +- åœØåˆē†ę—¶é—“å†…å›žå¤å®”ęŸ„ę„č§ć€‚å¦‚ęžœéœ€č¦ę›“å¤šę—¶é—“ļ¼ŒčÆ·å‘ŠēŸ„ć€‚ +- ꛓꖰ PR ä»„å“åŗ”åé¦ˆę—¶ļ¼Œē®€č¦čÆ“ę˜Žę”¹åŠØå†…å®¹ļ¼ˆä¾‹å¦‚ļ¼š"ęŒ‰å»ŗč®®ę”¹ē”Øäŗ† `sync.RWMutex`")。 +- å¦‚ęžœä½ äøåŒę„ęŸę”åé¦ˆļ¼ŒčÆ·ē¤¼č²Œåœ°é˜čæ°ä½ ēš„ē†ē”±ā€”ā€”å®”ęŸ„č€…ä¹ŸåÆčƒ½ęœ‰åˆ¤ę–­å¤±čÆÆēš„ę—¶å€™ć€‚ +- å®”ęŸ„å¼€å§‹åŽčÆ·äøč¦ force pushā€”ā€”čæ™ä¼šč®©å®”ęŸ„č€…éš¾ä»„čæ½čøŖå˜åŒ–ć€‚čÆ·ä½æē”Øé¢å¤–ēš„ commitļ¼Œē»“ęŠ¤č€…åœØåˆå¹¶ę—¶ä¼ščæ›č”Œ squash怂 + +### åÆ¹å®”ęŸ„č€…ēš„å»ŗč®® + +å®”ęŸ„é‡ē‚¹ļ¼š + +1. **正甮性** — ä»£ē ę˜Æå¦å®žēŽ°äŗ†å…¶å£°ē§°ēš„åŠŸčƒ½ļ¼Ÿę˜Æå¦å­˜åœØč¾¹ē•Œęƒ…å†µļ¼Ÿ +2. **安全性** — 对 AI ē”Ÿęˆä»£ē ć€tool å®žēŽ°å’Œ channel å¤„ē†å™Øå°¤å…¶éœ€č¦å…³ę³Øć€‚ +3. **ęž¶ęž„** — å®žēŽ°ę–¹å¼ę˜Æå¦äøŽēŽ°ęœ‰č®¾č®”äø€č‡“ļ¼Ÿ +4. **简擁性** — ę˜Æå¦ęœ‰ę›“ē®€å•ēš„ę–¹ę”ˆļ¼Ÿę˜Æå¦å¼•å…„äŗ†äøåæ…č¦ēš„å¤ę‚åŗ¦ļ¼Ÿ +5. **测试** — ę”¹åŠØę˜Æå¦ęœ‰ęµ‹čÆ•č¦†ē›–ļ¼ŸēŽ°ęœ‰ęµ‹čÆ•ę˜Æå¦ä»ē„¶ęœ‰ę„ä¹‰ļ¼Ÿ + +čÆ·ē»™å‡ŗå»ŗč®¾ę€§äø”å…·ä½“ēš„åé¦ˆć€‚"å¦‚ęžœäø¤äøŖ goroutine åŒę—¶č°ƒē”Øčæ™äøŖå‡½ę•°åÆčƒ½ä¼šęœ‰ē«žę€ę”ä»¶ļ¼Œå»ŗč®®åœØčæ™é‡ŒåŠ äø€äøŖ mutex" čæœęÆ” "čæ™é‡Œēœ‹čµ·ę„ęœ‰é—®é¢˜" ę›“ęœ‰åø®åŠ©ć€‚ + +### å®”ęŸ„č€…åˆ—č”Ø +ęäŗ¤åÆ¹åŗ”PRåŽļ¼ŒåÆä»„å‚č€ƒäø‹č”Øč”ē³»åÆ¹åŗ”ēš„å®”ęŸ„äŗŗå‘˜ę²Ÿé€š + +|Function| Reviewer| +|--- |--- | +|Provider|@yinwm | +|Channel |@yinwm | +|Agent |@lxowalle| +|Tools |@lxowalle| +|SKill || +|MCP || +|Optimization|@lxowalle| +|Security|| +|AI CI |@imguoguo| +|UX || +|Document|| + + + +--- + +## ę²Ÿé€šęø é“ + +- **GitHub Issues** — Bug ęŠ„å‘Šć€åŠŸčƒ½å»ŗč®®ć€č®¾č®”č®Øč®ŗć€‚ +- **GitHub Discussions** — äø€čˆ¬ę€§é—®é¢˜ć€ęƒ³ę³•äŗ¤ęµć€ē¤¾åŒŗč®Øč®ŗć€‚ +- **Pull Request 评论** — äøŽå…·ä½“ä»£ē ē›øå…³ēš„åé¦ˆć€‚ +- **Wechat&Discord** — å½“ä½ ęœ‰č‡³å°‘äø€äøŖå·²åˆå¹¶ēš„PRåŽļ¼Œęˆ‘ä»¬ä¼šé‚€čÆ·ä½ åŠ å…„å¼€å‘č€…äŗ¤ęµē¾¤ + +ęœ‰ē–‘é—®ę—¶ļ¼ŒčÆ·å…ˆå¼€ Issue č®Øč®ŗļ¼Œå†åŠØę‰‹å†™ä»£ē ć€‚čæ™å‡ ä¹Žę²”ęœ‰ęˆęœ¬ļ¼Œå“čƒ½éæå…å¤§é‡ę— ę•ˆęŠ•å…„ć€‚ + +--- + +## å…³äŗŽęœ¬é”¹ē›®ēš„ AI 驱动起源 + +PicoClaw ēš„ęž¶ęž„åœØäŗŗå·„ē›‘ē£äø‹ļ¼Œē»ē”± AI č¾…åŠ©å®Œęˆäŗ†å¤§é‡č®¾č®”å’Œå®žēŽ°å·„ä½œć€‚å¦‚ęžœä½ å‘ēŽ°ęŸå¤„ēœ‹čµ·ę„å„‡ę€Ŗęˆ–čæ‡åŗ¦č®¾č®”ļ¼Œčæ™åÆčƒ½ę˜ÆčÆ„čæ‡ēØ‹ē•™äø‹ēš„ē—•čæ¹ā€”ā€”ę¬¢čæŽę Issue 讨论。 + +ęˆ‘ä»¬ē›øäæ”ļ¼Œč“Ÿč“£ä»»åœ°ä½æē”Ø AI č¾…åŠ©å¼€å‘čƒ½äŗ§ē”Ÿä¼˜ē§€ēš„ęˆęžœć€‚ęˆ‘ä»¬åŒę ·ē›øäæ”ļ¼Œäŗŗē±»åæ…é”»åÆ¹č‡Ŗå·±ęäŗ¤ēš„å†…å®¹č“Ÿč“£ć€‚čæ™äø¤ē‚¹å¹¶äøēŸ›ē›¾ć€‚ + +ę„Ÿč°¢ä½ ēš„č“”ēŒ®ļ¼ diff --git a/assets/wechat.png b/assets/wechat.png index 8fc41ea7d53cfc9e0ccb7b6fe5a4fd6d079cee7c..a34217c335542a13aace2103bd15ccebf94acde2 100644 GIT binary patch literal 144045 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 ZcS`}Nmr^nhTC?^iWQVnMgke}I{a<4CA~*m5 From c51ceac70b66acc30d42a31e9f77d784b190890c Mon Sep 17 00:00:00 2001 From: Albert Simon <47634918+willyw0nka@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:53:53 +0100 Subject: [PATCH 20/21] fix: updated model configuration links at readme (#544) Signed-off-by: Albert Simon From b9a66248d8fd88644f1fec3f8be35fbbc23d4803 Mon Sep 17 00:00:00 2001 From: kernoeb Date: Sun, 22 Feb 2026 01:32:44 +0100 Subject: [PATCH 21/21] fix: resolve Groq STT key from model_list when providers.groq is absent (#602) When users migrate from the legacy `providers` config to the new `model_list` format, voice transcription silently breaks on Telegram, Discord and Slack channels. The gateway was reading the Groq API key exclusively from `cfg.Providers.Groq.APIKey`, which is empty once the key is defined only inside a `model_list` entry. The transcriber was never initialized, so voice messages fell back to a plain `[voice]` placeholder. This fix also scans `model_list` for any entry whose `model` field starts with `groq/` and uses its `api_key` as a fallback, preserving full backward compatibility with the legacy `providers.groq` field. --- cmd/picoclaw/cmd_gateway.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 9a3b6aa19..28ef76ad3 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -10,6 +10,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/agent" @@ -121,8 +122,17 @@ func gatewayCmd() { agentLoop.SetChannelManager(channelManager) var transcriber *voice.GroqTranscriber - if cfg.Providers.Groq.APIKey != "" { - transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) + groqAPIKey := cfg.Providers.Groq.APIKey + if groqAPIKey == "" { + for _, mc := range cfg.ModelList { + if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { + groqAPIKey = mc.APIKey + break + } + } + } + if groqAPIKey != "" { + transcriber = voice.NewGroqTranscriber(groqAPIKey) logger.InfoC("voice", "Groq voice transcription enabled") }