diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 654ad6ae6..e410eb51c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,10 +18,10 @@ builds: - stdjson ldflags: - -s -w - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }} + - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} + - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} + - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }} goos: - linux - windows @@ -125,6 +125,23 @@ dockers_v2: - linux/arm64 - linux/riscv64 + - id: picoclaw-launcher + dockerfile: docker/Dockerfile.goreleaser.launcher + ids: + - picoclaw + - picoclaw-launcher + - picoclaw-launcher-tui + images: + - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" + - '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}' + tags: + - "{{ .Tag }}-launcher" + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' + platforms: + - linux/amd64 + - linux/arm64 + - linux/riscv64 + notarize: macos: - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' diff --git a/Makefile b/Makefile index 955c1c966..98642703f 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal -LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w" +CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config +LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w" # Go variables GO?=CGO_ENABLED=0 go diff --git a/README.md b/README.md index 5cf9f6143..bae3fa681 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down ``` +### Launcher Mode (Web Console) + +The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. + +> [!WARNING] +> The web console does not yet support authentication. Avoid exposing it to the public internet. + ### Agent Mode (One-shot) ```bash diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index f754abc65..a995945d2 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -50,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error { msgBus := bus.NewMessageBus() defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + defer agentLoop.Close() // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 66a56f9ce..bfa69f072 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -1,23 +1,42 @@ package gateway import ( + "fmt" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) func NewGatewayCommand() *cobra.Command { var debug bool + var noTruncate bool cmd := &cobra.Command{ Use: "gateway", Aliases: []string{"g"}, Short: "Start picoclaw gateway", Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, _ []string) error { + if noTruncate && !debug { + return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)") + } + + if noTruncate { + utils.SetDisableTruncation(true) + logger.Info("String truncation is globally disabled via 'no-truncate' flag") + } + + return nil + }, RunE: func(_ *cobra.Command, _ []string) error { return gatewayCmd(debug) }, } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs") return cmd } diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 4f93b858a..fed3d5ffb 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -214,6 +214,7 @@ func gatewayCmd(debug bool) error { cronService.Stop() mediaStore.Stop() agentLoop.Stop() + agentLoop.Close() fmt.Println("βœ“ Gateway stopped") return nil diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index f81d7013d..e04bccffb 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -1,23 +1,14 @@ package internal import ( - "fmt" "os" "path/filepath" - "runtime" "github.com/sipeed/picoclaw/pkg/config" ) const Logo = "🦞" -var ( - version = "dev" - gitCommit string - buildTime string - goVersion string -) - // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { @@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) { } // FormatVersion returns the version string with optional git commit +// Deprecated: Use pkg/config.FormatVersion instead func FormatVersion() string { - v := version - if gitCommit != "" { - v += fmt.Sprintf(" (git: %s)", gitCommit) - } - return v + return config.FormatVersion() } // FormatBuildInfo returns build time and go version info +// Deprecated: Use pkg/config.FormatBuildInfo instead func FormatBuildInfo() (string, string) { - build := buildTime - goVer := goVersion - if goVer == "" { - goVer = runtime.Version() - } - return build, goVer + return config.FormatBuildInfo() } // GetVersion returns the version string +// Deprecated: Use pkg/config.GetVersion instead func GetVersion() string { - return version + return config.GetVersion() } diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go index 646be1ba1..583751781 100644 --- a/cmd/picoclaw/internal/helpers_test.go +++ b/cmd/picoclaw/internal/helpers_test.go @@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { assert.Equal(t, want, got) } -func TestFormatVersion_NoGitCommit(t *testing.T) { - oldVersion, oldGit := version, gitCommit - t.Cleanup(func() { version, gitCommit = oldVersion, oldGit }) - - version = "1.2.3" - gitCommit = "" - - assert.Equal(t, "1.2.3", FormatVersion()) -} - -func TestFormatVersion_WithGitCommit(t *testing.T) { - oldVersion, oldGit := version, gitCommit - t.Cleanup(func() { version, gitCommit = oldVersion, oldGit }) - - version = "1.2.3" - gitCommit = "abc123" - - assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) -} - -func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "2026-02-20T00:00:00Z" - goVersion = "go1.23.0" - - build, goVer := FormatBuildInfo() - - assert.Equal(t, buildTime, build) - assert.Equal(t, goVersion, goVer) -} - -func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "" - goVersion = "go1.23.0" - - build, goVer := FormatBuildInfo() - - assert.Empty(t, build) - assert.Equal(t, goVersion, goVer) -} - -func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "x" - goVersion = "" - - build, goVer := FormatBuildInfo() - - assert.Equal(t, "x", build) - assert.Equal(t, runtime.Version(), goVer) -} - func TestGetConfigPath_Windows(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("windows-specific HOME behavior varies; run on windows") @@ -112,17 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) { require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want) } - -func TestGetVersion(t *testing.T) { - assert.Equal(t, "dev", GetVersion()) -} - -func TestGetConfigPath_WithEnv(t *testing.T) { - t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json") - t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred - - got := GetConfigPath() - want := "/tmp/custom/config.json" - - assert.Equal(t, want, got) -} diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index ab28f4885..dd7063fe6 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -6,6 +6,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" ) func statusCmd() { @@ -18,8 +19,8 @@ func statusCmd() { configPath := internal.GetConfigPath() fmt.Printf("%s picoclaw Status\n", internal.Logo) - fmt.Printf("Version: %s\n", internal.FormatVersion()) - build, _ := internal.FormatBuildInfo() + fmt.Printf("Version: %s\n", config.FormatVersion()) + build, _ := config.FormatBuildInfo() if build != "" { fmt.Printf("Build: %s\n", build) } diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go index 1cf686671..71c7dd2f8 100644 --- a/cmd/picoclaw/internal/version/command.go +++ b/cmd/picoclaw/internal/version/command.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" ) func NewVersionCommand() *cobra.Command { @@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command { } func printVersion() { - fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion()) - build, goVer := internal.FormatBuildInfo() + fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion()) + build, goVer := config.FormatBuildInfo() if build != "" { fmt.Printf(" Build: %s\n", build) } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index fe4de8ecc..b82475905 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -22,10 +22,11 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" + "github.com/sipeed/picoclaw/pkg/config" ) func NewPicoclawCommand() *cobra.Command { - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) + short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion()) cmd := &cobra.Command{ Use: "picoclaw", diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 3740ba358..e622675ee 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" ) func TestNewPicoclawCommand(t *testing.T) { @@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) { require.NotNil(t, cmd) - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) + short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion()) assert.Equal(t, "picoclaw", cmd.Use) assert.Equal(t, short, cmd.Short) diff --git a/config/config.example.json b/config/config.example.json index 2b7d2ab45..30631a905 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -285,6 +285,9 @@ "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", + "api_keys": [ + "YOUR_BRAVE_API_KEY" + ], "max_results": 5 }, "tavily": { @@ -299,7 +302,10 @@ }, "perplexity": { "enabled": false, - "api_key": "", + "api_key": "pplx-xxx", + "api_keys": [ + "pplx-xxx" + ], "max_results": 5 }, "searxng": { diff --git a/docker/Dockerfile.goreleaser.launcher b/docker/Dockerfile.goreleaser.launcher new file mode 100644 index 000000000..5d65576f7 --- /dev/null +++ b/docker/Dockerfile.goreleaser.launcher @@ -0,0 +1,12 @@ +FROM alpine:3.21 + +ARG TARGETPLATFORM + +RUN apk add --no-cache ca-certificates tzdata + +COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw +COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher +COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui + +ENTRYPOINT ["picoclaw-launcher"] +CMD ["-public", "-no-browser"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9ec71abab..b26cf4199 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -19,7 +19,7 @@ services: # ───────────────────────────────────────────── # PicoClaw Gateway (Long-running Bot) - # docker compose -f docker/docker-compose.yml up picoclaw-gateway + # docker compose -f docker/docker-compose.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: image: docker.io/sipeed/picoclaw:latest @@ -32,3 +32,21 @@ services: # - "host.docker.internal:host-gateway" volumes: - ./data:/root/.picoclaw + + # ───────────────────────────────────────────── + # PicoClaw Launcher (Web Console + Gateway) + # docker compose -f docker/docker-compose.yml --profile launcher up + # ───────────────────────────────────────────── + picoclaw-launcher: + image: docker.io/sipeed/picoclaw:launcher + container_name: picoclaw-launcher + restart: on-failure + profiles: + - launcher + environment: + - PICOCLAW_GATEWAY_HOST=0.0.0.0 + ports: + - "127.0.0.1:18800:18800" + - "127.0.0.1:18790:18790" + volumes: + - ./data:/root/.picoclaw diff --git a/docs/debug.md b/docs/debug.md new file mode 100644 index 000000000..7e28a15f2 --- /dev/null +++ b/docs/debug.md @@ -0,0 +1,33 @@ +# Debugging PicoClaw + +PicoClaw performs multiple complex interactions under the hood for every single request it receivesβ€”from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates. +## Starting PicoClaw in Debug Mode + +To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results. + +## Disabling Log Truncation (Full Logs) + +By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable. + +If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag. + +**Note:** This flag *only* works when combined with the `--debug` mode. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +When this flag is active, the global truncation function is disabled. This is extremely useful for: + +* Verifying the exact syntax of the messages sent to the provider. +* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`. +* Debugging the session history saved in memory. diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 92663a32d..5a84c45e2 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -12,9 +12,11 @@ import ( "sync" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/utils" ) type ContextBuilder struct { @@ -80,8 +82,10 @@ func NewContextBuilder(workspace string) *ContextBuilder { func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) toolDiscovery := cb.getDiscoveryRule() + version := config.FormatVersion() - return fmt.Sprintf(`# picoclaw 🦞 + return fmt.Sprintf( + `# picoclaw 🦞 (%s) You are picoclaw, a helpful AI assistant. @@ -102,7 +106,7 @@ Your workspace is at: %s 4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content. %s`, - workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) + version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) } func (cb *ContextBuilder) getDiscoveryRule() string { @@ -535,10 +539,7 @@ func (cb *ContextBuilder) BuildMessages( }) // Log preview of system prompt (avoid logging huge content) - preview := fullSystemPrompt - if len(preview) > 500 { - preview = preview[:500] + "... (truncated)" - } + preview := utils.Truncate(fullSystemPrompt, 500) logger.DebugCF("agent", "System prompt preview", map[string]any{ "preview": preview, diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 430e51660..eab692417 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -1,6 +1,7 @@ package agent import ( + "context" "fmt" "log" "os" @@ -9,6 +10,7 @@ import ( "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" @@ -31,7 +33,7 @@ type AgentInstance struct { SummarizeMessageThreshold int SummarizeTokenPercent int Provider providers.LLMProvider - Sessions *session.SessionManager + Sessions session.SessionStore ContextBuilder *ContextBuilder Tools *tools.ToolRegistry Subagents *config.SubagentsConfig @@ -98,7 +100,7 @@ func NewAgentInstance( } sessionsDir := filepath.Join(workspace, "sessions") - sessionsManager := session.NewSessionManager(sessionsDir) + sessions := initSessionStore(sessionsDir) mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled contextBuilder := NewContextBuilder(workspace).WithToolDiscovery( @@ -229,7 +231,7 @@ func NewAgentInstance( SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeTokenPercent: summarizeTokenPercent, Provider: provider, - Sessions: sessionsManager, + Sessions: sessions, ContextBuilder: contextBuilder, Tools: toolsRegistry, Subagents: subagents, @@ -283,6 +285,39 @@ func compilePatterns(patterns []string) []*regexp.Regexp { return compiled } +// Close releases resources held by the agent's session store. +func (a *AgentInstance) Close() error { + if a.Sessions != nil { + return a.Sessions.Close() + } + return nil +} + +// initSessionStore creates the session persistence backend. +// It uses the JSONL store by default and auto-migrates legacy JSON sessions. +// Falls back to SessionManager if the JSONL store cannot be initialized or +// if migration fails (which indicates the store cannot write reliably). +func initSessionStore(dir string) session.SessionStore { + store, err := memory.NewJSONLStore(dir) + if err != nil { + log.Printf("memory: init store: %v; using json sessions", err) + return session.NewSessionManager(dir) + } + + if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil { + // Migration failure means the store could not write data. + // Fall back to SessionManager to avoid a split state where + // some sessions are in JSONL and others remain in JSON. + log.Printf("memory: migration failed: %v; falling back to json sessions", merr) + store.Close() + return session.NewSessionManager(dir) + } else if n > 0 { + log.Printf("memory: migrated %d session(s) to jsonl", n) + } + + return session.NewJSONLBackend(store) +} + func expandHome(path string) string { if path == "" { return path diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 58f53bef8..235d42fcc 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -120,19 +120,21 @@ func registerSharedTools( continue } - // Web tools if cfg.Tools.IsToolEnabled("web") { searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys), BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, + TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys), TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + PerplexityAPIKeys: config.MergeAPIKeys( + cfg.Tools.Web.Perplexity.APIKey, + cfg.Tools.Web.Perplexity.APIKeys, + ), PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, @@ -427,6 +429,11 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } +// Close releases resources held by agent session stores. Call after Stop. +func (al *AgentLoop) Close() { + al.registry.Close() +} + func (al *AgentLoop) RegisterTool(tool tools.Tool) { for _, agentID := range al.registry.ListAgentIDs() { if agent, ok := al.registry.GetAgent(agentID); ok { diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 0e7973dc3..58b7ce440 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -114,6 +114,18 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { } } +// Close releases resources held by all registered agents. +func (r *AgentRegistry) Close() { + r.mu.RLock() + defer r.mu.RUnlock() + for _, agent := range r.agents { + if err := agent.Close(); err != nil { + logger.WarnCF("agent", "Failed to close agent", + map[string]any{"agent_id": agent.ID, "error": err.Error()}) + } + } +} + // GetDefaultAgent returns the default agent instance. func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 0a36247a6..b04beeb6e 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -168,7 +168,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return channels.ErrNotRunning } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -200,7 +200,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err continue } - if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil { + if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk); err != nil { return err } } @@ -210,9 +210,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // sendHTMLChunk sends a single HTML message, falling back to the original // markdown as plain text on parse failure so users never see raw HTML tags. -func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error { +func (c *TelegramChannel) sendHTMLChunk( + ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, +) error { tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML + tgMsg.MessageThreadID = threadID if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ @@ -232,13 +235,16 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { - cid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return func() {}, err } + action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + action.MessageThreadID = threadID + // Send the first typing action immediately - _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + _ = c.bot.SendChatAction(ctx, action) typingCtx, cancel := context.WithCancel(ctx) go func() { @@ -249,7 +255,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( case <-typingCtx.Done(): return case <-ticker.C: - _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + a.MessageThreadID = threadID + _ = c.bot.SendChatAction(typingCtx, a) } } }() @@ -259,7 +267,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - cid, err := parseChatID(chatID) + cid, _, err := parseTelegramChatID(chatID) if err != nil { return err } @@ -288,12 +296,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s text = "Thinking... πŸ’­" } - cid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return "", err } - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) + phMsg := tu.Message(tu.ID(cid), text) + phMsg.MessageThreadID = threadID + pMsg, err := c.bot.SendMessage(ctx, phMsg) if err != nil { return "", err } @@ -307,7 +317,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe return channels.ErrNotRunning } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -339,30 +349,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe switch part.Type { case "image": params := &telego.SendPhotoParams{ - ChatID: tu.ID(chatID), - Photo: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Photo: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendPhoto(ctx, params) case "audio": params := &telego.SendAudioParams{ - ChatID: tu.ID(chatID), - Audio: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Audio: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendAudio(ctx, params) case "video": params := &telego.SendVideoParams{ - ChatID: tu.ID(chatID), - Video: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Video: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ - ChatID: tu.ID(chatID), - Document: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Document: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendDocument(ctx, params) } @@ -506,19 +520,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } + // For forum topics, embed the thread ID as "chatID/threadID" so replies + // route to the correct topic and each topic gets its own session. + // Only forum groups (IsForum) are handled; regular group reply threads + // must share one session per group. + compositeChatID := fmt.Sprintf("%d", chatID) + threadID := message.MessageThreadID + if message.Chat.IsForum && threadID != 0 { + compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) + } + logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": sender.CanonicalID, - "chat_id": fmt.Sprintf("%d", chatID), + "chat_id": compositeChatID, + "thread_id": threadID, "preview": utils.Truncate(content, 50), }) - // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable - peerKind := "direct" peerID := fmt.Sprintf("%d", user.ID) if message.Chat.Type != "private" { peerKind = "group" - peerID = fmt.Sprintf("%d", chatID) + peerID = compositeChatID } peer := bus.Peer{Kind: peerKind, ID: peerID} @@ -531,11 +554,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + // Set parent_peer metadata for per-topic agent binding. + if message.Chat.IsForum && threadID != 0 { + metadata["parent_peer_kind"] = "topic" + metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID) + } + c.HandleMessage(c.ctx, peer, messageID, platformID, - fmt.Sprintf("%d", chatID), + compositeChatID, content, mediaPaths, metadata, @@ -583,10 +612,23 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) return c.downloadFileWithInfo(file, ext) } -func parseChatID(chatIDStr string) (int64, error) { - var id int64 - _, err := fmt.Sscanf(chatIDStr, "%d", &id) - return id, err +// parseTelegramChatID splits "chatID/threadID" into its components. +// Returns threadID=0 when no "/" is present (non-forum messages). +func parseTelegramChatID(chatID string) (int64, int, error) { + idx := strings.Index(chatID, "/") + if idx == -1 { + cid, err := strconv.ParseInt(chatID, 10, 64) + return cid, 0, err + } + cid, err := strconv.ParseInt(chatID[:idx], 10, 64) + if err != nil { + return 0, 0, err + } + tid, err := strconv.Atoi(chatID[idx+1:]) + if err != nil { + return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err) + } + return cid, tid, nil } func markdownToTelegramHTML(text string) string { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 3a2f1aa66..c2186d0a3 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/mymmrac/telego" ta "github.com/mymmrac/telego/telegoapi" @@ -271,3 +272,191 @@ func TestSend_InvalidChatID(t *testing.T) { assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed") assert.Empty(t, caller.calls) } + +func TestParseTelegramChatID_Plain(t *testing.T) { + cid, tid, err := parseTelegramChatID("12345") + assert.NoError(t, err) + assert.Equal(t, int64(12345), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_NegativeGroup(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_WithThreadID(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890/42") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 42, tid) +} + +func TestParseTelegramChatID_GeneralTopic(t *testing.T) { + cid, tid, err := parseTelegramChatID("-100123/1") + assert.NoError(t, err) + assert.Equal(t, int64(-100123), cid) + assert.Equal(t, 1, tid) +} + +func TestParseTelegramChatID_Invalid(t *testing.T) { + _, _, err := parseTelegramChatID("not-a-number") + assert.Error(t, err) +} + +func TestParseTelegramChatID_InvalidThreadID(t *testing.T) { + _, _, err := parseTelegramChatID("-100123/not-a-thread") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid thread ID") +} + +func TestSend_WithForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890/42", + Content: "Hello from topic", + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1) +} + +func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "hello from topic", + MessageID: 10, + MessageThreadID: 42, + Chat: telego.Chat{ + ID: -1001234567890, + Type: "supergroup", + IsForum: true, + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok, "expected inbound message") + + // Composite chatID should include thread ID + assert.Equal(t, "-1001234567890/42", inbound.ChatID) + + // Peer ID should include thread ID for session key isolation + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-1001234567890/42", inbound.Peer.ID) + + // Parent peer metadata should be set for agent binding + assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"]) + assert.Equal(t, "42", inbound.Metadata["parent_peer_id"]) +} + +func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "regular group message", + MessageID: 11, + Chat: telego.Chat{ + ID: -100999, + Type: "group", + }, + From: &telego.User{ + ID: 8, + FirstName: "Bob", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok) + + // Plain chatID without thread suffix + assert.Equal(t, "-100999", inbound.ChatID) + + // Peer ID should be raw chat ID (no thread suffix) + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-100999", inbound.Peer.ID) + + // No parent peer metadata + assert.Empty(t, inbound.Metadata["parent_peer_kind"]) + assert.Empty(t, inbound.Metadata["parent_peer_id"]) +} + +func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + // In regular groups, reply threads set MessageThreadID to the original + // message ID. This should NOT trigger per-thread session isolation. + msg := &telego.Message{ + Text: "reply in thread", + MessageID: 20, + MessageThreadID: 15, + Chat: telego.Chat{ + ID: -100999, + Type: "supergroup", + IsForum: false, + }, + From: &telego.User{ + ID: 9, + FirstName: "Carol", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok) + + // chatID should NOT include thread suffix for non-forum groups + assert.Equal(t, "-100999", inbound.ChatID) + + // Peer ID should be raw chat ID (shared session for whole group) + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-100999", inbound.Peer.ID) + + // No parent peer metadata + assert.Empty(t, inbound.Metadata["parent_peer_kind"]) + assert.Empty(t, inbound.Metadata["parent_peer_id"]) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index af35552db..b63bc41df 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "sync/atomic" "github.com/caarlos0/env/v11" @@ -58,6 +59,16 @@ type Config struct { Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` + // BuildInfo contains build-time version information + BuildInfo BuildInfo `json:"build_info,omitempty"` +} + +// BuildInfo contains build-time version information +type BuildInfo struct { + Version string `json:"version"` + GitCommit string `json:"git_commit"` + BuildTime string `json:"build_time"` + GoVersion string `json:"go_version"` } // MarshalJSON implements custom JSON marshaling for Config @@ -593,16 +604,18 @@ type ToolConfig 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"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } type TavilyConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } type DuckDuckGoConfig struct { @@ -611,9 +624,10 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } type SearXNGConfig struct { @@ -934,6 +948,29 @@ func (c *Config) ValidateModelList() error { return nil } +func MergeAPIKeys(apiKey string, apiKeys []string) []string { + seen := make(map[string]struct{}) + var all []string + + if k := strings.TrimSpace(apiKey); k != "" { + if _, exists := seen[k]; !exists { + seen[k] = struct{}{} + all = append(all, k) + } + } + + for _, k := range apiKeys { + if trimmed := strings.TrimSpace(k); trimmed != "" { + if _, exists := seen[trimmed]; !exists { + seen[trimmed] = struct{}{} + all = append(all, trimmed) + } + } + } + + return all +} + func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 47f79c6f0..8baf3e6fd 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -296,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) { if cfg.Tools.Web.Brave.MaxResults != 5 { t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } - if cfg.Tools.Web.Brave.APIKey != "" { + if len(cfg.Tools.Web.Brave.APIKeys) != 0 { t.Error("Brave API key should be empty by default") } if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 0892d45f4..5bb3bd1d6 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -384,6 +384,13 @@ func DefaultConfig() *Config { Brave: BraveConfig{ Enabled: false, APIKey: "", + APIKeys: nil, + MaxResults: 5, + }, + Tavily: TavilyConfig{ + Enabled: false, + APIKey: "", + APIKeys: nil, MaxResults: 5, }, DuckDuckGo: DuckDuckGoConfig{ @@ -393,6 +400,7 @@ func DefaultConfig() *Config { Perplexity: PerplexityConfig{ Enabled: false, APIKey: "", + APIKeys: nil, MaxResults: 5, }, SearXNG: SearXNGConfig{ @@ -502,5 +510,11 @@ func DefaultConfig() *Config { Enabled: false, MonitorUSB: true, }, + BuildInfo: BuildInfo{ + Version: Version, + GitCommit: GitCommit, + BuildTime: BuildTime, + GoVersion: GoVersion, + }, } } diff --git a/pkg/config/version.go b/pkg/config/version.go new file mode 100644 index 000000000..b65d3cf33 --- /dev/null +++ b/pkg/config/version.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "runtime" +) + +// Build-time variables injected via ldflags during build process. +// These are set by the Makefile or .goreleaser.yaml using the -X flag: +// +// -X github.com/sipeed/picoclaw/pkg/config.Version= +// -X github.com/sipeed/picoclaw/pkg/config.GitCommit= +// -X github.com/sipeed/picoclaw/pkg/config.BuildTime= +// -X github.com/sipeed/picoclaw/pkg/config.GoVersion= +var ( + Version = "dev" // Default value when not built with ldflags + GitCommit string // Git commit SHA (short) + BuildTime string // Build timestamp in RFC3339 format + GoVersion string // Go version used for building +) + +// FormatVersion returns the version string with optional git commit +func FormatVersion() string { + v := Version + if GitCommit != "" { + v += fmt.Sprintf(" (git: %s)", GitCommit) + } + return v +} + +// FormatBuildInfo returns build time and go version info +func FormatBuildInfo() (string, string) { + build := BuildTime + goVer := GoVersion + if goVer == "" { + goVer = runtime.Version() + } + return build, goVer +} + +// GetVersion returns the version string +func GetVersion() string { + return Version +} diff --git a/pkg/config/version_test.go b/pkg/config/version_test.go new file mode 100644 index 000000000..34bc906ce --- /dev/null +++ b/pkg/config/version_test.go @@ -0,0 +1,92 @@ +package config + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatVersion_NoGitCommit(t *testing.T) { + oldVersion, oldGit := Version, GitCommit + t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) + + Version = "1.2.3" + GitCommit = "" + + assert.Equal(t, "1.2.3", FormatVersion()) +} + +func TestFormatVersion_WithGitCommit(t *testing.T) { + oldVersion, oldGit := Version, GitCommit + t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) + + Version = "1.2.3" + GitCommit = "abc123" + + assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) +} + +func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "2026-02-20T00:00:00Z" + GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, BuildTime, build) + assert.Equal(t, GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "" + GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Empty(t, build) + assert.Equal(t, GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "x" + GoVersion = "" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, "x", build) + assert.Equal(t, runtime.Version(), goVer) +} + +func TestGetVersion(t *testing.T) { + oldVersion := Version + t.Cleanup(func() { Version = oldVersion }) + + Version = "dev" + assert.Equal(t, "dev", GetVersion()) +} + +func TestGetVersion_Custom(t *testing.T) { + oldVersion := Version + t.Cleanup(func() { Version = oldVersion }) + + Version = "v1.0.0" + assert.Equal(t, "v1.0.0", GetVersion()) +} + +func TestVersion_DefaultIsDev(t *testing.T) { + // Reset to default values + oldVersion := Version + Version = "dev" + t.Cleanup(func() { Version = oldVersion }) + + assert.Equal(t, "dev", Version) +} diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index e12e2c5ab..afe374166 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string { // sanitizeKey converts a session key to a safe filename component. // Mirrors pkg/session.sanitizeFilename so that migration paths match. -// -// Note: this is a lossy mapping β€” "telegram:123" and "telegram_123" -// both produce the same filename. This is an intentional tradeoff: -// keys with colons (e.g. from channels) are by far the common case, -// and a bidirectional encoding (like URL-encoding) would complicate -// file listings and debugging. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' +// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts") +// do not create subdirectories or break on Windows. func sanitizeKey(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } // readMeta loads the metadata file for a session. diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 19d63bb77..e272d17a9 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -733,16 +733,18 @@ type WebToolsConfig struct { } type BraveConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type TavilyConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + BaseURL string `json:"base_url"` + MaxResults int `json:"max_results"` } type DuckDuckGoConfig struct { @@ -751,9 +753,10 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type CronConfig struct { @@ -1082,6 +1085,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig { Brave: config.BraveConfig{ Enabled: c.Web.Brave.Enabled, APIKey: c.Web.Brave.APIKey, + APIKeys: c.Web.Brave.APIKeys, MaxResults: c.Web.Brave.MaxResults, }, Tavily: config.TavilyConfig{ diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index d952c8cb0..ee9c11899 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -40,6 +40,10 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { providerName := strings.ToLower(cfg.Agents.Defaults.Provider) lowerModel := strings.ToLower(model) + if providerName == "" && model == "" { + return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty") + } + sel := providerSelection{ providerType: providerTypeHTTPCompat, model: model, diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go new file mode 100644 index 000000000..7f470de15 --- /dev/null +++ b/pkg/session/jsonl_backend.go @@ -0,0 +1,81 @@ +package session + +import ( + "context" + "log" + + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// JSONLBackend adapts a memory.Store into the SessionStore interface. +// Write errors are logged rather than returned, matching the fire-and-forget +// contract of SessionManager that the agent loop relies on. +type JSONLBackend struct { + store memory.Store +} + +// NewJSONLBackend wraps a memory.Store for use as a SessionStore. +func NewJSONLBackend(store memory.Store) *JSONLBackend { + return &JSONLBackend{store: store} +} + +func (b *JSONLBackend) AddMessage(sessionKey, role, content string) { + if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil { + log.Printf("session: add message: %v", err) + } +} + +func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) { + if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil { + log.Printf("session: add full message: %v", err) + } +} + +func (b *JSONLBackend) GetHistory(key string) []providers.Message { + msgs, err := b.store.GetHistory(context.Background(), key) + if err != nil { + log.Printf("session: get history: %v", err) + return []providers.Message{} + } + return msgs +} + +func (b *JSONLBackend) GetSummary(key string) string { + summary, err := b.store.GetSummary(context.Background(), key) + if err != nil { + log.Printf("session: get summary: %v", err) + return "" + } + return summary +} + +func (b *JSONLBackend) SetSummary(key, summary string) { + if err := b.store.SetSummary(context.Background(), key, summary); err != nil { + log.Printf("session: set summary: %v", err) + } +} + +func (b *JSONLBackend) SetHistory(key string, history []providers.Message) { + if err := b.store.SetHistory(context.Background(), key, history); err != nil { + log.Printf("session: set history: %v", err) + } +} + +func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { + if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil { + log.Printf("session: truncate history: %v", err) + } +} + +// Save persists session state. Since the JSONL store fsyncs every write +// immediately, the data is already durable. Save runs compaction to reclaim +// space from logically truncated messages (no-op when there are none). +func (b *JSONLBackend) Save(key string) error { + return b.store.Compact(context.Background(), key) +} + +// Close releases resources held by the underlying store. +func (b *JSONLBackend) Close() error { + return b.store.Close() +} diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go new file mode 100644 index 000000000..40fa019cb --- /dev/null +++ b/pkg/session/jsonl_backend_test.go @@ -0,0 +1,179 @@ +package session_test + +import ( + "fmt" + "testing" + + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" +) + +// Compile-time interface satisfaction checks. +var ( + _ session.SessionStore = (*session.SessionManager)(nil) + _ session.SessionStore = (*session.JSONLBackend)(nil) +) + +func newBackend(t *testing.T) *session.JSONLBackend { + t.Helper() + store, err := memory.NewJSONLStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return session.NewJSONLBackend(store) +} + +func TestJSONLBackend_AddAndGetHistory(t *testing.T) { + b := newBackend(t) + + b.AddMessage("s1", "user", "hello") + b.AddMessage("s1", "assistant", "hi") + + history := b.GetHistory("s1") + if len(history) != 2 { + t.Fatalf("got %d messages, want 2", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Errorf("msg[0] = %+v", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "hi" { + t.Errorf("msg[1] = %+v", history[1]) + } +} + +func TestJSONLBackend_AddFullMessage(t *testing.T) { + b := newBackend(t) + + msg := providers.Message{ + Role: "assistant", + Content: "done", + ToolCalls: []providers.ToolCall{ + {ID: "tc1", Function: &providers.FunctionCall{Name: "read_file", Arguments: `{"path":"x"}`}}, + }, + } + b.AddFullMessage("s1", msg) + + history := b.GetHistory("s1") + if len(history) != 1 { + t.Fatalf("got %d, want 1", len(history)) + } + if len(history[0].ToolCalls) != 1 || history[0].ToolCalls[0].ID != "tc1" { + t.Errorf("tool calls = %+v", history[0].ToolCalls) + } +} + +func TestJSONLBackend_Summary(t *testing.T) { + b := newBackend(t) + + if got := b.GetSummary("s1"); got != "" { + t.Errorf("got %q, want empty", got) + } + + b.SetSummary("s1", "test summary") + if got := b.GetSummary("s1"); got != "test summary" { + t.Errorf("got %q, want %q", got, "test summary") + } +} + +func TestJSONLBackend_TruncateAndSave(t *testing.T) { + b := newBackend(t) + + for i := 0; i < 10; i++ { + b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) + } + b.TruncateHistory("s1", 3) + + history := b.GetHistory("s1") + if len(history) != 3 { + t.Fatalf("got %d, want 3", len(history)) + } + if history[0].Content != "msg 7" { + t.Errorf("got %q, want %q", history[0].Content, "msg 7") + } + + // Save triggers compaction. + if err := b.Save("s1"); err != nil { + t.Fatal(err) + } + + // Messages still accessible after compaction. + history = b.GetHistory("s1") + if len(history) != 3 { + t.Fatalf("after save: got %d, want 3", len(history)) + } +} + +func TestJSONLBackend_SetHistory(t *testing.T) { + b := newBackend(t) + b.AddMessage("s1", "user", "old") + + b.SetHistory("s1", []providers.Message{ + {Role: "user", Content: "new1"}, + {Role: "assistant", Content: "new2"}, + }) + + history := b.GetHistory("s1") + if len(history) != 2 { + t.Fatalf("got %d, want 2", len(history)) + } + if history[0].Content != "new1" { + t.Errorf("got %q, want %q", history[0].Content, "new1") + } +} + +func TestJSONLBackend_EmptySession(t *testing.T) { + b := newBackend(t) + + history := b.GetHistory("nonexistent") + if history == nil { + t.Fatal("got nil, want empty slice") + } + if len(history) != 0 { + t.Errorf("got %d, want 0", len(history)) + } +} + +func TestJSONLBackend_SessionIsolation(t *testing.T) { + b := newBackend(t) + b.AddMessage("s1", "user", "session1") + b.AddMessage("s2", "user", "session2") + + h1 := b.GetHistory("s1") + h2 := b.GetHistory("s2") + + if len(h1) != 1 || h1[0].Content != "session1" { + t.Errorf("s1: %+v", h1) + } + if len(h2) != 1 || h2[0].Content != "session2" { + t.Errorf("s2: %+v", h2) + } +} + +func TestJSONLBackend_SummarizeFlow(t *testing.T) { + // Simulates the real summarization flow in the agent loop: + // SetSummary β†’ TruncateHistory β†’ Save + b := newBackend(t) + + for i := 0; i < 20; i++ { + b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) + } + + b.SetSummary("s1", "conversation about testing") + b.TruncateHistory("s1", 4) + if err := b.Save("s1"); err != nil { + t.Fatal(err) + } + + if got := b.GetSummary("s1"); got != "conversation about testing" { + t.Errorf("summary = %q", got) + } + history := b.GetHistory("s1") + if len(history) != 4 { + t.Fatalf("got %d messages, want 4", len(history)) + } + if history[0].Content != "msg 16" { + t.Errorf("first message = %q, want %q", history[0].Content, "msg 16") + } +} diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 08f0b0ad2..a31dbd55c 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -146,12 +146,15 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { } // sanitizeFilename converts a session key into a cross-platform safe filename. -// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the -// volume separator on Windows, so filepath.Base would misinterpret the key. -// We replace it with '_'. The original key is preserved inside the JSON file, -// so loadSessions still maps back to the right in-memory key. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so +// composite IDs (e.g. Telegram forum "chatID/threadID") do not create +// subdirectories or break on Windows. The original key is preserved inside +// the JSON file, so loadSessions still maps back to the right in-memory key. func sanitizeFilename(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } func (sm *SessionManager) Save(key string) error { @@ -162,10 +165,9 @@ func (sm *SessionManager) Save(key string) error { filename := sanitizeFilename(key) // filepath.IsLocal rejects empty names, "..", absolute paths, and - // OS-reserved device names (NUL, COM1 … on Windows). - // The extra checks reject "." and any directory separators so that - // the session file is always written directly inside sm.storage. - if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) { + // OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename + // already replaced '/' and '\' with '_', so no subdirs are created. + if filename == "." || !filepath.IsLocal(filename) { return os.ErrInvalid } @@ -265,6 +267,12 @@ func (sm *SessionManager) loadSessions() error { return nil } +// Close is a no-op for the in-memory SessionManager; it satisfies the +// SessionStore interface so callers can release resources uniformly. +func (sm *SessionManager) Close() error { + return nil +} + // SetHistory updates the messages of a session. func (sm *SessionManager) SetHistory(key string, history []providers.Message) { sm.mu.Lock() diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index 5ef5f4349..bc5615966 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -17,6 +17,7 @@ func TestSanitizeFilename(t *testing.T) { {"slack:C01234", "slack_C01234"}, {"no-colons-here", "no-colons-here"}, {"multiple:colons:here", "multiple_colons_here"}, + {"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"}, } for _, tt := range tests { @@ -64,11 +65,21 @@ func TestSave_RejectsPathTraversal(t *testing.T) { tmpDir := t.TempDir() sm := NewSessionManager(tmpDir) - badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"} + // Invalid names that must still be rejected. + badKeys := []string{"", ".", ".."} for _, key := range badKeys { sm.GetOrCreate(key) if err := sm.Save(key); err == nil { t.Errorf("Save(%q) should have failed but didn't", key) } } + + // Keys containing path separators are sanitized (no subdirs created). + sm.GetOrCreate("foo/bar") + if err := sm.Save("foo/bar"); err != nil { + t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) { + t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") + } } diff --git a/pkg/session/session_store.go b/pkg/session/session_store.go new file mode 100644 index 000000000..1d1a2f967 --- /dev/null +++ b/pkg/session/session_store.go @@ -0,0 +1,32 @@ +package session + +import "github.com/sipeed/picoclaw/pkg/providers" + +// SessionStore defines the persistence operations used by the agent loop. +// Both SessionManager (legacy JSON backend) and JSONLBackend satisfy this +// interface, allowing the storage layer to be swapped without touching the +// agent loop code. +// +// Write methods (Add*, Set*, Truncate*) are fire-and-forget: they do not +// return errors. Implementations should log failures internally. This +// matches the original SessionManager contract that the agent loop relies on. +type SessionStore interface { + // AddMessage appends a simple role/content message to the session. + AddMessage(sessionKey, role, content string) + // AddFullMessage appends a complete message including tool calls. + AddFullMessage(sessionKey string, msg providers.Message) + // GetHistory returns the full message history for the session. + GetHistory(key string) []providers.Message + // GetSummary returns the conversation summary, or "" if none. + GetSummary(key string) string + // SetSummary replaces the conversation summary. + SetSummary(key, summary string) + // SetHistory replaces the full message history. + SetHistory(key string, history []providers.Message) + // TruncateHistory keeps only the last keepLast messages. + TruncateHistory(key string, keepLast int) + // Save persists any pending state to durable storage. + Save(key string) error + // Close releases resources held by the store. + Close() error +} diff --git a/pkg/tools/web.go b/pkg/tools/web.go index eeceabd98..e248ea966 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -11,6 +11,7 @@ import ( "net/url" "regexp" "strings" + "sync/atomic" "time" ) @@ -76,81 +77,140 @@ func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err return client, nil } +type APIKeyPool struct { + keys []string + current uint32 +} + +func NewAPIKeyPool(keys []string) *APIKeyPool { + return &APIKeyPool{ + keys: keys, + } +} + +type APIKeyIterator struct { + pool *APIKeyPool + startIdx uint32 + attempt uint32 +} + +func (p *APIKeyPool) NewIterator() *APIKeyIterator { + if len(p.keys) == 0 { + return &APIKeyIterator{pool: p} + } + idx := atomic.AddUint32(&p.current, 1) - 1 + return &APIKeyIterator{ + pool: p, + startIdx: idx, + } +} + +func (it *APIKeyIterator) Next() (string, bool) { + length := uint32(len(it.pool.keys)) + if length == 0 || it.attempt >= length { + return "", false + } + key := it.pool.keys[(it.startIdx+it.attempt)%length] + it.attempt++ + return key, true +} + type SearchProvider interface { Search(ctx context.Context, query string, count int) (string, error) } type BraveSearchProvider struct { - apiKey string - proxy string - client *http.Client + keyPool *APIKeyPool + proxy string + client *http.Client } func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", url.QueryEscape(query), count) - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } + var lastErr error + iter := p.keyPool.NewIterator() - req.Header.Set("Accept", "application/json") - req.Header.Set("X-Subscription-Token", p.apiKey) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("brave api error (status %d): %s", resp.StatusCode, string(body)) - } - - var searchResp struct { - Web struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Description string `json:"description"` - } `json:"results"` - } `json:"web"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - // Log error body for debugging - fmt.Printf("Brave API Error Body: %s\n", string(body)) - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.Web.Results - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("Results for: %s", query)) - for i, item := range results { - if i >= count { + for { + apiKey, ok := iter.Next() + if !ok { break } - lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) - if item.Description != "" { - lines = append(lines, fmt.Sprintf(" %s", item.Description)) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Subscription-Token", apiKey) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Web struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + } `json:"results"` + } `json:"web"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + // Log error body for debugging + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Web.Results + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Description != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Description)) + } + } + + return strings.Join(lines, "\n"), nil } - return strings.Join(lines, "\n"), nil + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } type TavilySearchProvider struct { - apiKey string + keyPool *APIKeyPool baseURL string proxy string client *http.Client @@ -162,74 +222,96 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i searchURL = "https://api.tavily.com/search" } - payload := map[string]any{ - "api_key": p.apiKey, - "query": query, - "search_depth": "advanced", - "include_answer": false, - "include_images": false, - "include_raw_content": false, - "max_results": count, - } + var lastErr error + iter := p.keyPool.NewIterator() - bodyBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) - } - - var searchResp struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Content string `json:"content"` - } `json:"results"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.Results - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query)) - for i, item := range results { - if i >= count { + for { + apiKey, ok := iter.Next() + if !ok { break } - lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) - if item.Content != "" { - lines = append(lines, fmt.Sprintf(" %s", item.Content)) + + payload := map[string]any{ + "api_key": apiKey, + "query": query, + "search_depth": "advanced", + "include_answer": false, + "include_images": false, + "include_raw_content": false, + "max_results": count, } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"results"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Results + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Content != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Content)) + } + } + + return strings.Join(lines, "\n"), nil } - return strings.Join(lines, "\n"), nil + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } type DuckDuckGoSearchProvider struct { @@ -324,75 +406,97 @@ func stripTags(content string) string { } type PerplexitySearchProvider struct { - apiKey string - proxy string - client *http.Client + keyPool *APIKeyPool + proxy string + client *http.Client } func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := "https://api.perplexity.ai/chat/completions" - 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.", + var lastErr error + iter := p.keyPool.NewIterator() + + for { + apiKey, ok := iter.Next() + if !ok { + break + } + + 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": "user", - "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count), - }, - }, - "max_tokens": 1000, + "max_tokens": 1000, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes))) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("Perplexity API error: %s", string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(searchResp.Choices) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil } - payloadBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes))) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+p.apiKey) - req.Header.Set("User-Agent", userAgent) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Perplexity API error: %s", string(body)) - } - - var searchResp struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - if len(searchResp.Choices) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } type SearXNGSearchProvider struct { @@ -545,16 +649,16 @@ type WebSearchTool struct { } type WebSearchToolOptions struct { - BraveAPIKey string + BraveAPIKeys []string BraveMaxResults int BraveEnabled bool - TavilyAPIKey string + TavilyAPIKeys []string TavilyBaseURL string TavilyMaxResults int TavilyEnabled bool DuckDuckGoMaxResults int DuckDuckGoEnabled bool - PerplexityAPIKey string + PerplexityAPIKeys []string PerplexityMaxResults int PerplexityEnabled bool SearXNGBaseURL string @@ -571,23 +675,26 @@ type WebSearchToolOptions struct { func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider maxResults := 5 - // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search - if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { + if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 { client, err := createHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) } - provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client} + provider = &PerplexitySearchProvider{ + keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys), + proxy: opts.Proxy, + client: client, + } if opts.PerplexityMaxResults > 0 { maxResults = opts.PerplexityMaxResults } - } else if opts.BraveEnabled && opts.BraveAPIKey != "" { + } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 { client, err := createHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err) } - provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client} + provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client} if opts.BraveMaxResults > 0 { maxResults = opts.BraveMaxResults } @@ -596,13 +703,13 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.SearXNGMaxResults > 0 { maxResults = opts.SearXNGMaxResults } - } else if opts.TavilyEnabled && opts.TavilyAPIKey != "" { + } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 { client, err := createHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) } provider = &TavilySearchProvider{ - apiKey: opts.TavilyAPIKey, + keyPool: NewAPIKeyPool(opts.TavilyAPIKeys), baseURL: opts.TavilyBaseURL, proxy: opts.Proxy, client: client, diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index bdd30d385..188fb8adb 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -249,7 +249,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { // TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing func TestWebTool_WebSearch_NoApiKey(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) + tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil}) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -269,7 +269,11 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) { // TestWebTool_WebSearch_MissingQuery verifies error handling for missing query func TestWebTool_WebSearch_MissingQuery(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BraveEnabled: true, + BraveAPIKeys: []string{"test-key"}, + BraveMaxResults: 5, + }) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -553,7 +557,7 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Run("perplexity", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ PerplexityEnabled: true, - PerplexityAPIKey: "k", + PerplexityAPIKeys: []string{"k"}, PerplexityMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) @@ -572,7 +576,7 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Run("brave", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ BraveEnabled: true, - BraveAPIKey: "k", + BraveAPIKeys: []string{"k"}, BraveMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) @@ -650,7 +654,7 @@ func TestWebTool_TavilySearch_Success(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ TavilyEnabled: true, - TavilyAPIKey: "test-key", + TavilyAPIKeys: []string{"test-key"}, TavilyBaseURL: server.URL, TavilyMaxResults: 5, }) @@ -682,6 +686,121 @@ func TestWebTool_TavilySearch_Success(t *testing.T) { } } +func TestAPIKeyPool(t *testing.T) { + pool := NewAPIKeyPool([]string{"key1", "key2", "key3"}) + if len(pool.keys) != 3 { + t.Fatalf("expected 3 keys, got %d", len(pool.keys)) + } + if pool.keys[0] != "key1" || pool.keys[1] != "key2" || pool.keys[2] != "key3" { + t.Fatalf("unexpected keys: %v", pool.keys) + } + + // Test Iterator: each iterator should cover all keys exactly once + iter := pool.NewIterator() + expected := []string{"key1", "key2", "key3"} + for i, want := range expected { + k, ok := iter.Next() + if !ok { + t.Fatalf("iter.Next() returned false at step %d", i) + } + if k != want { + t.Errorf("step %d: expected %s, got %s", i, want, k) + } + } + // Should be exhausted + if _, ok := iter.Next(); ok { + t.Errorf("expected iterator exhausted after all keys") + } + + // Second iterator starts at next position (load balancing) + iter2 := pool.NewIterator() + k, ok := iter2.Next() + if !ok { + t.Fatal("iter2.Next() returned false") + } + if k != "key2" { + t.Errorf("expected key2 (round-robin), got %s", k) + } + + // Empty pool + emptyPool := NewAPIKeyPool([]string{}) + emptyIter := emptyPool.NewIterator() + if _, ok := emptyIter.Next(); ok { + t.Errorf("expected false for empty pool") + } + + // Single key pool + singlePool := NewAPIKeyPool([]string{"single"}) + singleIter := singlePool.NewIterator() + if k, ok := singleIter.Next(); !ok || k != "single" { + t.Errorf("expected single, got %s (ok=%v)", k, ok) + } + if _, ok := singleIter.Next(); ok { + t.Errorf("expected exhausted after single key") + } +} + +func TestWebTool_TavilySearch_Failover(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + + apiKey := payload["api_key"].(string) + + if apiKey == "key1" { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte("Rate limited")) + return + } + + if apiKey == "key2" { + // Success + response := map[string]any{ + "results": []map[string]any{ + { + "title": "Success Result", + "url": "https://example.com/success", + "content": "Success content", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + return + } + + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKeys: []string{"key1", "key2"}, + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + ctx := context.Background() + args := map[string]any{ + "query": "test query", + } + + result := tool.Execute(ctx, args) + + if result.IsError { + t.Errorf("Expected success, got Error: %s", result.ForLLM) + } + if !strings.Contains(result.ForUser, "Success Result") { + t.Errorf("Expected failover to second key and success result, got: %s", result.ForUser) + } +} + func TestWebTool_GLMSearch_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 02f346db4..dbaafdb7f 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -2,9 +2,18 @@ package utils import ( "strings" + "sync/atomic" "unicode" ) +// Global variable to disable truncation +var disableTruncation atomic.Bool + +// SetDisableTruncation globally enables or disables string truncation +func SetDisableTruncation(enabled bool) { + disableTruncation.Store(enabled) +} + // SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, // zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. @@ -30,6 +39,10 @@ func SanitizeMessageContent(input string) string { // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { + // If the no-truncate flag is active, it returns the full string + if disableTruncation.Load() { + return s + } if maxLen <= 0 { return "" } diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 1aea1c801..8f86dd73d 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -134,6 +134,12 @@ func (h *Handler) startGatewayLocked() (int, error) { execPath := findPicoclawBinary() cmd := exec.Command(execPath, "gateway") + // Forward the launcher's config path via the environment variable that + // GetConfigPath() already reads, so the gateway sub-process uses the same + // config file without requiring a --config flag on the gateway subcommand. + if h.configPath != "" { + cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+h.configPath) + } stdoutPipe, err := cmd.StdoutPipe() if err != nil { @@ -530,18 +536,32 @@ func (h *Handler) currentGatewayStatus() string { } // findPicoclawBinary locates the picoclaw executable. -// Tries the same directory as the current executable first, then falls back to $PATH. +// Search order: +// 1. PICOCLAW_BINARY environment variable (explicit override) +// 2. Same directory as the current executable +// 3. Falls back to "picoclaw" and relies on $PATH func findPicoclawBinary() string { - if exe, err := os.Executable(); err == nil { - dir := filepath.Dir(exe) - candidate := filepath.Join(dir, "picoclaw") - if runtime.GOOS == "windows" { - candidate += ".exe" + binaryName := "picoclaw" + if runtime.GOOS == "windows" { + binaryName = "picoclaw.exe" + } + + // 1. Explicit override via environment variable + if p := os.Getenv("PICOCLAW_BINARY"); p != "" { + if info, _ := os.Stat(p); info != nil && !info.IsDir() { + return p } + } + + // 2. Same directory as the launcher executable + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), binaryName) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } } + + // 3. Fall back to PATH lookup return "picoclaw" } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 336bb6a0c..998c133b5 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -120,3 +121,30 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"]) } } + +func TestFindPicoclawBinary_EnvOverride(t *testing.T) { + // Create a temporary file to act as the mock binary + tmpDir := t.TempDir() + mockBinary := filepath.Join(tmpDir, "picoclaw-mock") + if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + t.Setenv("PICOCLAW_BINARY", mockBinary) + + got := findPicoclawBinary() + if got != mockBinary { + t.Errorf("findPicoclawBinary() = %q, want %q", got, mockBinary) + } +} + +func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) { + // When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy + t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary") + + got := findPicoclawBinary() + // Should not return the invalid path; falls back to "picoclaw" or another found path + if got == "/nonexistent/picoclaw-binary" { + t.Errorf("findPicoclawBinary() returned invalid env path %q, expected fallback", got) + } +} diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md index dabb0e14b..20e3e49fa 100644 --- a/workspace/IDENTITY.md +++ b/workspace/IDENTITY.md @@ -6,9 +6,6 @@ PicoClaw 🦞 ## Description Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. -## Version -0.1.0 - ## Purpose - Provide intelligent AI assistance with minimal resource usage - Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)