package config import ( "encoding/json" "errors" "fmt" "math/rand" "os" "path/filepath" "strconv" "strings" "sync/atomic" "time" "github.com/caarlos0/env/v11" "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" ) // rrCounter is a global counter for round-robin load balancing across models. var rrCounter atomic.Uint64 // CurrentVersion is the latest config schema version const CurrentVersion = 3 func init() { initChannel() } // Config is the current config structure with version support. type Config struct { // Config schema version for migration. Version int `json:"version" yaml:"-"` Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"` Agents AgentsConfig `json:"agents" yaml:"-"` Session SessionConfig `json:"session,omitempty" yaml:"-"` Channels ChannelsConfig `json:"channel_list" yaml:"channel_list"` ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway" yaml:"-"` Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` Tools ToolsConfig `json:"tools" yaml:",inline"` Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` Devices DevicesConfig `json:"devices" yaml:"-"` Voice VoiceConfig `json:"voice" yaml:"-"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"` // cache for sensitive values and compiled regex (computed once) sensitiveCache *SensitiveDataCache } // IsolationConfig controls subprocess isolation for commands started by PicoClaw. // It is applied by the isolation package rather than by sandboxing the main process. type IsolationConfig struct { Enabled bool `json:"enabled,omitempty"` ExposePaths []ExposePath `json:"expose_paths,omitempty"` } // ExposePath describes a host path that should remain visible inside the isolated // child-process environment. This is currently implemented on Linux only. type ExposePath struct { Source string `json:"source"` Target string `json:"target,omitempty"` Mode string `json:"mode"` } // FilterSensitiveData filters sensitive values from content before sending to LLM. // This prevents the LLM from seeing its own credentials. // Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig). // Short content (below FilterMinLength) is returned unchanged for performance. func (c *Config) FilterSensitiveData(content string) string { // Check if filtering is enabled (default: true) if !c.Tools.IsFilterSensitiveDataEnabled() { return content } // Fast path: skip filtering for short content if len(content) < c.Tools.GetFilterMinLength() { return content } return c.SensitiveDataReplacer().Replace(content) } type HooksConfig struct { Enabled bool `json:"enabled"` Defaults HookDefaultsConfig `json:"defaults,omitempty"` Builtins map[string]BuiltinHookConfig `json:"builtins,omitempty"` Processes map[string]ProcessHookConfig `json:"processes,omitempty"` } type HookDefaultsConfig struct { ObserverTimeoutMS int `json:"observer_timeout_ms,omitempty"` InterceptorTimeoutMS int `json:"interceptor_timeout_ms,omitempty"` ApprovalTimeoutMS int `json:"approval_timeout_ms,omitempty"` } type BuiltinHookConfig struct { Enabled bool `json:"enabled"` Priority int `json:"priority,omitempty"` Config json.RawMessage `json:"config,omitempty"` } type ProcessHookConfig struct { Enabled bool `json:"enabled"` Priority int `json:"priority,omitempty"` Transport string `json:"transport,omitempty"` Command []string `json:"command,omitempty"` Dir string `json:"dir,omitempty"` Env map[string]string `json:"env,omitempty"` Observe []string `json:"observe,omitempty"` Intercept []string `json:"intercept,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 // to omit providers section when empty and session when empty. func (c *Config) MarshalJSON() ([]byte, error) { type Alias Config aux := &struct { Session *SessionConfig `json:"session,omitempty"` *Alias }{ Alias: (*Alias)(c), } if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 { sessionCfg := c.Session aux.Session = &sessionCfg } return json.Marshal(aux) } type AgentsConfig struct { Defaults AgentDefaults `json:"defaults"` List []AgentConfig `json:"list,omitempty"` Dispatch *DispatchConfig `json:"dispatch,omitempty"` } // AgentModelConfig supports both string and structured model config. // String format: "gpt-4" (just primary, no fallbacks) // Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]} type AgentModelConfig struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err == nil { m.Primary = s m.Fallbacks = nil return nil } type raw struct { Primary string `json:"primary"` Fallbacks []string `json:"fallbacks"` } var r raw if err := json.Unmarshal(data, &r); err != nil { return err } m.Primary = r.Primary m.Fallbacks = r.Fallbacks return nil } func (m AgentModelConfig) MarshalJSON() ([]byte, error) { if len(m.Fallbacks) == 0 && m.Primary != "" { return json.Marshal(m.Primary) } type raw struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) } type AgentConfig struct { ID string `json:"id"` Default bool `json:"default,omitempty"` Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } type SubagentsConfig struct { AllowAgents []string `json:"allow_agents,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` } type DispatchConfig struct { Rules []DispatchRule `json:"rules,omitempty"` } type DispatchRule struct { Name string `json:"name,omitempty"` Agent string `json:"agent"` When DispatchSelector `json:"when"` SessionDimensions []string `json:"session_dimensions,omitempty"` } type DispatchSelector struct { Channel string `json:"channel,omitempty"` Account string `json:"account,omitempty"` Space string `json:"space,omitempty"` Chat string `json:"chat,omitempty"` Topic string `json:"topic,omitempty"` Sender string `json:"sender,omitempty"` Mentioned *bool `json:"mentioned,omitempty"` } type SessionConfig struct { Dimensions []string `json:"dimensions,omitempty"` IdentityLinks map[string][]string `json:"identity_links,omitempty"` } // RoutingConfig controls the intelligent model routing feature. // When enabled, each incoming message is scored against structural features // (message length, code blocks, tool call history, conversation depth, attachments). // Messages scoring below Threshold are sent to LightModel; all others use the // agent's primary model. This reduces cost and latency for simple tasks without // requiring any keyword matching — all scoring is language-agnostic. type RoutingConfig struct { Enabled bool `json:"enabled"` LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model } // SubTurnConfig configures the SubTurn execution system. type SubTurnConfig struct { MaxDepth int `json:"max_depth" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_DEPTH"` MaxConcurrent int `json:"max_concurrent" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_CONCURRENT"` DefaultTimeoutMinutes int `json:"default_timeout_minutes" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TIMEOUT_MINUTES"` DefaultTokenBudget int `json:"default_token_budget" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TOKEN_BUDGET"` ConcurrencyTimeoutSec int `json:"concurrency_timeout_sec" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_CONCURRENCY_TIMEOUT_SEC"` } type ToolFeedbackConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"` } 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"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelFallbacks []string `json:"model_fallbacks,omitempty"` 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"` ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"` SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB func (d *AgentDefaults) GetMaxMediaSize() int { if d.MaxMediaSize > 0 { return d.MaxMediaSize } return DefaultMaxMediaSize } // GetToolFeedbackMaxArgsLength returns the max visible text length for tool feedback messages. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength } return 300 } // IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat. func (d *AgentDefaults) IsToolFeedbackEnabled() bool { return d.ToolFeedback.Enabled } // IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback // update should be sent as its own chat message instead of editing a single // in-place progress message. func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool { return d.ToolFeedback.SeparateMessages } // GetModelName returns the effective model name for the agent defaults. // It prefers the new "model_name" field but falls back to "model" for backward compatibility. func (d *AgentDefaults) GetModelName() string { return d.ModelName } // GroupTriggerConfig controls when the bot responds in group chats. type GroupTriggerConfig struct { MentionOnly bool `json:"mention_only,omitempty"` Prefixes []string `json:"prefixes,omitempty"` } // TypingConfig controls typing indicator behavior (Phase 10). type TypingConfig struct { Enabled bool `json:"enabled,omitempty"` } // PlaceholderConfig controls placeholder message behavior (Phase 10). type PlaceholderConfig struct { Enabled bool `json:"enabled"` Text FlexibleStringSlice `json:"text,omitempty"` } // GetRandomText returns a random placeholder text, or default if none set. func (p *PlaceholderConfig) GetRandomText() string { if len(p.Text) == 0 { return "Thinking..." } if len(p.Text) == 1 { return p.Text[0] } idx := rand.Intn(len(p.Text)) return p.Text[idx] } type StreamingConfig struct { Enabled bool `json:"enabled,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED"` ThrottleSeconds int `json:"throttle_seconds,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS"` MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"` } type WhatsAppSettings struct { BridgeURL string `json:"bridge_url" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` UseNative bool `json:"use_native" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` SessionStorePath string `json:"session_store_path" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` } type TelegramSettings struct { Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } type FeishuSettings struct { AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` EncryptKey SecureString `json:"encrypt_key,omitzero" yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken SecureString `json:"verification_token,omitzero" yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` IsLark bool `json:"is_lark" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` } type DiscordSettings struct { Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` MentionOnly bool `json:"mention_only" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` } type MaixCamSettings struct { Host string `json:"host" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Port int `json:"port" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` } type QQSettings struct { AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` MaxMessageLength int `json:"max_message_length" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` SendMarkdown bool `json:"send_markdown" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` } type DingTalkSettings struct { ClientID string `json:"client_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret SecureString `json:"client_secret,omitzero" yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` } type SlackSettings struct { BotToken SecureString `json:"bot_token,omitzero" yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` AppToken SecureString `json:"app_token,omitzero" yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` } type MatrixSettings struct { Homeserver string `json:"homeserver" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` UserID string `json:"user_id" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` DeviceID string `json:"device_id,omitempty" yaml:"-"` JoinOnInvite bool `json:"join_on_invite" yaml:"-"` MessageFormat string `json:"message_format,omitempty" yaml:"-"` CryptoDatabasePath string `json:"crypto_database_path,omitempty" yaml:"-"` CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"` } type LINESettings struct { ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` WebhookHost string `json:"webhook_host" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` WebhookPort int `json:"webhook_port" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` } type OneBotSettings struct { WSUrl string `json:"ws_url" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` ReconnectInterval int `json:"reconnect_interval" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` } type WeComGroupConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"` } type WeComSettings struct { BotID string `json:"bot_id" yaml:"-" env:"BOT_ID"` Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"SECRET"` WebSocketURL string `json:"websocket_url,omitempty" yaml:"-" env:"WEBSOCKET_URL"` SendThinkingMessage bool `json:"send_thinking_message" yaml:"-" env:"SEND_THINKING_MESSAGE"` } func (c *WeComSettings) SetSecret(secret string) { c.Secret = *NewSecureString(secret) } type WeixinSettings struct { Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` AccountID string `json:"account_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"` BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"` CDNBaseURL string `json:"cdn_base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"` Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"` } // SetToken sets the Weixin token and marks it as dirty for security saving func (c *WeixinSettings) SetToken(token string) { c.Token = *NewSecureString(token) } type PicoSettings struct { Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` AllowTokenQuery bool `json:"allow_token_query,omitempty" yaml:"-"` AllowOrigins []string `json:"allow_origins,omitempty" yaml:"-"` PingInterval int `json:"ping_interval,omitempty" yaml:"-"` ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"` WriteTimeout int `json:"write_timeout,omitempty" yaml:"-"` MaxConnections int `json:"max_connections,omitempty" yaml:"-"` } // SetToken sets the Pico token and marks it as dirty for security saving func (c *PicoSettings) SetToken(token string) { c.Token = *NewSecureString(token) } type PicoClientSettings struct { URL string `json:"url" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"` Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"` SessionID string `json:"session_id,omitempty" yaml:"-"` PingInterval int `json:"ping_interval,omitempty" yaml:"-"` ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"` } type IRCSettings struct { Server string `json:"server" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SERVER"` TLS bool `json:"tls" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_TLS"` Nick string `json:"nick" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_NICK"` User string `json:"user,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_USER"` RealName string `json:"real_name,omitempty" yaml:"-"` Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` NickServPassword SecureString `json:"nickserv_password,omitzero" yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` SASLUser string `json:"sasl_user" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` SASLPassword SecureString `json:"sasl_password,omitzero" yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` Channels FlexibleStringSlice `json:"channels" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" yaml:"-"` } type VKSettings struct { Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"` GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"` } func (c *VKSettings) SetToken(token string) { c.Token = *NewSecureString(token) } // TeamsWebhookSettings configures the output-only Microsoft Teams webhook channel. // Multiple webhook targets can be configured and selected via ChatID at send time. type TeamsWebhookSettings struct { Webhooks map[string]TeamsWebhookTarget `json:"webhooks" yaml:"webhooks,omitempty"` } // TeamsWebhookTarget represents a single Teams webhook destination. type TeamsWebhookTarget struct { WebhookURL SecureString `json:"webhook_url,omitzero" yaml:"webhook_url,omitempty"` Title string `json:"title,omitempty" yaml:"-"` } type HeartbeatConfig struct { 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"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } type VoiceConfig struct { ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` TTSModelName string `json:"tts_model_name,omitempty" env:"PICOCLAW_VOICE_TTS_MODEL_NAME"` EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"` } // ModelConfig represents a model-centric provider configuration. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only. // The Model field may be either a plain model identifier or a provider-prefixed // identifier such as "openai/gpt-5.4" or "nvidia/z-ai/glm-5.1". // Supported providers include openai, anthropic, antigravity, claude-cli, // codex-cli, github-copilot, and named OpenAI-compatible protocols such as // groq, deepseek, modelscope, and novita. type ModelConfig struct { // Required fields ModelName string `json:"model_name"` // User-facing alias for the model Provider string `json:"provider"` // Provider name for routing and selection. When empty, provider resolution infers it from Model. Model string `json:"model"` // Model identifier, optionally provider-prefixed. // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL Proxy string `json:"proxy,omitempty"` // HTTP proxy URL Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover // Special providers (CLI-based, OAuth, etc.) AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations RPM int `json:"rpm,omitempty"` // Requests per minute limit MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") RequestTimeout int `json:"request_timeout,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Additional headers to inject into every HTTP request APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) // Enabled indicates whether this model entry is active. When omitted in // existing configs, the field is inferred during load: models with API keys // or the reserved "local-model" name are auto-enabled. Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` // UserAgent is the user agent string to use for HTTP requests. UserAgent string `json:"user_agent,omitempty" yaml:"-"` // isVirtual marks this model as a virtual model generated from multi-key expansion. // Virtual models should not be persisted to config files. isVirtual bool } // APIKey returns the first API key from apiKeys func (c *ModelConfig) APIKey() string { if len(c.APIKeys) > 0 { return c.APIKeys[0].String() } return "" } // IsVirtual returns true if this model was generated from multi-key expansion. func (c *ModelConfig) IsVirtual() bool { return c.isVirtual } // Validate checks if the ModelConfig has all required fields. func (c *ModelConfig) Validate() error { if c.ModelName == "" { return fmt.Errorf("model_name is required") } if c.Model == "" { return fmt.Errorf("model is required") } return nil } func (c *ModelConfig) SetAPIKey(value string) { if len(c.APIKeys) > 0 { c.APIKeys[0].Set(value) } else { c.APIKeys = append(c.APIKeys, NewSecureString(value)) } } type ToolDiscoveryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"` UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"` UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"` } type ToolConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"` } type BraveConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } // APIKey returns the Brave API key func (c *BraveConfig) APIKey() string { if len(c.APIKeys) == 0 { return "" } return c.APIKeys[0].String() } // SetAPIKey sets the Brave API key func (c *BraveConfig) SetAPIKey(key string) { c.APIKeys = SimpleSecureStrings(key) } func (c *BraveConfig) SetAPIKeys(keys []string) { c.APIKeys = SimpleSecureStrings(keys...) } type TavilyConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } // APIKey returns the Tavily API key func (c *TavilyConfig) APIKey() string { if len(c.APIKeys) == 0 { return "" } return c.APIKeys[0].String() } // SetAPIKey sets the Tavily API key func (c *TavilyConfig) SetAPIKey(key string) { c.APIKeys = SimpleSecureStrings(key) } // SetAPIKeys sets the Tavily API keys func (c *TavilyConfig) SetAPIKeys(keys []string) { c.APIKeys = make(SecureStrings, len(keys)) for i, k := range keys { c.APIKeys[i] = NewSecureString(k) } } type DuckDuckGoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } type SogouConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SOGOU_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SOGOU_MAX_RESULTS"` } type PerplexityConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } // APIKey returns the Perplexity API key func (c *PerplexityConfig) APIKey() string { if len(c.APIKeys) == 0 { return "" } return c.APIKeys[0].String() } // SetAPIKey sets the Perplexity API key func (c *PerplexityConfig) SetAPIKey(key string) { c.APIKeys = SimpleSecureStrings(key) } type SearXNGConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"` BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"` } type GLMSearchConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". SearchEngine string `json:"search_engine" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` } type BaiduSearchConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"` APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"` MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` } type WebToolsConfig struct { ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` Sogou SogouConfig `yaml:"-" json:"sogou"` DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"` SearXNG SearXNGConfig `yaml:"-" json:"searxng"` GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"` BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"` Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"` // PreferNative controls whether to use provider-native web search when // the active LLM supports it (e.g. OpenAI web_search_preview). When true, // the client-side web_search tool is hidden to avoid duplicate search surfaces, // and the provider's built-in search is used instead. Falls back to client-side // search when the provider does not support native search. PreferNative bool `yaml:"-" json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. Proxy string `yaml:"-" json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` FetchLimitBytes int64 `yaml:"-" json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` Format string `yaml:"-" json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` PrivateHostWhitelist FlexibleStringSlice `yaml:"-" json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` ExecTimeoutMinutes int ` json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"` } type ExecConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` EnableDenyPatterns bool ` json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` AllowRemote bool ` json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"` CustomDenyPatterns []string ` json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` CustomAllowPatterns []string ` json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"` TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s) } type SkillsToolsConfig struct { ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig `yaml:"registries,omitempty" json:"registries"` // Deprecated: use registries.github instead. Github SkillsGithubConfig `yaml:"github,omitempty" json:"github"` MaxConcurrentSearches int `yaml:"-" json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` SearchCache SearchCacheConfig `yaml:"-" json:"search_cache"` } type MediaCleanupConfig struct { ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"` MaxAge int ` json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` Interval int ` json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` } type ReadFileToolConfig struct { Enabled bool `json:"enabled"` Mode string `json:"mode"` MaxReadFileSize int `json:"max_read_file_size"` } const ( ReadFileModeBytes = "bytes" ReadFileModeLines = "lines" ) func (c ReadFileToolConfig) EffectiveMode() string { switch strings.ToLower(strings.TrimSpace(c.Mode)) { case ReadFileModeLines: return ReadFileModeLines case "", ReadFileModeBytes: return ReadFileModeBytes default: return ReadFileModeBytes } } type ToolsConfig struct { AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` // FilterSensitiveData controls whether to filter sensitive values (API keys, // tokens, secrets) from tool results before sending to the LLM. // Default: true (enabled) FilterSensitiveData bool `json:"filter_sensitive_data" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` // FilterMinLength is the minimum content length required for filtering. // Content shorter than this will be returned unchanged for performance. // Default: 8 FilterMinLength int `json:"filter_min_length" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` Web WebToolsConfig `json:"web" yaml:"web,omitempty"` Cron CronToolsConfig `json:"cron" yaml:"-"` Exec ExecConfig `json:"exec" yaml:"-"` Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` MCP MCPConfig `json:"mcp" yaml:"-"` AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"` InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"` Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled func (c *ToolsConfig) IsFilterSensitiveDataEnabled() bool { return c.FilterSensitiveData } // GetFilterMinLength returns the minimum content length for filtering (default: 8) func (c *ToolsConfig) GetFilterMinLength() int { if c.FilterMinLength <= 0 { return 8 } return c.FilterMinLength } type SearchCacheConfig struct { MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` } type SkillsRegistriesConfig []*SkillRegistryConfig func (c *SkillsRegistriesConfig) Get(name string) (SkillRegistryConfig, bool) { if c == nil { return SkillRegistryConfig{}, false } name = strings.TrimSpace(name) if name == "" { return SkillRegistryConfig{}, false } for _, registry := range *c { if registry == nil || registry.Name != name { continue } return *registry, true } return SkillRegistryConfig{}, false } func (c *SkillsRegistriesConfig) Set(name string, cfg SkillRegistryConfig) { if c == nil { return } name = strings.TrimSpace(name) if name == "" { return } cfg.Name = name for i, registry := range *c { if registry == nil || registry.Name != name { continue } (*c)[i] = &cfg return } *c = append(*c, &cfg) } type SkillsGithubConfig struct { BaseURL string `json:"base_url,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_BASE_URL"` Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN"` Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` } type SkillRegistryConfig struct { Name string `json:"name,omitempty" yaml:"-" env:"-"` Enabled bool `json:"enabled" yaml:"-" env:"-"` BaseURL string `json:"base_url" yaml:"-" env:"-"` AuthToken SecureString `json:"auth_token,omitzero" yaml:"auth_token,omitempty" env:"-"` Param map[string]any `json:"-" yaml:"-" env:"-"` } const ( envSkillsClawHubEnabled = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED" envSkillsClawHubBaseURL = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL" envSkillsClawHubAuthToken = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN" envSkillsClawHubSearchPath = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH" envSkillsClawHubSkillsPath = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH" envSkillsClawHubDownloadPath = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH" envSkillsClawHubTimeout = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT" envSkillsClawHubMaxZipSize = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE" envSkillsClawHubMaxResponseSize = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE" envSkillsGitHubEnabled = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_ENABLED" envSkillsGitHubBaseURL = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_BASE_URL" envSkillsGitHubAuthToken = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_AUTH_TOKEN" envSkillsGitHubProxy = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_PROXY" ) func (c *SkillRegistryConfig) DecodeParam(target any) error { if c == nil { return nil } if len(c.Param) == 0 { return nil } data, err := json.Marshal(c.Param) if err != nil { return err } return json.Unmarshal(data, target) } // MCPServerConfig defines configuration for a single MCP server type MCPServerConfig struct { // Enabled indicates whether this MCP server is active Enabled bool `json:"enabled"` // Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode). // When nil, the global Discovery.Enabled setting applies. // When explicitly set to true or false, it overrides the global setting for this server only. Deferred *bool `json:"deferred,omitempty"` // Command is the executable to run (e.g., "npx", "python", "/path/to/server") Command string `json:"command"` // Args are the arguments to pass to the command Args []string `json:"args,omitempty"` // Env are environment variables to set for the server process (stdio only) Env map[string]string `json:"env,omitempty"` // EnvFile is the path to a file containing environment variables (stdio only) EnvFile string `json:"env_file,omitempty"` // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set) Type string `json:"type,omitempty"` // URL is used for SSE/HTTP transport URL string `json:"url,omitempty"` // Headers are HTTP headers to send with requests (sse/http only) Headers map[string]string `json:"headers,omitempty"` } // MCPConfig defines configuration for all MCP servers type MCPConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` Discovery ToolDiscoveryConfig ` json:"discovery"` // MaxInlineTextChars controls how much MCP text stays inline before it is saved as an artifact. MaxInlineTextChars int `json:"max_inline_text_chars,omitempty" env:"PICOCLAW_TOOLS_MCP_MAX_INLINE_TEXT_CHARS"` // Servers is a map of server name to server configuration Servers map[string]MCPServerConfig `json:"servers,omitempty"` } const DefaultMCPMaxInlineTextChars = 16 * 1024 func (c *MCPConfig) GetMaxInlineTextChars() int { if c.MaxInlineTextChars > 0 { return c.MaxInlineTextChars } return DefaultMCPMaxInlineTextChars } func LoadConfig(path string) (*Config, error) { updateResolver(filepath.Dir(path)) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { logger.WarnF( "config file not found, using default config", map[string]any{"path": path}, ) return DefaultConfig(), nil } return nil, err } // First, try to detect config version by reading the version field var versionInfo struct { Version int `json:"version"` } if e := json.Unmarshal(data, &versionInfo); e != nil { return nil, fmt.Errorf("failed to detect config version: %w", e) } if len(data) <= 10 { logger.Warn(fmt.Sprintf("content is [%s]", string(data))) return DefaultConfig(), nil } // Load config based on detected version var cfg *Config switch versionInfo.Version { case 0: logger.InfoF( "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) var m map[string]any m, err = loadConfigMap(path) if err != nil { return nil, err } migrateErr := migrateV0ToV1(m) if migrateErr != nil { return nil, fmt.Errorf("V0→V1 migration failed: %w", migrateErr) } migrateErr = migrateV1ToV2(m) if migrateErr != nil { return nil, fmt.Errorf("V1→V2 migration failed: %w", migrateErr) } migrateErr = migrateV2ToV3(m) if migrateErr != nil { return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr) } var migrated []byte migrated, err = json.Marshal(m) if err != nil { return nil, err } cfg, err = loadConfig(migrated) if err != nil { return nil, err } err = makeBackup(path) if err != nil { return nil, err } defer func(cfg *Config) { _ = SaveConfig(path, cfg) }(cfg) case 1: // V1→V3 migration: rename channels→channel_list, infer Enabled, migrate channel configs logger.InfoF( "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) var m map[string]any m, err = loadConfigMap(path) if err != nil { return nil, err } migrateErr := migrateV1ToV2(m) if migrateErr != nil { return nil, fmt.Errorf("V1→V2 migration failed: %w", migrateErr) } migrateErr = migrateV2ToV3(m) if migrateErr != nil { return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr) } var migrated []byte migrated, err = json.Marshal(m) if err != nil { return nil, err } cfg, err = loadConfig(migrated) if err != nil { return nil, err } err = makeBackup(path) if err != nil { return nil, err } defer func(cfg *Config) { _ = SaveConfig(path, cfg) }(cfg) logger.InfoF( "config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) case 2: // V2→V3 migration: rename channels→channel_list, convert flat→nested logger.InfoF( "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) var m map[string]any m, err = loadConfigMap(path) if err != nil { return nil, err } migrateErr := migrateV2ToV3(m) if migrateErr != nil { return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr) } var migrated []byte migrated, err = json.Marshal(m) if err != nil { return nil, err } cfg, err = loadConfig(migrated) if err != nil { return nil, err } err = makeBackup(path) if err != nil { return nil, err } defer func(cfg *Config) { _ = SaveConfig(path, cfg) }(cfg) logger.InfoF( "config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) case CurrentVersion: // Current version cfg, err = loadConfig(data) if err != nil { return nil, err } // Load security configuration secPath := securityPath(path) err = loadSecurityConfig(cfg, secPath) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("failed to load security config: %w", err) } default: return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) } applyLegacyBindingsMigration(data, cfg) gatewayHostBeforeEnv := cfg.Gateway.Host if err = env.Parse(cfg); err != nil { return nil, err } applySkillsRegistryEnvCompat(cfg) if err = InitChannelList(cfg.Channels); err != nil { return nil, err } cfg.Gateway.Host, err = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) if err != nil { return nil, fmt.Errorf("invalid gateway host: %w", err) } // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = expandMultiKeyModels(cfg.ModelList) // Validate model_list for uniqueness and required fields if err = cfg.ValidateModelList(); err != nil { return nil, err } // Ensure Workspace has a default if not set if cfg.Agents.Defaults.Workspace == "" { homePath := GetHome() cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName) } return cfg, nil } func applySkillsRegistryEnvCompat(cfg *Config) { if cfg == nil { return } registryCfg, foundClawHub := cfg.Tools.Skills.Registries.Get("clawhub") if !foundClawHub { registryCfg = SkillRegistryConfig{ Name: "clawhub", Param: map[string]any{}, } } if registryCfg.Param == nil { registryCfg.Param = map[string]any{} } if raw, envSet := os.LookupEnv(envSkillsClawHubEnabled); envSet { if value, err := strconv.ParseBool(strings.TrimSpace(raw)); err == nil { registryCfg.Enabled = value } } if value, envSet := os.LookupEnv(envSkillsClawHubBaseURL); envSet { registryCfg.BaseURL = value } if value, envSet := os.LookupEnv(envSkillsClawHubAuthToken); envSet { registryCfg.AuthToken = *NewSecureString(value) } if value, envSet := os.LookupEnv(envSkillsClawHubSearchPath); envSet { registryCfg.Param["search_path"] = value } if value, envSet := os.LookupEnv(envSkillsClawHubSkillsPath); envSet { registryCfg.Param["skills_path"] = value } if value, envSet := os.LookupEnv(envSkillsClawHubDownloadPath); envSet { registryCfg.Param["download_path"] = value } if raw, envSet := os.LookupEnv(envSkillsClawHubTimeout); envSet { if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { registryCfg.Param["timeout"] = value } } if raw, envSet := os.LookupEnv(envSkillsClawHubMaxZipSize); envSet { if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { registryCfg.Param["max_zip_size"] = value } } if raw, envSet := os.LookupEnv(envSkillsClawHubMaxResponseSize); envSet { if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { registryCfg.Param["max_response_size"] = value } } cfg.Tools.Skills.Registries.Set("clawhub", registryCfg) githubCfg, foundGitHub := cfg.Tools.Skills.Registries.Get("github") if !foundGitHub { githubCfg = SkillRegistryConfig{ Name: "github", Param: map[string]any{}, } } if githubCfg.Param == nil { githubCfg.Param = map[string]any{} } if raw, envSet := os.LookupEnv(envSkillsGitHubEnabled); envSet { if value, err := strconv.ParseBool(strings.TrimSpace(raw)); err == nil { githubCfg.Enabled = value } } if value, envSet := os.LookupEnv(envSkillsGitHubBaseURL); envSet { githubCfg.BaseURL = value } if value, envSet := os.LookupEnv(envSkillsGitHubAuthToken); envSet { githubCfg.AuthToken = *NewSecureString(value) } if value, envSet := os.LookupEnv(envSkillsGitHubProxy); envSet { githubCfg.Param["proxy"] = value } cfg.Tools.Skills.Registries.Set("github", githubCfg) } func makeBackup(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return nil } dateSuffix := time.Now().Format(".20060102.bak") // Backup config file bakPath := path + dateSuffix if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil { logger.ErrorF("failed to create config backup", map[string]any{"error": err}) return fmt.Errorf("failed to create config backup: %w", err) } // Backup security config file secPath := securityPath(path) if _, err := os.Stat(secPath); err == nil { secBakPath := secPath + dateSuffix if secErr := fileutil.CopyFile(secPath, secBakPath, 0o600); secErr != nil { logger.ErrorF("failed to create security backup", map[string]any{"error": secErr}) return fmt.Errorf("failed to create security backup: %w", secErr) } } return nil } func toNameIndex(list []*ModelConfig) []string { nameList := make([]string, 0, len(list)) countMap := make(map[string]int) for _, model := range list { name := model.ModelName index := countMap[name] nameList = append(nameList, fmt.Sprintf("%s:%d", name, index)) countMap[name]++ } return nameList } func SaveConfig(path string, cfg *Config) error { if cfg.Version < CurrentVersion { cfg.Version = CurrentVersion } // Filter out virtual models before serializing to config file nonVirtualModels := make([]*ModelConfig, 0, len(cfg.ModelList)) for _, m := range cfg.ModelList { if !m.isVirtual { nonVirtualModels = append(nonVirtualModels, m) } } // Temporarily replace ModelList with filtered version for serialization originalModelList := cfg.ModelList defer func() { // Restore original ModelList after serialization cfg.ModelList = originalModelList }() cfg.ModelList = nonVirtualModels if err := saveSecurityConfig(securityPath(path), cfg); err != nil { logger.ErrorCF("config", "cannot save .security.yml", map[string]any{"error": err}) return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return fileutil.WriteFileAtomic(path, data, 0o600) } func (c *Config) WorkspacePath() string { return expandHome(c.Agents.Defaults.Workspace) } func expandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path } // GetModelConfig returns the ModelConfig for the given model name. // If multiple configs exist with the same model_name, it uses round-robin // selection for load balancing. Returns an error if the model is not found. func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { matches := c.findMatches(modelName) if len(matches) == 0 { return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } if len(matches) == 1 { return matches[0], nil } // Multiple configs - use round-robin for load balancing idx := (rrCounter.Add(1) - 1) % uint64(len(matches)) return matches[idx], nil } // findMatches finds all ModelConfig entries with the given model_name. func (c *Config) findMatches(modelName string) []*ModelConfig { var matches []*ModelConfig for i := range c.ModelList { if c.ModelList[i].ModelName == modelName { matches = append(matches, c.ModelList[i]) } } return matches } // ValidateModelList validates all ModelConfig entries in the model_list. // It checks that each model config is valid. // Note: Multiple entries with the same model_name are allowed for load balancing. func (c *Config) ValidateModelList() error { for i := range c.ModelList { if err := c.ModelList[i].Validate(); err != nil { return fmt.Errorf("model_list[%d]: %w", i, err) } } return nil } func (c *Config) SecurityCopyFrom(path string) error { return loadSecurityConfig(c, securityPath(path)) } func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { var expanded []*ModelConfig for _, m := range models { keys := m.APIKeys.Values() // Single key or no keys: keep as-is if len(keys) <= 1 { expanded = append(expanded, m) continue } // Multiple keys: expand originalName := m.ModelName // Create entries for additional keys (key_1, key_2, ...) var fallbackNames []string for i := 1; i < len(keys); i++ { suffix := fmt.Sprintf("__key_%d", i) expandedName := originalName + suffix // Create a copy for the additional key additionalEntry := &ModelConfig{ ModelName: expandedName, Provider: m.Provider, Model: m.Model, APIBase: m.APIBase, APIKeys: SimpleSecureStrings(keys[i]), Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, Workspace: m.Workspace, RPM: m.RPM, MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, CustomHeaders: m.CustomHeaders, UserAgent: m.UserAgent, isVirtual: true, } expanded = append(expanded, additionalEntry) fallbackNames = append(fallbackNames, expandedName) } // Create the primary entry with first key and fallbacks primaryEntry := &ModelConfig{ ModelName: originalName, Provider: m.Provider, Model: m.Model, APIBase: m.APIBase, Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, Workspace: m.Workspace, RPM: m.RPM, MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, CustomHeaders: m.CustomHeaders, UserAgent: m.UserAgent, APIKeys: SimpleSecureStrings(keys[0]), } // Prepend new fallbacks to existing ones if len(fallbackNames) > 0 { primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...) } else if len(m.Fallbacks) > 0 { primaryEntry.Fallbacks = m.Fallbacks } expanded = append(expanded, primaryEntry) } return expanded } func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": return t.Web.Enabled case "cron": return t.Cron.Enabled case "exec": return t.Exec.Enabled case "skills": return t.Skills.Enabled case "media_cleanup": return t.MediaCleanup.Enabled case "append_file": return t.AppendFile.Enabled case "edit_file": return t.EditFile.Enabled case "find_skills": return t.FindSkills.Enabled case "i2c": return t.I2C.Enabled case "install_skill": return t.InstallSkill.Enabled case "list_dir": return t.ListDir.Enabled case "message": return t.Message.Enabled case "read_file": return t.ReadFile.Enabled case "spawn": return t.Spawn.Enabled case "spawn_status": return t.SpawnStatus.Enabled case "spi": return t.SPI.Enabled case "subagent": return t.Subagent.Enabled case "web_fetch": return t.WebFetch.Enabled case "send_file": return t.SendFile.Enabled case "send_tts": return t.SendTTS.Enabled case "write_file": return t.WriteFile.Enabled case "mcp": return t.MCP.Enabled default: return true } }