Skip to main content

localgpt_core/config/
mod.rs

1mod migrate;
2mod schema;
3pub mod watcher;
4
5pub use migrate::check_openclaw_detected;
6pub use schema::*;
7pub use watcher::{ConfigWatcher, spawn_sighup_handler};
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13
14use crate::env::LOCALGPT_WORKSPACE;
15use crate::paths::Paths;
16use crate::paths::{DEFAULT_DATA_DIR_STR, DEFAULT_STATE_DIR_STR};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct Config {
20    /// Resolved XDG-compliant paths (not serialized)
21    #[serde(skip)]
22    pub paths: Paths,
23
24    #[serde(default)]
25    pub agent: AgentConfig,
26
27    #[serde(default)]
28    pub providers: ProvidersConfig,
29
30    #[serde(default)]
31    pub heartbeat: HeartbeatConfig,
32
33    #[serde(default)]
34    pub memory: MemoryConfig,
35
36    #[serde(default)]
37    pub server: ServerConfig,
38
39    #[serde(default)]
40    pub logging: LoggingConfig,
41
42    #[serde(default)]
43    pub tools: ToolsConfig,
44
45    #[serde(default)]
46    pub security: SecurityConfig,
47
48    #[serde(default)]
49    pub sandbox: SandboxConfig,
50
51    #[serde(default)]
52    pub telegram: Option<TelegramConfig>,
53
54    #[serde(default)]
55    pub cron: CronConfig,
56
57    #[serde(default)]
58    pub hooks: HooksConfig,
59
60    #[serde(default)]
61    pub mcp: McpConfig,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct AgentConfig {
66    #[serde(default = "default_model")]
67    pub default_model: String,
68
69    #[serde(default = "default_context_window")]
70    pub context_window: usize,
71
72    #[serde(default = "default_reserve_tokens")]
73    pub reserve_tokens: usize,
74
75    /// Maximum tokens for LLM response
76    #[serde(default = "default_max_tokens")]
77    pub max_tokens: usize,
78
79    /// Maximum depth for spawn_agent tool (default: 1, no nested spawning)
80    /// - 0: spawn_agent tool disabled
81    /// - 1: single level only (subagents cannot spawn more agents)
82    /// - 2+: limited nesting allowed (not recommended)
83    #[serde(default)]
84    pub max_spawn_depth: Option<u8>,
85
86    /// Model to use for spawned subagents (default: same as default_model or claude-cli/sonnet)
87    #[serde(default)]
88    pub subagent_model: Option<String>,
89
90    /// Fallback models to try if primary provider fails with retryable errors
91    /// (rate limits, server errors, timeouts). Providers are tried in order.
92    /// Example: ["openai/gpt-4o", "ollama/llama3"]
93    #[serde(default)]
94    pub fallback_models: Vec<String>,
95
96    /// Maximum times the same tool can be called with identical arguments before
97    /// loop detection triggers. Default: 3. Set to 0 to disable loop detection.
98    #[serde(default = "default_max_tool_repeats")]
99    pub max_tool_repeats: usize,
100
101    /// Maximum age for session files before pruning (in seconds).
102    /// 0 = keep forever. Default: 30 days.
103    #[serde(default = "default_session_max_age")]
104    pub session_max_age: u64,
105
106    /// Maximum number of sessions to keep per agent.
107    /// 0 = unlimited. Default: 500.
108    #[serde(default = "default_session_max_count")]
109    pub session_max_count: usize,
110}
111
112fn default_max_tool_repeats() -> usize {
113    3
114}
115
116fn default_session_max_age() -> u64 {
117    30 * 24 * 60 * 60 // 30 days in seconds
118}
119
120fn default_session_max_count() -> usize {
121    500
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ToolsConfig {
126    /// Bash command timeout in milliseconds
127    #[serde(default = "default_bash_timeout")]
128    pub bash_timeout_ms: u64,
129
130    /// Maximum bytes to return from web_fetch
131    #[serde(default = "default_web_fetch_max_bytes")]
132    pub web_fetch_max_bytes: usize,
133
134    /// Tools that require user approval before execution
135    /// e.g., ["bash", "write_file", "edit_file"]
136    #[serde(default)]
137    pub require_approval: Vec<String>,
138
139    /// Maximum characters for tool output (0 = unlimited)
140    #[serde(default = "default_tool_output_max_chars")]
141    pub tool_output_max_chars: usize,
142
143    /// Log warnings for suspicious injection patterns detected in tool outputs
144    #[serde(default = "default_true")]
145    pub log_injection_warnings: bool,
146
147    /// Wrap tool outputs and memory content with XML-style delimiters
148    #[serde(default = "default_true")]
149    pub use_content_delimiters: bool,
150
151    /// Web search configuration (disabled by default)
152    #[serde(default)]
153    pub web_search: Option<WebSearchConfig>,
154
155    /// Per-tool input filters (deny/allow patterns and substrings).
156    /// Keys are tool names (e.g. "bash", "web_fetch").
157    #[serde(default)]
158    pub filters: std::collections::HashMap<String, crate::agent::tool_filters::ToolFilter>,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162#[serde(rename_all = "lowercase")]
163pub enum SearchProviderType {
164    Searxng,
165    Brave,
166    Tavily,
167    Perplexity,
168    #[default]
169    None,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct WebSearchConfig {
174    #[serde(default)]
175    pub provider: SearchProviderType,
176
177    #[serde(default = "default_true")]
178    pub cache_enabled: bool,
179
180    /// Cache TTL in seconds (default: 900 = 15 minutes)
181    #[serde(default = "default_cache_ttl")]
182    pub cache_ttl: u64,
183
184    /// Maximum results per query (1-10, default: 5)
185    #[serde(default = "default_max_results")]
186    pub max_results: u8,
187
188    /// Prefer provider-native search when supported (e.g., Anthropic web_search tool)
189    #[serde(default = "default_true")]
190    pub prefer_native: bool,
191
192    #[serde(default)]
193    pub searxng: Option<SearxngConfig>,
194
195    #[serde(default)]
196    pub brave: Option<BraveConfig>,
197
198    #[serde(default)]
199    pub tavily: Option<TavilyConfig>,
200
201    #[serde(default)]
202    pub perplexity: Option<PerplexityConfig>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SearxngConfig {
207    pub base_url: String,
208
209    #[serde(default)]
210    pub categories: String,
211
212    #[serde(default)]
213    pub language: String,
214
215    #[serde(default)]
216    pub time_range: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct BraveConfig {
221    pub api_key: String,
222
223    #[serde(default)]
224    pub country: String,
225
226    #[serde(default)]
227    pub freshness: String,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct TavilyConfig {
232    pub api_key: String,
233
234    #[serde(default = "default_basic")]
235    pub search_depth: String,
236
237    #[serde(default = "default_true")]
238    pub include_answer: bool,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct PerplexityConfig {
243    pub api_key: String,
244
245    #[serde(default = "default_sonar")]
246    pub model: String,
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct SecurityConfig {
251    /// Abort agent startup on tamper or suspicious content (default: false).
252    ///
253    /// When true, `TamperDetected` and `SuspiciousContent` are fatal errors
254    /// that prevent the agent from starting. When false (default), the agent
255    /// warns and falls back to hardcoded-only security.
256    #[serde(default)]
257    pub strict_policy: bool,
258
259    /// Skip loading and injecting the `LocalGPT.md` workspace security policy
260    /// (default: false).
261    ///
262    /// When true, the user's signed `LocalGPT.md` content is not loaded or
263    /// injected into the context window. The hardcoded security suffix still
264    /// applies unless [`disable_suffix`] is also set.
265    #[serde(default)]
266    pub disable_policy: bool,
267
268    /// Skip injecting the hardcoded security suffix (default: false).
269    ///
270    /// The suffix is a compiled-in reminder that tells the model to treat
271    /// tool outputs and retrieved content as data, not instructions. When
272    /// disabled, the user policy (if any) still applies.
273    ///
274    /// **Warning:** Setting both `disable_policy` and `disable_suffix` to
275    /// `true` removes all end-of-context security reinforcement. The system
276    /// prompt safety section still exists, but may lose effectiveness in
277    /// long sessions due to attention decay ("lost in the middle" effect).
278    #[serde(default)]
279    pub disable_suffix: bool,
280
281    /// Restrict file tools to these directories (empty = unrestricted).
282    /// Paths are canonicalized at startup. Symlinks are resolved before checking.
283    #[serde(default)]
284    pub allowed_directories: Vec<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct SandboxConfig {
289    /// Enable shell command sandboxing (default: true)
290    #[serde(default = "default_true")]
291    pub enabled: bool,
292
293    /// Sandbox level: "auto" | "full" | "standard" | "minimal" | "none"
294    #[serde(default = "default_sandbox_level")]
295    pub level: String,
296
297    /// Command timeout in seconds (default: 120)
298    #[serde(default = "default_sandbox_timeout")]
299    pub timeout_secs: u64,
300
301    /// Maximum output bytes (default: 1MB)
302    #[serde(default = "default_sandbox_max_output")]
303    pub max_output_bytes: u64,
304
305    /// Maximum file size in bytes (RLIMIT_FSIZE, default: 50MB)
306    #[serde(default = "default_sandbox_max_file_size")]
307    pub max_file_size_bytes: u64,
308
309    /// Maximum child processes (RLIMIT_NPROC, default: 64)
310    #[serde(default = "default_sandbox_max_processes")]
311    pub max_processes: u32,
312
313    /// Additional path allowances
314    #[serde(default)]
315    pub allow_paths: AllowPathsConfig,
316
317    /// Network policy
318    #[serde(default)]
319    pub network: SandboxNetworkConfig,
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct AllowPathsConfig {
324    /// Additional read-only paths
325    #[serde(default)]
326    pub read: Vec<String>,
327
328    /// Additional writable paths
329    #[serde(default)]
330    pub write: Vec<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct SandboxNetworkConfig {
335    /// Network policy: "deny" | "proxy"
336    #[serde(default = "default_sandbox_network_policy")]
337    pub policy: String,
338}
339
340impl Default for SandboxConfig {
341    fn default() -> Self {
342        Self {
343            enabled: default_true(),
344            level: default_sandbox_level(),
345            timeout_secs: default_sandbox_timeout(),
346            max_output_bytes: default_sandbox_max_output(),
347            max_file_size_bytes: default_sandbox_max_file_size(),
348            max_processes: default_sandbox_max_processes(),
349            allow_paths: AllowPathsConfig::default(),
350            network: SandboxNetworkConfig::default(),
351        }
352    }
353}
354
355impl Default for SandboxNetworkConfig {
356    fn default() -> Self {
357        Self {
358            policy: default_sandbox_network_policy(),
359        }
360    }
361}
362
363#[derive(Debug, Clone, Default, Serialize, Deserialize)]
364pub struct ProvidersConfig {
365    #[serde(default)]
366    pub openai: Option<OpenAIConfig>,
367
368    #[serde(default)]
369    pub xai: Option<XaiConfig>,
370
371    #[serde(default)]
372    pub anthropic: Option<AnthropicConfig>,
373
374    #[serde(default)]
375    pub ollama: Option<OllamaConfig>,
376
377    #[serde(default)]
378    pub claude_cli: Option<ClaudeCliConfig>,
379
380    #[serde(default)]
381    pub gemini_cli: Option<GeminiCliConfig>,
382
383    #[serde(default)]
384    pub codex_cli: Option<CodexCliConfig>,
385
386    #[serde(default)]
387    pub glm: Option<GlmConfig>,
388
389    #[serde(default)]
390    pub gemini: Option<GeminiConfig>,
391
392    /// Generic OpenAI-compatible provider for any endpoint speaking the OpenAI Chat Completions API
393    /// (OpenRouter, DeepSeek, Groq, vLLM, LiteLLM, Together AI, Fireworks, etc.)
394    #[serde(default)]
395    pub openai_compatible: Option<OpenAICompatibleConfig>,
396
397    /// Google Vertex AI (service account key authentication)
398    #[serde(default)]
399    pub vertex: Option<VertexAiConfig>,
400}
401
402/// Configuration for OpenAI-compatible providers (OpenRouter, DeepSeek, Groq, etc.)
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct OpenAICompatibleConfig {
405    /// Base URL for the API endpoint (e.g., "https://openrouter.ai/api/v1")
406    pub base_url: String,
407
408    /// API key for authentication (supports ${ENV_VAR} expansion)
409    pub api_key: String,
410
411    /// Extra headers to include in every request (e.g., OpenRouter attribution)
412    #[serde(default)]
413    pub extra_headers: std::collections::HashMap<String, String>,
414}
415
416/// Configuration for Google Vertex AI (service account key authentication)
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct VertexAiConfig {
419    /// Path to service account JSON key file (supports ~ and ${ENV_VAR} expansion)
420    pub service_account_key: String,
421
422    /// Google Cloud project ID (supports ${ENV_VAR} expansion)
423    pub project_id: String,
424
425    /// Regional endpoint location (default: "us-central1")
426    #[serde(default = "default_vertex_location")]
427    pub location: String,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct OpenAIConfig {
432    pub api_key: String,
433
434    #[serde(default = "default_openai_base_url")]
435    pub base_url: String,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct XaiConfig {
440    pub api_key: String,
441
442    #[serde(default = "default_xai_base_url")]
443    pub base_url: String,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct AnthropicConfig {
448    pub api_key: String,
449
450    #[serde(default = "default_anthropic_base_url")]
451    pub base_url: String,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct OllamaConfig {
456    #[serde(default = "default_ollama_endpoint")]
457    pub endpoint: String,
458
459    #[serde(default = "default_ollama_model")]
460    pub model: String,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct ClaudeCliConfig {
465    #[serde(default = "default_claude_cli_command")]
466    pub command: String,
467
468    #[serde(default = "default_claude_cli_model")]
469    pub model: String,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct GeminiCliConfig {
474    #[serde(default = "default_gemini_cli_command")]
475    pub command: String,
476
477    #[serde(default = "default_gemini_cli_model")]
478    pub model: String,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct CodexCliConfig {
483    #[serde(default = "default_codex_cli_command")]
484    pub command: String,
485
486    #[serde(default = "default_codex_cli_model")]
487    pub model: String,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct GlmConfig {
492    pub api_key: String,
493
494    #[serde(default = "default_glm_base_url")]
495    pub base_url: String,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct GeminiConfig {
500    pub api_key: String,
501
502    #[serde(default = "default_gemini_base_url")]
503    pub base_url: String,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct HeartbeatConfig {
508    #[serde(default = "default_true")]
509    pub enabled: bool,
510
511    #[serde(default = "default_interval")]
512    pub interval: String,
513
514    #[serde(default = "default_overdue_delay")]
515    pub overdue_delay: String,
516
517    /// Maximum duration for a single heartbeat run.
518    /// If not set, defaults to half the heartbeat interval.
519    /// Accepts the same format as `interval` (e.g., "15m", "1h").
520    #[serde(default)]
521    pub timeout: Option<String>,
522
523    #[serde(default)]
524    pub active_hours: Option<ActiveHours>,
525
526    #[serde(default)]
527    pub timezone: Option<String>,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct ActiveHours {
532    pub start: String,
533    pub end: String,
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct MemoryConfig {
538    #[serde(default = "default_workspace")]
539    pub workspace: String,
540
541    /// Embedding provider: "local" (fastembed, default), "openai", or "none"
542    #[serde(default = "default_embedding_provider")]
543    pub embedding_provider: String,
544
545    #[serde(default = "default_embedding_model")]
546    pub embedding_model: String,
547
548    /// Cache directory for local embedding models (optional)
549    /// Default: ~/.cache/localgpt/models
550    /// Can also be set via FASTEMBED_CACHE_DIR environment variable
551    #[serde(default = "default_embedding_cache_dir")]
552    pub embedding_cache_dir: String,
553
554    #[serde(default = "default_chunk_size")]
555    pub chunk_size: usize,
556
557    #[serde(default = "default_chunk_overlap")]
558    pub chunk_overlap: usize,
559
560    /// Additional paths to index (relative to workspace or absolute)
561    /// Each path uses a glob pattern for file matching
562    #[serde(default = "default_index_paths")]
563    pub paths: Vec<MemoryIndexPath>,
564
565    /// Maximum messages to save in session memory files (0 = unlimited)
566    /// Similar to OpenClaw's hooks.session-memory.messages (default: 15)
567    #[serde(default = "default_session_max_messages")]
568    pub session_max_messages: usize,
569
570    /// Maximum characters per message in session memory (0 = unlimited)
571    /// Set to 0 to preserve full message content like OpenClaw
572    #[serde(default)]
573    pub session_max_chars: usize,
574
575    /// Temporal decay factor for search scoring.
576    /// Older memories get lower scores using: score * exp(-lambda * age_days)
577    /// Default: 0.0 (disabled)
578    /// 0.1 = ~50% penalty for 7-day old memory
579    /// 0.05 = ~50% penalty for 14-day old memory
580    #[serde(default)]
581    pub temporal_decay_lambda: f64,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct MemoryIndexPath {
586    pub path: String,
587    #[serde(default = "default_pattern")]
588    pub pattern: String,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ServerConfig {
593    #[serde(default = "default_true")]
594    pub enabled: bool,
595
596    #[serde(default = "default_port")]
597    pub port: u16,
598
599    #[serde(default = "default_bind")]
600    pub bind: String,
601
602    /// Bearer token for API authentication.
603    /// If set, all /api/* routes require Authorization: Bearer <token>.
604    /// Supports ${ENV_VAR} expansion.
605    /// If unset, auth is disabled (backward compatible for local-only use).
606    #[serde(default)]
607    pub auth_token: Option<String>,
608
609    #[serde(default)]
610    pub rate_limit: RateLimitConfig,
611
612    /// Allowed CORS origins. If empty, defaults to local access.
613    #[serde(default)]
614    pub cors_origins: Vec<String>,
615
616    /// Maximum request body size in bytes.
617    /// Requests larger than this return 413 Payload Too Large.
618    /// Default: 10MB
619    #[serde(default = "default_max_request_body")]
620    pub max_request_body: usize,
621}
622
623fn default_max_request_body() -> usize {
624    10 * 1024 * 1024 // 10MB
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct RateLimitConfig {
629    #[serde(default = "default_true")]
630    pub enabled: bool,
631
632    /// Maximum requests per minute per IP
633    #[serde(default = "default_requests_per_minute")]
634    pub requests_per_minute: u32,
635
636    /// Burst allowance (extra requests above steady rate)
637    #[serde(default = "default_burst")]
638    pub burst: u32,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct LoggingConfig {
643    #[serde(default = "default_log_level")]
644    pub level: String,
645
646    #[serde(default = "default_log_file")]
647    pub file: String,
648
649    /// Days to keep log files (0 = keep forever, no auto-deletion)
650    #[serde(default)]
651    pub retention_days: u32,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct TelegramConfig {
656    #[serde(default)]
657    pub enabled: bool,
658
659    pub api_token: String,
660}
661
662#[derive(Debug, Clone, Default, Serialize, Deserialize)]
663pub struct CronConfig {
664    #[serde(default)]
665    pub jobs: Vec<CronJob>,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize)]
669pub struct CronJob {
670    pub name: String,
671
672    /// Cron expression ("0 */6 * * *") or interval ("every 30m", "every 2h", "every 1d")
673    pub schedule: String,
674
675    /// Prompt to send to a fresh agent session
676    pub prompt: String,
677
678    /// Optional Telegram channel/chat to route output to
679    #[serde(default)]
680    pub channel: Option<String>,
681
682    #[serde(default = "default_true")]
683    pub enabled: bool,
684
685    /// Timeout for the job (e.g., "5m", "1h"). Default: 10m
686    #[serde(default = "default_cron_timeout")]
687    pub timeout: String,
688}
689
690#[derive(Debug, Clone, Default, Serialize, Deserialize)]
691pub struct HooksConfig {
692    #[serde(default)]
693    pub hooks: Vec<HookConfig>,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct HookConfig {
698    /// Unique name for this hook
699    pub name: String,
700
701    /// Event type: onMessage, onToolCall, onSessionStart, onSessionEnd, beforeToolCall, afterToolCall
702    pub event: String,
703
704    /// Command to execute (shell command)
705    pub command: String,
706
707    /// Optional filter (e.g., "tool:bash" for beforeToolCall)
708    #[serde(default)]
709    pub filter: Option<String>,
710
711    /// Whether this hook is enabled
712    #[serde(default = "default_true")]
713    pub enabled: bool,
714}
715
716#[derive(Debug, Clone, Default, Serialize, Deserialize)]
717pub struct McpConfig {
718    #[serde(default)]
719    pub servers: Vec<McpServerConfig>,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct McpServerConfig {
724    /// Unique name for this MCP server (used in tool namespacing)
725    pub name: String,
726
727    /// Transport type: "stdio" or "sse"
728    #[serde(default = "default_mcp_transport")]
729    pub transport: String,
730
731    /// Command to run for stdio transport
732    pub command: Option<String>,
733
734    /// Arguments for the stdio command
735    #[serde(default)]
736    pub args: Vec<String>,
737
738    /// Environment variables for the subprocess
739    #[serde(default)]
740    pub env: std::collections::HashMap<String, String>,
741
742    /// URL for SSE transport
743    pub url: Option<String>,
744}
745
746fn default_mcp_transport() -> String {
747    "stdio".to_string()
748}
749
750// Default value functions
751fn default_model() -> String {
752    // Default to Claude CLI (uses existing Claude Code auth, no API key needed)
753    "claude-cli/opus".to_string()
754}
755fn default_context_window() -> usize {
756    128000
757}
758fn default_reserve_tokens() -> usize {
759    8000
760}
761fn default_max_tokens() -> usize {
762    4096
763}
764fn default_bash_timeout() -> u64 {
765    30000 // 30 seconds
766}
767fn default_web_fetch_max_bytes() -> usize {
768    10000
769}
770fn default_tool_output_max_chars() -> usize {
771    50000 // 50k characters max for tool output by default
772}
773fn default_openai_base_url() -> String {
774    "https://api.openai.com/v1".to_string()
775}
776fn default_xai_base_url() -> String {
777    "https://api.x.ai/v1".to_string()
778}
779fn default_anthropic_base_url() -> String {
780    "https://api.anthropic.com".to_string()
781}
782fn default_ollama_endpoint() -> String {
783    "http://localhost:11434".to_string()
784}
785fn default_ollama_model() -> String {
786    "llama3".to_string()
787}
788fn default_claude_cli_command() -> String {
789    "claude".to_string()
790}
791fn default_claude_cli_model() -> String {
792    "opus".to_string()
793}
794fn default_gemini_cli_command() -> String {
795    "gemini".to_string()
796}
797fn default_gemini_cli_model() -> String {
798    "gemini-3.1-pro-preview".to_string()
799}
800fn default_codex_cli_command() -> String {
801    "codex".to_string()
802}
803fn default_codex_cli_model() -> String {
804    "o4-mini".to_string()
805}
806fn default_glm_base_url() -> String {
807    "https://api.z.ai/api/coding/paas/v4".to_string()
808}
809fn default_gemini_base_url() -> String {
810    "https://generativelanguage.googleapis.com".to_string()
811}
812fn default_vertex_location() -> String {
813    "us-central1".to_string()
814}
815fn default_true() -> bool {
816    true
817}
818fn default_interval() -> String {
819    "30m".to_string()
820}
821
822fn default_overdue_delay() -> String {
823    "1m".to_string()
824}
825fn default_workspace() -> String {
826    format!("{}/workspace", DEFAULT_DATA_DIR_STR)
827}
828fn default_embedding_provider() -> String {
829    "local".to_string() // Local embeddings via fastembed (no API key needed)
830}
831fn default_embedding_model() -> String {
832    "all-MiniLM-L6-v2".to_string() // Local model via fastembed (no API key needed)
833}
834fn default_embedding_cache_dir() -> String {
835    crate::paths::DEFAULT_CACHE_DIR_STR.to_string() + "/embeddings"
836}
837fn default_chunk_size() -> usize {
838    400
839}
840fn default_chunk_overlap() -> usize {
841    80
842}
843fn default_index_paths() -> Vec<MemoryIndexPath> {
844    vec![MemoryIndexPath {
845        path: "knowledge".to_string(),
846        pattern: "**/*.md".to_string(),
847    }]
848}
849fn default_pattern() -> String {
850    "**/*.md".to_string()
851}
852fn default_session_max_messages() -> usize {
853    15 // Match OpenClaw's default
854}
855fn default_port() -> u16 {
856    31327
857}
858fn default_cron_timeout() -> String {
859    "10m".to_string()
860}
861fn default_requests_per_minute() -> u32 {
862    60
863}
864fn default_burst() -> u32 {
865    10
866}
867fn default_bind() -> String {
868    "127.0.0.1".to_string()
869}
870fn default_log_level() -> String {
871    "info".to_string()
872}
873fn default_log_file() -> String {
874    format!("{}/logs/agent.log", DEFAULT_STATE_DIR_STR)
875}
876fn default_sandbox_level() -> String {
877    "auto".to_string()
878}
879fn default_sandbox_timeout() -> u64 {
880    120
881}
882fn default_sandbox_max_output() -> u64 {
883    1_048_576 // 1MB
884}
885fn default_sandbox_max_file_size() -> u64 {
886    52_428_800 // 50MB
887}
888fn default_sandbox_max_processes() -> u32 {
889    64
890}
891fn default_sandbox_network_policy() -> String {
892    "deny".to_string()
893}
894fn default_cache_ttl() -> u64 {
895    900 // 15 minutes
896}
897fn default_max_results() -> u8 {
898    5
899}
900fn default_basic() -> String {
901    "basic".to_string()
902}
903fn default_sonar() -> String {
904    "sonar".to_string()
905}
906
907impl Default for AgentConfig {
908    fn default() -> Self {
909        Self {
910            default_model: default_model(),
911            context_window: default_context_window(),
912            reserve_tokens: default_reserve_tokens(),
913            max_tokens: default_max_tokens(),
914            max_spawn_depth: Some(1),    // Single-level spawning by default
915            subagent_model: None,        // Use default_model if not specified
916            fallback_models: Vec::new(), // No fallbacks by default
917            max_tool_repeats: default_max_tool_repeats(), // Loop detection threshold
918            session_max_age: default_session_max_age(), // 30 days
919            session_max_count: default_session_max_count(), // 500 sessions
920        }
921    }
922}
923
924impl Default for ToolsConfig {
925    fn default() -> Self {
926        Self {
927            bash_timeout_ms: default_bash_timeout(),
928            web_fetch_max_bytes: default_web_fetch_max_bytes(),
929            require_approval: Vec::new(),
930            tool_output_max_chars: default_tool_output_max_chars(),
931            log_injection_warnings: default_true(),
932            use_content_delimiters: default_true(),
933            web_search: None,
934            filters: std::collections::HashMap::new(),
935        }
936    }
937}
938
939impl Default for HeartbeatConfig {
940    fn default() -> Self {
941        Self {
942            enabled: default_true(),
943            interval: default_interval(),
944            overdue_delay: default_overdue_delay(),
945            timeout: None,
946            active_hours: None,
947            timezone: None,
948        }
949    }
950}
951
952impl Default for MemoryConfig {
953    fn default() -> Self {
954        Self {
955            workspace: default_workspace(),
956            embedding_provider: default_embedding_provider(),
957            embedding_model: default_embedding_model(),
958            embedding_cache_dir: default_embedding_cache_dir(),
959            chunk_size: default_chunk_size(),
960            chunk_overlap: default_chunk_overlap(),
961            paths: default_index_paths(),
962            session_max_messages: default_session_max_messages(),
963            session_max_chars: 0, // 0 = unlimited (preserve full content like OpenClaw)
964            temporal_decay_lambda: 0.0, // Disabled by default
965        }
966    }
967}
968
969impl Default for ServerConfig {
970    fn default() -> Self {
971        Self {
972            enabled: default_true(),
973            port: default_port(),
974            bind: default_bind(),
975            auth_token: None,
976            rate_limit: RateLimitConfig::default(),
977            cors_origins: Vec::new(),
978            max_request_body: default_max_request_body(),
979        }
980    }
981}
982
983impl Default for RateLimitConfig {
984    fn default() -> Self {
985        Self {
986            enabled: default_true(),
987            requests_per_minute: default_requests_per_minute(),
988            burst: default_burst(),
989        }
990    }
991}
992
993impl Default for LoggingConfig {
994    fn default() -> Self {
995        Self {
996            level: default_log_level(),
997            file: default_log_file(),
998            retention_days: 0, // 0 = keep forever
999        }
1000    }
1001}
1002
1003impl Config {
1004    pub fn load() -> Result<Self> {
1005        let paths = Paths::resolve()?;
1006        paths.ensure_dirs()?;
1007        let path = paths.config_file();
1008
1009        if !path.exists() {
1010            // Create default config file on first run
1011            let config = Config {
1012                paths,
1013                ..Config::default()
1014            };
1015            config.save_with_template()?;
1016            return Ok(config);
1017        }
1018
1019        let content = fs::read_to_string(&path)?;
1020        let mut config: Config = toml::from_str(&content)?;
1021        config.paths = paths;
1022
1023        // Expand environment variables in API keys
1024        config.expand_env_vars();
1025
1026        // Apply deprecated memory.workspace override if set and LOCALGPT_WORKSPACE not set
1027        if config.memory.workspace != default_workspace()
1028            && std::env::var(LOCALGPT_WORKSPACE).is_err()
1029        {
1030            let expanded = shellexpand::tilde(&config.memory.workspace);
1031            let ws_path = PathBuf::from(expanded.to_string());
1032            if ws_path.is_absolute() {
1033                config.paths.workspace = ws_path;
1034            }
1035        }
1036
1037        Ok(config)
1038    }
1039
1040    /// Load (or create default) config with all directories rooted under `data_dir`.
1041    ///
1042    /// Mobile apps use this instead of `load()` since they don't have XDG dirs.
1043    pub fn load_from_dir(data_dir: &str) -> Result<Self> {
1044        let paths = Paths::from_root(data_dir);
1045        paths.ensure_dirs()?;
1046        let path = paths.config_file();
1047
1048        if !path.exists() {
1049            let config = Config {
1050                paths,
1051                ..Config::default()
1052            };
1053            config.save()?;
1054            return Ok(config);
1055        }
1056
1057        let content = fs::read_to_string(&path)?;
1058        let mut config: Config = toml::from_str(&content)?;
1059        config.paths = paths;
1060        config.expand_env_vars();
1061        Ok(config)
1062    }
1063
1064    pub fn save(&self) -> Result<()> {
1065        let path = self.paths.config_file();
1066
1067        // Create parent directories
1068        if let Some(parent) = path.parent() {
1069            fs::create_dir_all(parent)?;
1070        }
1071
1072        let content = toml::to_string_pretty(self)?;
1073        fs::write(&path, content)?;
1074
1075        Ok(())
1076    }
1077
1078    /// Save config with a helpful template (for first-time setup)
1079    pub fn save_with_template(&self) -> Result<()> {
1080        let path = self.paths.config_file();
1081
1082        // Create parent directories
1083        if let Some(parent) = path.parent() {
1084            fs::create_dir_all(parent)?;
1085        }
1086
1087        fs::write(&path, DEFAULT_CONFIG_TEMPLATE)?;
1088        eprintln!("Created default config at {}", path.display());
1089
1090        Ok(())
1091    }
1092
1093    pub fn config_path() -> Result<PathBuf> {
1094        let paths = Paths::resolve()?;
1095        Ok(paths.config_file())
1096    }
1097
1098    fn expand_env_vars(&mut self) {
1099        if let Some(ref mut openai) = self.providers.openai {
1100            openai.api_key = expand_env(&openai.api_key);
1101        }
1102        if let Some(ref mut xai) = self.providers.xai {
1103            xai.api_key = expand_env(&xai.api_key);
1104        }
1105        if let Some(ref mut anthropic) = self.providers.anthropic {
1106            anthropic.api_key = expand_env(&anthropic.api_key);
1107        }
1108        if let Some(ref mut telegram) = self.telegram {
1109            telegram.api_token = expand_env(&telegram.api_token);
1110        }
1111        if let Some(ref mut ws) = self.tools.web_search
1112            && let Some(ref mut brave) = ws.brave
1113        {
1114            brave.api_key = expand_env(&brave.api_key);
1115        }
1116        if let Some(ref mut ws) = self.tools.web_search
1117            && let Some(ref mut tavily) = ws.tavily
1118        {
1119            tavily.api_key = expand_env(&tavily.api_key);
1120        }
1121        if let Some(ref mut ws) = self.tools.web_search
1122            && let Some(ref mut perplexity) = ws.perplexity
1123        {
1124            perplexity.api_key = expand_env(&perplexity.api_key);
1125        }
1126        if let Some(ref mut gemini) = self.providers.gemini {
1127            gemini.api_key = expand_env(&gemini.api_key);
1128        }
1129        if let Some(ref mut openai_compat) = self.providers.openai_compatible {
1130            openai_compat.api_key = expand_env(&openai_compat.api_key);
1131            openai_compat.base_url = expand_env(&openai_compat.base_url);
1132        }
1133        if let Some(ref mut vertex) = self.providers.vertex {
1134            vertex.service_account_key = expand_env(&vertex.service_account_key);
1135            vertex.project_id = expand_env(&vertex.project_id);
1136        }
1137        if let Some(ref mut auth_token) = self.server.auth_token {
1138            *auth_token = expand_env(auth_token);
1139        }
1140    }
1141
1142    pub fn get_value(&self, key: &str) -> Result<String> {
1143        let parts: Vec<&str> = key.split('.').collect();
1144
1145        match parts.as_slice() {
1146            ["agent", "default_model"] => Ok(self.agent.default_model.clone()),
1147            ["agent", "context_window"] => Ok(self.agent.context_window.to_string()),
1148            ["agent", "reserve_tokens"] => Ok(self.agent.reserve_tokens.to_string()),
1149            ["heartbeat", "enabled"] => Ok(self.heartbeat.enabled.to_string()),
1150            ["heartbeat", "interval"] => Ok(self.heartbeat.interval.clone()),
1151            ["server", "enabled"] => Ok(self.server.enabled.to_string()),
1152            ["server", "port"] => Ok(self.server.port.to_string()),
1153            ["server", "bind"] => Ok(self.server.bind.clone()),
1154            ["memory", "workspace"] => Ok(self.memory.workspace.clone()),
1155            ["logging", "level"] => Ok(self.logging.level.clone()),
1156            _ => anyhow::bail!("Unknown config key: {}", key),
1157        }
1158    }
1159
1160    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1161        let parts: Vec<&str> = key.split('.').collect();
1162
1163        match parts.as_slice() {
1164            ["agent", "default_model"] => self.agent.default_model = value.to_string(),
1165            ["agent", "context_window"] => self.agent.context_window = value.parse()?,
1166            ["agent", "reserve_tokens"] => self.agent.reserve_tokens = value.parse()?,
1167            ["heartbeat", "enabled"] => self.heartbeat.enabled = value.parse()?,
1168            ["heartbeat", "interval"] => self.heartbeat.interval = value.to_string(),
1169            ["server", "enabled"] => self.server.enabled = value.parse()?,
1170            ["server", "port"] => self.server.port = value.parse()?,
1171            ["server", "bind"] => self.server.bind = value.to_string(),
1172            ["memory", "workspace"] => self.memory.workspace = value.to_string(),
1173            ["logging", "level"] => self.logging.level = value.to_string(),
1174            _ => anyhow::bail!("Unknown config key: {}", key),
1175        }
1176
1177        Ok(())
1178    }
1179
1180    /// Get workspace path from resolved Paths.
1181    ///
1182    /// Resolution is handled by `Paths::resolve()`:
1183    /// 1. LOCALGPT_WORKSPACE env var (absolute path override)
1184    /// 2. LOCALGPT_PROFILE env var (creates workspace-{profile} under data_dir)
1185    /// 3. memory.workspace from config file (deprecated compat)
1186    /// 4. Default: data_dir/workspace
1187    pub fn workspace_path(&self) -> PathBuf {
1188        self.paths.workspace.clone()
1189    }
1190}
1191
1192fn expand_env(s: &str) -> String {
1193    if let Some(var_name) = s.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
1194        std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1195    } else if let Some(var_name) = s.strip_prefix('$') {
1196        std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1197    } else {
1198        s.to_string()
1199    }
1200}
1201
1202/// Default config template with helpful comments (used for first-time setup)
1203const DEFAULT_CONFIG_TEMPLATE: &str = r#"# LocalGPT Configuration
1204# Auto-created on first run. Edit as needed.
1205
1206[agent]
1207# Default model: claude-cli/opus, anthropic/claude-sonnet-4-5, openai/gpt-4o, xai/grok-3-mini, etc.
1208default_model = "claude-cli/opus"
1209context_window = 128000
1210reserve_tokens = 8000
1211
1212# Spawn agent (subagent) configuration
1213# max_spawn_depth = 1            # 0 = disabled, 1 = single level (default)
1214# subagent_model = "claude-cli/sonnet"  # Model for subagents (default: same as default_model)
1215
1216# Failover configuration (optional)
1217# Automatically try fallback models if primary fails with retryable errors
1218# (rate limits, server errors, timeouts). Providers tried in order.
1219# fallback_models = ["openai/gpt-4o", "ollama/llama3"]
1220
1221# Loop detection (optional)
1222# Maximum times the same tool can be called with identical arguments
1223# before detection triggers. Default: 3. Set to 0 to disable.
1224# max_tool_repeats = 3
1225
1226# Anthropic API (for anthropic/* models)
1227# [providers.anthropic]
1228# api_key = "${ANTHROPIC_API_KEY}"
1229
1230# OpenAI API (for openai/* models)
1231# [providers.openai]
1232# api_key = "${OPENAI_API_KEY}"
1233
1234# xAI API (for xai/* models)
1235# [providers.xai]
1236# api_key = "${XAI_API_KEY}"
1237# base_url = "https://api.x.ai/v1"
1238
1239# OpenAI-Compatible provider (OpenRouter, DeepSeek, Groq, vLLM, LiteLLM, etc.)
1240# [providers.openai_compatible]
1241# base_url = "https://openrouter.ai/api/v1"
1242# api_key = "${OPENROUTER_API_KEY}"
1243# # Optional extra headers (e.g., OpenRouter attribution)
1244# extra_headers = { "HTTP-Referer" = "https://localgpt.app", "X-Title" = "LocalGPT" }
1245# # Use with: localgpt chat --model openai-compat/deepseek-chat
1246
1247# Claude CLI (for claude-cli/* models, requires claude CLI installed)
1248[providers.claude_cli]
1249command = "claude"
1250
1251[heartbeat]
1252enabled = true
1253interval = "30m"
1254
1255# Maximum wall-clock time for a single heartbeat run (optional).
1256# If the heartbeat LLM turn exceeds this deadline it is cancelled and
1257# a TimedOut event is recorded so the next interval can run on schedule.
1258# Defaults to half the interval (e.g., "15m" when interval = "30m").
1259# timeout = "15m"
1260
1261# Only run during these hours (optional)
1262# [heartbeat.active_hours]
1263# start = "09:00"
1264# end = "22:00"
1265
1266[memory]
1267# Workspace directory for memory files (MEMORY.md, HEARTBEAT.md, etc.)
1268# Default: XDG data dir (~/.local/share/localgpt/workspace)
1269# Override with environment variables:
1270#   LOCALGPT_WORKSPACE=/path/to/workspace  - absolute path override
1271#   LOCALGPT_PROFILE=work                  - uses data_dir/workspace-work
1272# workspace = "~/.local/share/localgpt/workspace"
1273
1274# Session memory settings (for /new command)
1275# session_max_messages = 15    # Max messages to save (0 = unlimited)
1276# session_max_chars = 0        # Max chars per message (0 = unlimited, preserves full content)
1277
1278[server]
1279enabled = true
1280port = 31327
1281bind = "127.0.0.1"
1282# Optional bearer token for API authentication
1283# auth_token = "${LOCALGPT_AUTH_TOKEN}"
1284
1285[logging]
1286level = "info"
1287
1288# Shell sandbox (kernel-enforced isolation for LLM-generated commands)
1289# [sandbox]
1290# enabled = true                        # default: true
1291# level = "auto"                        # auto | full | standard | minimal | none
1292# timeout_secs = 120                    # default: 120
1293# max_output_bytes = 1048576            # default: 1MB
1294#
1295# [sandbox.allow_paths]
1296# read = ["/data/datasets"]             # additional read-only paths
1297# write = ["/tmp/builds"]               # additional writable paths
1298#
1299# [sandbox.network]
1300# policy = "deny"                       # deny | proxy
1301
1302# Web search (optional)
1303# [tools.web_search]
1304# provider = "searxng"            # searxng | brave | tavily | perplexity | none
1305# cache_enabled = true
1306# cache_ttl = 900                 # seconds (default: 15 min)
1307# max_results = 5                 # 1-10
1308# prefer_native = true            # prefer native provider search when available
1309#
1310# [tools.web_search.searxng]
1311# base_url = "http://localhost:8080"
1312# categories = "general"
1313# language = "en"
1314#
1315# [tools.web_search.brave]
1316# api_key = "${BRAVE_API_KEY}"
1317#
1318# [tools.web_search.tavily]
1319# api_key = "${TAVILY_API_KEY}"
1320# search_depth = "basic"          # basic | advanced
1321# include_answer = true
1322#
1323# [tools.web_search.perplexity]
1324# api_key = "${PERPLEXITY_API_KEY}"
1325# model = "sonar"
1326
1327# Telegram bot (optional)
1328# [telegram]
1329# enabled = true
1330# api_token = "${TELEGRAM_BOT_TOKEN}"
1331"#;