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/// Memory search backend kind.
19///
20/// Determines which storage engine is used for the memory search index:
21/// - `sqlite` (default): Full-featured FTS5 + optional vector search via SQLite
22/// - `markdown`: Lightweight grep-based search over workspace `.md` files
23/// - `none`: Memory search disabled (workspace file reading still works)
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum MemoryBackendKind {
27    #[default]
28    Sqlite,
29    Markdown,
30    None,
31}
32
33impl std::fmt::Display for MemoryBackendKind {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Sqlite => write!(f, "sqlite"),
37            Self::Markdown => write!(f, "markdown"),
38            Self::None => write!(f, "none"),
39        }
40    }
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct Config {
45    /// Resolved XDG-compliant paths (not serialized)
46    #[serde(skip)]
47    pub paths: Paths,
48
49    #[serde(default)]
50    pub agent: AgentConfig,
51
52    #[serde(default)]
53    pub providers: ProvidersConfig,
54
55    #[serde(default)]
56    pub heartbeat: HeartbeatConfig,
57
58    #[serde(default)]
59    pub memory: MemoryConfig,
60
61    #[serde(default)]
62    pub server: ServerConfig,
63
64    #[serde(default)]
65    pub logging: LoggingConfig,
66
67    #[serde(default)]
68    pub tools: ToolsConfig,
69
70    #[serde(default)]
71    pub security: SecurityConfig,
72
73    #[serde(default)]
74    pub sandbox: SandboxConfig,
75
76    #[serde(default)]
77    pub telegram: Option<TelegramConfig>,
78
79    #[serde(default)]
80    pub cron: CronConfig,
81
82    #[serde(default)]
83    pub hooks: HooksConfig,
84
85    #[serde(default)]
86    pub mcp: McpConfig,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AgentConfig {
91    #[serde(default = "default_model")]
92    pub default_model: String,
93
94    #[serde(default = "default_context_window")]
95    pub context_window: usize,
96
97    #[serde(default = "default_reserve_tokens")]
98    pub reserve_tokens: usize,
99
100    /// Maximum tokens for LLM response
101    #[serde(default = "default_max_tokens")]
102    pub max_tokens: usize,
103
104    /// Maximum depth for spawn_agent tool (default: 1, no nested spawning)
105    /// - 0: spawn_agent tool disabled
106    /// - 1: single level only (subagents cannot spawn more agents)
107    /// - 2+: limited nesting allowed (not recommended)
108    #[serde(default)]
109    pub max_spawn_depth: Option<u8>,
110
111    /// Model to use for spawned subagents (default: same as default_model or claude-cli/sonnet)
112    #[serde(default)]
113    pub subagent_model: Option<String>,
114
115    /// Fallback models to try if primary provider fails with retryable errors
116    /// (rate limits, server errors, timeouts). Providers are tried in order.
117    /// Example: ["openai/gpt-4o", "ollama/llama3"]
118    #[serde(default)]
119    pub fallback_models: Vec<String>,
120
121    /// Maximum times the same tool can be called with identical arguments before
122    /// loop detection triggers. Default: 3. Set to 0 to disable loop detection.
123    #[serde(default = "default_max_tool_repeats")]
124    pub max_tool_repeats: usize,
125
126    /// Maximum consecutive failures of the same tool before blocking it.
127    /// Default: 3. Set to 0 to disable tool error tracking.
128    #[serde(default = "default_max_tool_errors")]
129    pub max_tool_errors: u32,
130
131    /// Allow one retry when a tool call has malformed arguments.
132    /// Default: true. Set to false to fail immediately.
133    #[serde(default = "default_true")]
134    pub tool_retry_on_malformed: bool,
135
136    /// Maximum age for session files before pruning (in seconds).
137    /// 0 = keep forever. Default: 30 days.
138    #[serde(default = "default_session_max_age")]
139    pub session_max_age: u64,
140
141    /// Maximum number of sessions to keep per agent.
142    /// 0 = unlimited. Default: 500.
143    #[serde(default = "default_session_max_count")]
144    pub session_max_count: usize,
145
146    /// Sections from AGENTS.md (or SOUL.md) to re-inject after session compaction.
147    /// Default: ["Session Startup", "Red Lines"]. Empty array disables injection.
148    #[serde(default = "default_post_compaction_sections")]
149    pub post_compaction_sections: Vec<String>,
150
151    /// Save checkpoint of session transcript before compaction (enables restore).
152    /// Default: true.
153    #[serde(default = "default_true")]
154    pub checkpoints_enabled: bool,
155
156    /// Maximum compaction checkpoints to keep per session. Default: 5.
157    #[serde(default = "default_max_checkpoints")]
158    pub max_checkpoints: usize,
159
160    /// Active memory recall configuration — automatically search memory before replies
161    #[serde(default)]
162    pub active_memory: crate::memory::active_recall::ActiveMemoryConfig,
163
164    /// Session permission level. Tools requiring a higher level need approval.
165    /// Default: "elevated" (all tools run without prompting, matching current behavior).
166    /// Set to "safe" to require approval for dangerous tools (bash, file write, etc.).
167    #[serde(default = "default_permission_level")]
168    pub permission_level: crate::agent::tools::PermissionLevel,
169
170    /// Auto-approve elevated tool calls from loopback connections (localhost).
171    /// Default: true (backward compatible — CLI and local HTTP skip approval).
172    #[serde(default = "default_true")]
173    pub auto_approve_loopback: bool,
174}
175
176fn default_max_tool_repeats() -> usize {
177    3
178}
179
180fn default_max_tool_errors() -> u32 {
181    3
182}
183
184fn default_session_max_age() -> u64 {
185    30 * 24 * 60 * 60 // 30 days in seconds
186}
187
188fn default_session_max_count() -> usize {
189    500
190}
191
192fn default_max_checkpoints() -> usize {
193    5
194}
195
196fn default_permission_level() -> crate::agent::tools::PermissionLevel {
197    // Elevated = all tools run without prompting (backward compatible)
198    crate::agent::tools::PermissionLevel::Elevated
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ToolsConfig {
203    /// Bash command timeout in milliseconds
204    #[serde(default = "default_bash_timeout")]
205    pub bash_timeout_ms: u64,
206
207    /// Maximum bytes to return from web_fetch
208    #[serde(default = "default_web_fetch_max_bytes")]
209    pub web_fetch_max_bytes: usize,
210
211    /// Tools that require user approval before execution
212    /// e.g., ["bash", "write_file", "edit_file"]
213    #[serde(default)]
214    pub require_approval: Vec<String>,
215
216    /// Maximum characters for tool output (0 = unlimited)
217    #[serde(default = "default_tool_output_max_chars")]
218    pub tool_output_max_chars: usize,
219
220    /// Log warnings for suspicious injection patterns detected in tool outputs
221    #[serde(default = "default_true")]
222    pub log_injection_warnings: bool,
223
224    /// Wrap tool outputs and memory content with XML-style delimiters
225    #[serde(default = "default_true")]
226    pub use_content_delimiters: bool,
227
228    /// Web search configuration (disabled by default)
229    #[serde(default)]
230    pub web_search: Option<WebSearchConfig>,
231
232    /// Document loader overrides: extension → shell command (e.g., "pdf" → "pdftotext $1 -")
233    #[serde(default)]
234    pub document_loaders: Option<std::collections::HashMap<String, String>>,
235
236    /// Maximum document file size in bytes (default: 10MB)
237    #[serde(default = "default_document_max_bytes")]
238    pub document_max_bytes: usize,
239
240    /// Audio transcription (STT) configuration
241    #[serde(default)]
242    pub stt: Option<crate::media::SttConfig>,
243
244    /// Maximum image dimension (width or height) before resizing for vision models.
245    /// 0 = disabled (send original). Default: 1568px.
246    #[serde(default = "default_image_max_dimension")]
247    pub image_max_dimension: u32,
248
249    /// Enable media result caching for document_load/transcribe_audio (default: true)
250    #[serde(default = "default_true")]
251    pub media_cache_enabled: bool,
252
253    /// Maximum media cache size in MB (default: 100)
254    #[serde(default = "default_media_cache_max_mb")]
255    pub media_cache_max_mb: u64,
256
257    /// Enable browser automation tool via Chrome DevTools Protocol (default: false)
258    #[serde(default)]
259    pub browser_enabled: bool,
260
261    /// Chrome CDP debug port (default: 9222)
262    #[serde(default = "default_browser_port")]
263    pub browser_port: u16,
264
265    /// Per-tool input filters (deny/allow patterns and substrings).
266    /// Keys are tool names (e.g. "bash", "web_fetch").
267    #[serde(default)]
268    pub filters: std::collections::HashMap<String, crate::agent::tool_filters::ToolFilter>,
269}
270
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272#[serde(rename_all = "lowercase")]
273pub enum SearchProviderType {
274    Searxng,
275    Brave,
276    Tavily,
277    Perplexity,
278    #[default]
279    None,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct WebSearchConfig {
284    #[serde(default)]
285    pub provider: SearchProviderType,
286
287    #[serde(default = "default_true")]
288    pub cache_enabled: bool,
289
290    /// Cache TTL in seconds (default: 900 = 15 minutes)
291    #[serde(default = "default_cache_ttl")]
292    pub cache_ttl: u64,
293
294    /// Maximum results per query (1-10, default: 5)
295    #[serde(default = "default_max_results")]
296    pub max_results: u8,
297
298    /// Prefer provider-native search when supported (e.g., Anthropic web_search tool)
299    #[serde(default = "default_true")]
300    pub prefer_native: bool,
301
302    #[serde(default)]
303    pub searxng: Option<SearxngConfig>,
304
305    #[serde(default)]
306    pub brave: Option<BraveConfig>,
307
308    #[serde(default)]
309    pub tavily: Option<TavilyConfig>,
310
311    #[serde(default)]
312    pub perplexity: Option<PerplexityConfig>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct SearxngConfig {
317    pub base_url: String,
318
319    #[serde(default)]
320    pub categories: String,
321
322    #[serde(default)]
323    pub language: String,
324
325    #[serde(default)]
326    pub time_range: String,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct BraveConfig {
331    pub api_key: String,
332
333    #[serde(default)]
334    pub country: String,
335
336    #[serde(default)]
337    pub freshness: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct TavilyConfig {
342    pub api_key: String,
343
344    #[serde(default = "default_basic")]
345    pub search_depth: String,
346
347    #[serde(default = "default_true")]
348    pub include_answer: bool,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct PerplexityConfig {
353    pub api_key: String,
354
355    #[serde(default = "default_sonar")]
356    pub model: String,
357}
358
359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
360pub struct SecurityConfig {
361    /// Abort agent startup on tamper or suspicious content (default: false).
362    ///
363    /// When true, `TamperDetected` and `SuspiciousContent` are fatal errors
364    /// that prevent the agent from starting. When false (default), the agent
365    /// warns and falls back to hardcoded-only security.
366    #[serde(default)]
367    pub strict_policy: bool,
368
369    /// Skip loading and injecting the `LocalGPT.md` workspace security policy
370    /// (default: false).
371    ///
372    /// When true, the user's signed `LocalGPT.md` content is not loaded or
373    /// injected into the context window. The hardcoded security suffix still
374    /// applies unless [`disable_suffix`] is also set.
375    #[serde(default)]
376    pub disable_policy: bool,
377
378    /// Skip injecting the hardcoded security suffix (default: false).
379    ///
380    /// The suffix is a compiled-in reminder that tells the model to treat
381    /// tool outputs and retrieved content as data, not instructions. When
382    /// disabled, the user policy (if any) still applies.
383    ///
384    /// **Warning:** Setting both `disable_policy` and `disable_suffix` to
385    /// `true` removes all end-of-context security reinforcement. The system
386    /// prompt safety section still exists, but may lose effectiveness in
387    /// long sessions due to attention decay ("lost in the middle" effect).
388    #[serde(default)]
389    pub disable_suffix: bool,
390
391    /// Restrict file tools to these directories (empty = unrestricted).
392    /// Paths are canonicalized at startup. Symlinks are resolved before checking.
393    #[serde(default)]
394    pub allowed_directories: Vec<String>,
395
396    /// Enable encryption at rest for sessions and config secrets (default: false).
397    #[serde(default)]
398    pub encryption: bool,
399
400    /// Path to the encryption key file.
401    /// Default: data_dir/encryption.key (~/.local/share/localgpt/encryption.key)
402    #[serde(default)]
403    pub encryption_key_path: Option<String>,
404
405    /// Container sandbox backend: "disabled" (default), "auto", "docker", "podman"
406    /// "auto" tries Docker then Podman, falls back to kernel sandbox.
407    #[serde(default = "default_sandbox_backend")]
408    pub sandbox_backend: String,
409
410    /// Docker image for container sandbox (default: "ubuntu:24.04")
411    #[serde(default)]
412    pub sandbox_image: Option<String>,
413
414    /// Memory limit for sandbox container (e.g., "512m", "1g")
415    #[serde(default)]
416    pub sandbox_memory: Option<String>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct SandboxConfig {
421    /// Enable shell command sandboxing (default: true)
422    #[serde(default = "default_true")]
423    pub enabled: bool,
424
425    /// Sandbox level: "auto" | "full" | "standard" | "minimal" | "none"
426    #[serde(default = "default_sandbox_level")]
427    pub level: String,
428
429    /// Command timeout in seconds (default: 120)
430    #[serde(default = "default_sandbox_timeout")]
431    pub timeout_secs: u64,
432
433    /// Maximum output bytes (default: 1MB)
434    #[serde(default = "default_sandbox_max_output")]
435    pub max_output_bytes: u64,
436
437    /// Maximum file size in bytes (RLIMIT_FSIZE, default: 50MB)
438    #[serde(default = "default_sandbox_max_file_size")]
439    pub max_file_size_bytes: u64,
440
441    /// Maximum child processes (RLIMIT_NPROC, default: 64)
442    #[serde(default = "default_sandbox_max_processes")]
443    pub max_processes: u32,
444
445    /// Additional path allowances
446    #[serde(default)]
447    pub allow_paths: AllowPathsConfig,
448
449    /// Network policy
450    #[serde(default)]
451    pub network: SandboxNetworkConfig,
452}
453
454#[derive(Debug, Clone, Default, Serialize, Deserialize)]
455pub struct AllowPathsConfig {
456    /// Additional read-only paths
457    #[serde(default)]
458    pub read: Vec<String>,
459
460    /// Additional writable paths
461    #[serde(default)]
462    pub write: Vec<String>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct SandboxNetworkConfig {
467    /// Network policy: "deny" | "proxy"
468    #[serde(default = "default_sandbox_network_policy")]
469    pub policy: String,
470}
471
472impl Default for SandboxConfig {
473    fn default() -> Self {
474        Self {
475            enabled: default_true(),
476            level: default_sandbox_level(),
477            timeout_secs: default_sandbox_timeout(),
478            max_output_bytes: default_sandbox_max_output(),
479            max_file_size_bytes: default_sandbox_max_file_size(),
480            max_processes: default_sandbox_max_processes(),
481            allow_paths: AllowPathsConfig::default(),
482            network: SandboxNetworkConfig::default(),
483        }
484    }
485}
486
487impl Default for SandboxNetworkConfig {
488    fn default() -> Self {
489        Self {
490            policy: default_sandbox_network_policy(),
491        }
492    }
493}
494
495#[derive(Debug, Clone, Default, Serialize, Deserialize)]
496pub struct ProvidersConfig {
497    #[serde(default)]
498    pub openai: Option<OpenAIConfig>,
499
500    #[serde(default)]
501    pub xai: Option<XaiConfig>,
502
503    #[serde(default)]
504    pub anthropic: Option<AnthropicConfig>,
505
506    #[serde(default)]
507    pub ollama: Option<OllamaConfig>,
508
509    #[serde(default)]
510    pub claude_cli: Option<ClaudeCliConfig>,
511
512    #[serde(default)]
513    pub gemini_cli: Option<GeminiCliConfig>,
514
515    #[serde(default)]
516    pub codex_cli: Option<CodexCliConfig>,
517
518    #[serde(default)]
519    pub glm: Option<GlmConfig>,
520
521    #[serde(default)]
522    pub gemini: Option<GeminiConfig>,
523
524    /// Generic OpenAI-compatible provider for any endpoint speaking the OpenAI Chat Completions API
525    /// (OpenRouter, DeepSeek, Groq, vLLM, LiteLLM, Together AI, Fireworks, etc.)
526    #[serde(default)]
527    pub openai_compatible: Option<OpenAICompatibleConfig>,
528
529    /// Google Vertex AI (service account key authentication)
530    #[serde(default)]
531    pub vertex: Option<VertexAiConfig>,
532
533    /// OpenRouter convenience provider (auto-sets base_url)
534    #[serde(default)]
535    pub openrouter: Option<OpenRouterConfig>,
536}
537
538/// OpenRouter provider config — convenience wrapper for OpenAI-compatible
539#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct OpenRouterConfig {
541    pub api_key: String,
542}
543
544/// Configuration for OpenAI-compatible providers (OpenRouter, DeepSeek, Groq, etc.)
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct OpenAICompatibleConfig {
547    /// Base URL for the API endpoint (e.g., "https://openrouter.ai/api/v1")
548    pub base_url: String,
549
550    /// API key for authentication (supports ${ENV_VAR} expansion)
551    pub api_key: String,
552
553    /// Extra headers to include in every request (e.g., OpenRouter attribution)
554    #[serde(default)]
555    pub extra_headers: std::collections::HashMap<String, String>,
556}
557
558/// Configuration for Google Vertex AI (service account key authentication)
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct VertexAiConfig {
561    /// Path to service account JSON key file (supports ~ and ${ENV_VAR} expansion)
562    pub service_account_key: String,
563
564    /// Google Cloud project ID (supports ${ENV_VAR} expansion)
565    pub project_id: String,
566
567    /// Regional endpoint location (default: "us-central1")
568    #[serde(default = "default_vertex_location")]
569    pub location: String,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct OpenAIConfig {
574    pub api_key: String,
575
576    #[serde(default = "default_openai_base_url")]
577    pub base_url: String,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct XaiConfig {
582    pub api_key: String,
583
584    #[serde(default = "default_xai_base_url")]
585    pub base_url: String,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct AnthropicConfig {
590    pub api_key: String,
591
592    #[serde(default = "default_anthropic_base_url")]
593    pub base_url: String,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct OllamaConfig {
598    #[serde(default = "default_ollama_endpoint")]
599    pub endpoint: String,
600
601    #[serde(default = "default_ollama_model")]
602    pub model: String,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct ClaudeCliConfig {
607    #[serde(default = "default_claude_cli_command")]
608    pub command: String,
609
610    #[serde(default = "default_claude_cli_model")]
611    pub model: String,
612
613    /// Effort level passed to Claude CLI via --effort flag.
614    /// Valid values: "low", "medium", "high", "max".
615    #[serde(default = "default_claude_cli_effort")]
616    pub effort: String,
617
618    /// Optional MCP config JSON passed to Claude CLI via --strict-mcp-config --mcp-config.
619    /// When set, overrides Claude CLI's own MCP server configuration, preventing it from
620    /// spawning processes that may conflict with the caller (e.g., a second Bevy window
621    /// when localgpt-gen is already running).
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub mcp_config_override: Option<String>,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct GeminiCliConfig {
628    #[serde(default = "default_gemini_cli_command")]
629    pub command: String,
630
631    #[serde(default = "default_gemini_cli_model")]
632    pub model: String,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct CodexCliConfig {
637    #[serde(default = "default_codex_cli_command")]
638    pub command: String,
639
640    #[serde(default = "default_codex_cli_model")]
641    pub model: String,
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct GlmConfig {
646    pub api_key: String,
647
648    #[serde(default = "default_glm_base_url")]
649    pub base_url: String,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct GeminiConfig {
654    pub api_key: String,
655
656    #[serde(default = "default_gemini_base_url")]
657    pub base_url: String,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct HeartbeatConfig {
662    #[serde(default = "default_true")]
663    pub enabled: bool,
664
665    #[serde(default = "default_interval")]
666    pub interval: String,
667
668    #[serde(default = "default_overdue_delay")]
669    pub overdue_delay: String,
670
671    /// Maximum duration for a single heartbeat run.
672    /// If not set, defaults to half the heartbeat interval.
673    /// Accepts the same format as `interval` (e.g., "15m", "1h").
674    #[serde(default)]
675    pub timeout: Option<String>,
676
677    #[serde(default)]
678    pub active_hours: Option<ActiveHours>,
679
680    #[serde(default)]
681    pub timezone: Option<String>,
682
683    /// Dreaming configuration — background memory consolidation
684    #[serde(default)]
685    pub dreaming: crate::memory::dreaming::DreamingConfig,
686
687    /// MCP server allowlist for heartbeat. Empty = all servers. Case-insensitive.
688    #[serde(default)]
689    pub mcp_servers: Vec<String>,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct ActiveHours {
694    pub start: String,
695    pub end: String,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct MemoryConfig {
700    /// Memory search backend: "sqlite" (default), "markdown", or "none"
701    #[serde(default)]
702    pub backend: MemoryBackendKind,
703
704    #[serde(default = "default_workspace")]
705    pub workspace: String,
706
707    /// Embedding provider: "local" (fastembed, default), "openai", "gemini", or "none"
708    #[serde(default = "default_embedding_provider")]
709    pub embedding_provider: String,
710
711    #[serde(default = "default_embedding_model")]
712    pub embedding_model: String,
713
714    /// Gemini API key for embeddings (supports ${ENV_VAR} syntax)
715    #[serde(default)]
716    pub gemini_api_key: Option<String>,
717
718    /// Index completed session transcripts for memory_search (default: false)
719    #[serde(default)]
720    pub index_sessions: bool,
721
722    /// Enable multimodal embeddings for images (requires gemini-embedding-2-preview model)
723    #[serde(default)]
724    pub multimodal_embeddings: bool,
725
726    /// Cache directory for local embedding models (optional)
727    /// Default: ~/.cache/localgpt/models
728    /// Can also be set via FASTEMBED_CACHE_DIR environment variable
729    #[serde(default = "default_embedding_cache_dir")]
730    pub embedding_cache_dir: String,
731
732    #[serde(default = "default_chunk_size")]
733    pub chunk_size: usize,
734
735    #[serde(default = "default_chunk_overlap")]
736    pub chunk_overlap: usize,
737
738    /// Additional paths to index (relative to workspace or absolute)
739    /// Each path uses a glob pattern for file matching
740    #[serde(default = "default_index_paths")]
741    pub paths: Vec<MemoryIndexPath>,
742
743    /// Maximum messages to save in session memory files (0 = unlimited)
744    /// Similar to OpenClaw's hooks.session-memory.messages (default: 15)
745    #[serde(default = "default_session_max_messages")]
746    pub session_max_messages: usize,
747
748    /// Maximum characters per message in session memory (0 = unlimited)
749    /// Set to 0 to preserve full message content like OpenClaw
750    #[serde(default)]
751    pub session_max_chars: usize,
752
753    /// Temporal decay factor for search scoring.
754    /// Older memories get lower scores using: score * exp(-lambda * age_days)
755    /// Default: 0.0 (disabled)
756    /// 0.1 = ~50% penalty for 7-day old memory
757    /// 0.05 = ~50% penalty for 14-day old memory
758    #[serde(default)]
759    pub temporal_decay_lambda: f64,
760
761    /// Enable LLM-based query expansion for memory search.
762    /// Costs one extra LLM call per search but produces better keywords
763    /// from conversational queries. Default: false.
764    #[serde(default)]
765    pub llm_query_expansion: bool,
766
767    /// Enable the Memory Wiki structured knowledge layer (claims, evidence, staleness).
768    /// Default: false.
769    #[serde(default)]
770    pub wiki_enabled: bool,
771
772    /// Days after last update before a wiki claim transitions from Fresh to Aging.
773    /// Default: 30.
774    #[serde(default = "default_wiki_fresh_days")]
775    pub wiki_fresh_days: u32,
776
777    /// Days after last update before a wiki claim transitions from Aging to Stale.
778    /// Default: 90.
779    #[serde(default = "default_wiki_stale_days")]
780    pub wiki_stale_days: u32,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
784pub struct MemoryIndexPath {
785    pub path: String,
786    #[serde(default = "default_pattern")]
787    pub pattern: String,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct ServerConfig {
792    #[serde(default = "default_true")]
793    pub enabled: bool,
794
795    #[serde(default = "default_port")]
796    pub port: u16,
797
798    #[serde(default = "default_bind")]
799    pub bind: String,
800
801    /// Bearer token for API authentication.
802    /// If set, all /api/* routes require Authorization: Bearer <token>.
803    /// Supports ${ENV_VAR} expansion.
804    /// If unset, auth is disabled (backward compatible for local-only use).
805    #[serde(default)]
806    pub auth_token: Option<String>,
807
808    #[serde(default)]
809    pub rate_limit: RateLimitConfig,
810
811    /// Allowed CORS origins. If empty, defaults to localhost only (any port,
812    /// http/https, 127.0.0.1, localhost, and [::1]).
813    #[serde(default)]
814    pub cors_origins: Vec<String>,
815
816    /// Maximum request body size in bytes.
817    /// Requests larger than this return 413 Payload Too Large.
818    /// Default: 10MB
819    #[serde(default = "default_max_request_body")]
820    pub max_request_body: usize,
821
822    /// HMAC-SHA256 secret for webhook signature verification.
823    /// When set, incoming webhook requests must include X-Signature-256 header.
824    /// Supports ${ENV_VAR} expansion.
825    #[serde(default)]
826    pub webhook_secret: Option<String>,
827
828    /// Enable durable outbound message queue with retry on send failure.
829    /// Default: false.
830    #[serde(default)]
831    pub outbox_enabled: bool,
832
833    /// Maximum retry attempts for outbox messages. Default: 5.
834    #[serde(default = "default_outbox_max_attempts")]
835    pub outbox_max_attempts: i64,
836
837    /// Days to retain delivered messages before cleanup. Default: 7.
838    #[serde(default = "default_outbox_retain_days")]
839    pub outbox_retain_days: u32,
840
841    /// Enable auto-generated TLS certificates for HTTPS. Default: false.
842    /// Requires the `tls` feature on localgpt-server.
843    #[serde(default)]
844    pub tls_enabled: bool,
845
846    /// Directory for TLS certificate storage. Default: ~/.config/localgpt/certs
847    #[serde(default = "default_tls_cert_dir")]
848    pub tls_cert_dir: String,
849
850    /// Regenerate certificates when they expire within this many days. Default: 30.
851    #[serde(default = "default_tls_renew_threshold")]
852    pub tls_renew_threshold_days: u32,
853}
854
855fn default_max_request_body() -> usize {
856    10 * 1024 * 1024 // 10MB
857}
858fn default_outbox_max_attempts() -> i64 {
859    5
860}
861fn default_outbox_retain_days() -> u32 {
862    7
863}
864fn default_tls_cert_dir() -> String {
865    if let Some(proj) = directories::ProjectDirs::from("app", "LocalGPT", "localgpt") {
866        proj.config_dir()
867            .join("certs")
868            .to_string_lossy()
869            .to_string()
870    } else {
871        "~/.config/localgpt/certs".to_string()
872    }
873}
874fn default_tls_renew_threshold() -> u32 {
875    30
876}
877
878#[derive(Debug, Clone, Serialize, Deserialize)]
879pub struct RateLimitConfig {
880    #[serde(default = "default_true")]
881    pub enabled: bool,
882
883    /// Maximum requests per minute per IP
884    #[serde(default = "default_requests_per_minute")]
885    pub requests_per_minute: u32,
886
887    /// Burst allowance (extra requests above steady rate)
888    #[serde(default = "default_burst")]
889    pub burst: u32,
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct LoggingConfig {
894    #[serde(default = "default_log_level")]
895    pub level: String,
896
897    #[serde(default = "default_log_path", alias = "file")]
898    pub path: String,
899
900    /// Days to keep log files (0 = keep forever, no auto-deletion)
901    #[serde(default)]
902    pub retention_days: u32,
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize)]
906pub struct TelegramConfig {
907    #[serde(default)]
908    pub enabled: bool,
909
910    pub api_token: String,
911
912    /// Optional Telegram forum topic ID for heartbeat/cron results.
913    /// When set, heartbeat alerts are routed to this topic instead of the general thread.
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub heartbeat_topic_id: Option<i32>,
916}
917
918#[derive(Debug, Clone, Default, Serialize, Deserialize)]
919pub struct CronConfig {
920    #[serde(default)]
921    pub jobs: Vec<CronJob>,
922}
923
924#[derive(Debug, Clone, Serialize, Deserialize)]
925pub struct CronJob {
926    pub name: String,
927
928    /// Cron expression ("0 */6 * * *") or interval ("every 30m", "every 2h", "every 1d")
929    pub schedule: String,
930
931    /// Prompt to send to a fresh agent session
932    pub prompt: String,
933
934    /// Optional Telegram channel/chat to route output to
935    #[serde(default)]
936    pub channel: Option<String>,
937
938    #[serde(default = "default_true")]
939    pub enabled: bool,
940
941    /// Timeout for the job (e.g., "5m", "1h"). Default: 10m
942    #[serde(default = "default_cron_timeout")]
943    pub timeout: String,
944
945    /// MCP server allowlist for this job. Empty = all servers. Case-insensitive.
946    #[serde(default)]
947    pub mcp_servers: Vec<String>,
948}
949
950#[derive(Debug, Clone, Default, Serialize, Deserialize)]
951pub struct HooksConfig {
952    #[serde(default)]
953    pub hooks: Vec<HookConfig>,
954}
955
956#[derive(Debug, Clone, Serialize, Deserialize)]
957pub struct HookConfig {
958    /// Unique name for this hook
959    pub name: String,
960
961    /// Event type: onMessage, onToolCall, onSessionStart, onSessionEnd, beforeToolCall, afterToolCall
962    pub event: String,
963
964    /// Command to execute (shell command)
965    pub command: String,
966
967    /// Optional filter (e.g., "tool:bash" for beforeToolCall)
968    #[serde(default)]
969    pub filter: Option<String>,
970
971    /// Whether this hook is enabled
972    #[serde(default = "default_true")]
973    pub enabled: bool,
974}
975
976#[derive(Debug, Clone, Default, Serialize, Deserialize)]
977pub struct McpConfig {
978    #[serde(default)]
979    pub servers: Vec<McpServerConfig>,
980}
981
982impl McpConfig {
983    /// Filter servers by an allowlist. Empty allowlist returns all servers.
984    /// Matching is case-insensitive.
985    pub fn filter_servers(&self, allowlist: &[String]) -> Vec<McpServerConfig> {
986        if allowlist.is_empty() {
987            return self.servers.clone();
988        }
989        self.servers
990            .iter()
991            .filter(|s| {
992                allowlist
993                    .iter()
994                    .any(|name| name.eq_ignore_ascii_case(&s.name))
995            })
996            .cloned()
997            .collect()
998    }
999}
1000
1001#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1002pub struct McpServerConfig {
1003    /// Unique name for this MCP server (used in tool namespacing)
1004    #[serde(default)]
1005    pub name: String,
1006
1007    /// Transport type: "stdio" or "sse"
1008    #[serde(default = "default_mcp_transport")]
1009    pub transport: String,
1010
1011    /// Command to run for stdio transport
1012    pub command: Option<String>,
1013
1014    /// Arguments for the stdio command
1015    #[serde(default)]
1016    pub args: Vec<String>,
1017
1018    /// Environment variables for the subprocess
1019    #[serde(default)]
1020    pub env: std::collections::HashMap<String, String>,
1021
1022    /// URL for SSE transport
1023    pub url: Option<String>,
1024
1025    /// Whether this MCP server is enabled (default: true)
1026    #[serde(default = "default_true")]
1027    pub enabled: bool,
1028}
1029
1030fn default_mcp_transport() -> String {
1031    "stdio".to_string()
1032}
1033
1034// Default value functions
1035fn default_model() -> String {
1036    // Default to Claude CLI (uses existing Claude Code auth, no API key needed)
1037    "claude-cli/opus".to_string()
1038}
1039fn default_context_window() -> usize {
1040    128000
1041}
1042fn default_reserve_tokens() -> usize {
1043    8000
1044}
1045fn default_max_tokens() -> usize {
1046    4096
1047}
1048fn default_bash_timeout() -> u64 {
1049    30000 // 30 seconds
1050}
1051fn default_web_fetch_max_bytes() -> usize {
1052    10000
1053}
1054fn default_tool_output_max_chars() -> usize {
1055    50000 // 50k characters max for tool output by default
1056}
1057fn default_document_max_bytes() -> usize {
1058    10_485_760 // 10MB
1059}
1060fn default_media_cache_max_mb() -> u64 {
1061    100
1062}
1063fn default_browser_port() -> u16 {
1064    9222
1065}
1066fn default_sandbox_backend() -> String {
1067    "disabled".to_string()
1068}
1069fn default_image_max_dimension() -> u32 {
1070    1568 // Moltis-compatible, conservative for token cost
1071}
1072fn default_post_compaction_sections() -> Vec<String> {
1073    vec!["Session Startup".to_string(), "Red Lines".to_string()]
1074}
1075fn default_openai_base_url() -> String {
1076    "https://api.openai.com/v1".to_string()
1077}
1078fn default_xai_base_url() -> String {
1079    "https://api.x.ai/v1".to_string()
1080}
1081fn default_anthropic_base_url() -> String {
1082    "https://api.anthropic.com".to_string()
1083}
1084fn default_ollama_endpoint() -> String {
1085    "http://localhost:11434".to_string()
1086}
1087fn default_ollama_model() -> String {
1088    "llama3".to_string()
1089}
1090fn default_claude_cli_command() -> String {
1091    "claude".to_string()
1092}
1093fn default_claude_cli_model() -> String {
1094    "opus".to_string()
1095}
1096fn default_claude_cli_effort() -> String {
1097    "max".to_string()
1098}
1099fn default_gemini_cli_command() -> String {
1100    "gemini".to_string()
1101}
1102fn default_gemini_cli_model() -> String {
1103    "gemini-3.1-pro-preview".to_string()
1104}
1105fn default_codex_cli_command() -> String {
1106    "codex".to_string()
1107}
1108fn default_codex_cli_model() -> String {
1109    "o4-mini".to_string()
1110}
1111fn default_glm_base_url() -> String {
1112    "https://api.z.ai/api/coding/paas/v4".to_string()
1113}
1114fn default_gemini_base_url() -> String {
1115    "https://generativelanguage.googleapis.com".to_string()
1116}
1117fn default_vertex_location() -> String {
1118    "us-central1".to_string()
1119}
1120fn default_true() -> bool {
1121    true
1122}
1123fn default_interval() -> String {
1124    "30m".to_string()
1125}
1126
1127fn default_overdue_delay() -> String {
1128    "1m".to_string()
1129}
1130fn default_workspace() -> String {
1131    format!("{}/workspace", DEFAULT_DATA_DIR_STR)
1132}
1133fn default_embedding_provider() -> String {
1134    "local".to_string() // Local embeddings via fastembed (no API key needed)
1135}
1136fn default_embedding_model() -> String {
1137    "all-MiniLM-L6-v2".to_string() // Local model via fastembed (no API key needed)
1138}
1139fn default_embedding_cache_dir() -> String {
1140    crate::paths::DEFAULT_CACHE_DIR_STR.to_string() + "/embeddings"
1141}
1142fn default_chunk_size() -> usize {
1143    400
1144}
1145fn default_chunk_overlap() -> usize {
1146    80
1147}
1148fn default_index_paths() -> Vec<MemoryIndexPath> {
1149    vec![MemoryIndexPath {
1150        path: "knowledge".to_string(),
1151        pattern: "**/*.md".to_string(),
1152    }]
1153}
1154fn default_pattern() -> String {
1155    "**/*.md".to_string()
1156}
1157fn default_session_max_messages() -> usize {
1158    15 // Match OpenClaw's default
1159}
1160fn default_wiki_fresh_days() -> u32 {
1161    30
1162}
1163fn default_wiki_stale_days() -> u32 {
1164    90
1165}
1166fn default_port() -> u16 {
1167    31327
1168}
1169fn default_cron_timeout() -> String {
1170    "10m".to_string()
1171}
1172fn default_requests_per_minute() -> u32 {
1173    60
1174}
1175fn default_burst() -> u32 {
1176    10
1177}
1178fn default_bind() -> String {
1179    "127.0.0.1".to_string()
1180}
1181fn default_log_level() -> String {
1182    "info".to_string()
1183}
1184fn default_log_path() -> String {
1185    format!("{}/logs", DEFAULT_STATE_DIR_STR)
1186}
1187fn default_sandbox_level() -> String {
1188    "auto".to_string()
1189}
1190fn default_sandbox_timeout() -> u64 {
1191    120
1192}
1193fn default_sandbox_max_output() -> u64 {
1194    1_048_576 // 1MB
1195}
1196fn default_sandbox_max_file_size() -> u64 {
1197    52_428_800 // 50MB
1198}
1199fn default_sandbox_max_processes() -> u32 {
1200    64
1201}
1202fn default_sandbox_network_policy() -> String {
1203    "deny".to_string()
1204}
1205fn default_cache_ttl() -> u64 {
1206    900 // 15 minutes
1207}
1208fn default_max_results() -> u8 {
1209    5
1210}
1211fn default_basic() -> String {
1212    "basic".to_string()
1213}
1214fn default_sonar() -> String {
1215    "sonar".to_string()
1216}
1217
1218impl Default for AgentConfig {
1219    fn default() -> Self {
1220        Self {
1221            default_model: default_model(),
1222            context_window: default_context_window(),
1223            reserve_tokens: default_reserve_tokens(),
1224            max_tokens: default_max_tokens(),
1225            max_spawn_depth: Some(1),    // Single-level spawning by default
1226            subagent_model: None,        // Use default_model if not specified
1227            fallback_models: Vec::new(), // No fallbacks by default
1228            max_tool_repeats: default_max_tool_repeats(), // Loop detection threshold
1229            max_tool_errors: default_max_tool_errors(), // Error spiral detection
1230            tool_retry_on_malformed: true,
1231            session_max_age: default_session_max_age(), // 30 days
1232            session_max_count: default_session_max_count(), // 500 sessions
1233            post_compaction_sections: default_post_compaction_sections(),
1234            checkpoints_enabled: true,
1235            max_checkpoints: default_max_checkpoints(),
1236            active_memory: Default::default(),
1237            permission_level: default_permission_level(),
1238            auto_approve_loopback: true,
1239        }
1240    }
1241}
1242
1243impl Default for ToolsConfig {
1244    fn default() -> Self {
1245        Self {
1246            bash_timeout_ms: default_bash_timeout(),
1247            web_fetch_max_bytes: default_web_fetch_max_bytes(),
1248            require_approval: Vec::new(),
1249            tool_output_max_chars: default_tool_output_max_chars(),
1250            log_injection_warnings: default_true(),
1251            use_content_delimiters: default_true(),
1252            web_search: None,
1253            document_loaders: None,
1254            document_max_bytes: default_document_max_bytes(),
1255            stt: None,
1256            image_max_dimension: default_image_max_dimension(),
1257            media_cache_enabled: true,
1258            media_cache_max_mb: default_media_cache_max_mb(),
1259            browser_enabled: false,
1260            browser_port: default_browser_port(),
1261            filters: std::collections::HashMap::new(),
1262        }
1263    }
1264}
1265
1266impl Default for HeartbeatConfig {
1267    fn default() -> Self {
1268        Self {
1269            enabled: default_true(),
1270            interval: default_interval(),
1271            overdue_delay: default_overdue_delay(),
1272            timeout: None,
1273            active_hours: None,
1274            timezone: None,
1275            dreaming: Default::default(),
1276            mcp_servers: Vec::new(),
1277        }
1278    }
1279}
1280
1281impl Default for MemoryConfig {
1282    fn default() -> Self {
1283        Self {
1284            backend: MemoryBackendKind::default(),
1285            workspace: default_workspace(),
1286            embedding_provider: default_embedding_provider(),
1287            embedding_model: default_embedding_model(),
1288            embedding_cache_dir: default_embedding_cache_dir(),
1289            chunk_size: default_chunk_size(),
1290            chunk_overlap: default_chunk_overlap(),
1291            paths: default_index_paths(),
1292            session_max_messages: default_session_max_messages(),
1293            session_max_chars: 0, // 0 = unlimited (preserve full content like OpenClaw)
1294            temporal_decay_lambda: 0.0, // Disabled by default
1295            gemini_api_key: None,
1296            index_sessions: false,
1297            multimodal_embeddings: false,
1298            llm_query_expansion: false,
1299            wiki_enabled: false,
1300            wiki_fresh_days: default_wiki_fresh_days(),
1301            wiki_stale_days: default_wiki_stale_days(),
1302        }
1303    }
1304}
1305
1306impl Default for ServerConfig {
1307    fn default() -> Self {
1308        Self {
1309            enabled: default_true(),
1310            port: default_port(),
1311            bind: default_bind(),
1312            auth_token: None,
1313            rate_limit: RateLimitConfig::default(),
1314            cors_origins: Vec::new(),
1315            max_request_body: default_max_request_body(),
1316            webhook_secret: None,
1317            outbox_enabled: false,
1318            outbox_max_attempts: default_outbox_max_attempts(),
1319            outbox_retain_days: default_outbox_retain_days(),
1320            tls_enabled: false,
1321            tls_cert_dir: default_tls_cert_dir(),
1322            tls_renew_threshold_days: default_tls_renew_threshold(),
1323        }
1324    }
1325}
1326
1327impl Default for RateLimitConfig {
1328    fn default() -> Self {
1329        Self {
1330            enabled: default_true(),
1331            requests_per_minute: default_requests_per_minute(),
1332            burst: default_burst(),
1333        }
1334    }
1335}
1336
1337impl Default for LoggingConfig {
1338    fn default() -> Self {
1339        Self {
1340            level: default_log_level(),
1341            path: default_log_path(),
1342            retention_days: 0, // 0 = keep forever
1343        }
1344    }
1345}
1346
1347impl Config {
1348    pub fn load() -> Result<Self> {
1349        let paths = Paths::resolve()?;
1350        paths.ensure_dirs()?;
1351        let path = paths.config_file();
1352
1353        if !path.exists() {
1354            // Create default config file on first run
1355            let config = Config {
1356                paths,
1357                ..Config::default()
1358            };
1359            config.save_with_template()?;
1360            return Ok(config);
1361        }
1362
1363        let content = fs::read_to_string(&path)?;
1364        let mut config: Config = toml::from_str(&content)?;
1365        config.paths = paths;
1366
1367        // Expand environment variables in API keys
1368        config.expand_env_vars();
1369
1370        // Apply deprecated memory.workspace override if set and LOCALGPT_WORKSPACE not set
1371        if config.memory.workspace != default_workspace()
1372            && std::env::var(LOCALGPT_WORKSPACE).is_err()
1373        {
1374            let expanded = shellexpand::tilde(&config.memory.workspace);
1375            let ws_path = PathBuf::from(expanded.to_string());
1376            if ws_path.is_absolute() {
1377                config.paths.workspace = ws_path;
1378            }
1379        }
1380
1381        Ok(config)
1382    }
1383
1384    /// Load (or create default) config with all directories rooted under `data_dir`.
1385    ///
1386    /// Mobile apps use this instead of `load()` since they don't have XDG dirs.
1387    pub fn load_from_dir(data_dir: &str) -> Result<Self> {
1388        let paths = Paths::from_root(data_dir);
1389        paths.ensure_dirs()?;
1390        let path = paths.config_file();
1391
1392        if !path.exists() {
1393            let config = Config {
1394                paths,
1395                ..Config::default()
1396            };
1397            config.save()?;
1398            return Ok(config);
1399        }
1400
1401        let content = fs::read_to_string(&path)?;
1402        let mut config: Config = toml::from_str(&content)?;
1403        config.paths = paths;
1404        config.expand_env_vars();
1405        Ok(config)
1406    }
1407
1408    pub fn save(&self) -> Result<()> {
1409        let path = self.paths.config_file();
1410
1411        // Create parent directories
1412        if let Some(parent) = path.parent() {
1413            fs::create_dir_all(parent)?;
1414        }
1415
1416        let content = toml::to_string_pretty(self)?;
1417        fs::write(&path, content)?;
1418
1419        Ok(())
1420    }
1421
1422    /// Save config with a helpful template (for first-time setup)
1423    pub fn save_with_template(&self) -> Result<()> {
1424        let path = self.paths.config_file();
1425
1426        // Create parent directories
1427        if let Some(parent) = path.parent() {
1428            fs::create_dir_all(parent)?;
1429        }
1430
1431        fs::write(&path, DEFAULT_CONFIG_TEMPLATE)?;
1432        eprintln!("Created default config at {}", path.display());
1433
1434        Ok(())
1435    }
1436
1437    pub fn config_path() -> Result<PathBuf> {
1438        let paths = Paths::resolve()?;
1439        Ok(paths.config_file())
1440    }
1441
1442    fn expand_env_vars(&mut self) {
1443        if let Some(ref mut openai) = self.providers.openai {
1444            openai.api_key = expand_env(&openai.api_key);
1445        }
1446        if let Some(ref mut xai) = self.providers.xai {
1447            xai.api_key = expand_env(&xai.api_key);
1448        }
1449        if let Some(ref mut anthropic) = self.providers.anthropic {
1450            anthropic.api_key = expand_env(&anthropic.api_key);
1451        }
1452        if let Some(ref mut telegram) = self.telegram {
1453            telegram.api_token = expand_env(&telegram.api_token);
1454        }
1455        if let Some(ref mut ws) = self.tools.web_search
1456            && let Some(ref mut brave) = ws.brave
1457        {
1458            brave.api_key = expand_env(&brave.api_key);
1459        }
1460        if let Some(ref mut ws) = self.tools.web_search
1461            && let Some(ref mut tavily) = ws.tavily
1462        {
1463            tavily.api_key = expand_env(&tavily.api_key);
1464        }
1465        if let Some(ref mut ws) = self.tools.web_search
1466            && let Some(ref mut perplexity) = ws.perplexity
1467        {
1468            perplexity.api_key = expand_env(&perplexity.api_key);
1469        }
1470        if let Some(ref mut gemini) = self.providers.gemini {
1471            gemini.api_key = expand_env(&gemini.api_key);
1472        }
1473        if let Some(ref mut openrouter) = self.providers.openrouter {
1474            openrouter.api_key = expand_env(&openrouter.api_key);
1475        }
1476        if let Some(ref mut openai_compat) = self.providers.openai_compatible {
1477            openai_compat.api_key = expand_env(&openai_compat.api_key);
1478            openai_compat.base_url = expand_env(&openai_compat.base_url);
1479        }
1480        if let Some(ref mut vertex) = self.providers.vertex {
1481            vertex.service_account_key = expand_env(&vertex.service_account_key);
1482            vertex.project_id = expand_env(&vertex.project_id);
1483        }
1484        if let Some(ref mut gemini_key) = self.memory.gemini_api_key {
1485            *gemini_key = expand_env(gemini_key);
1486        }
1487        if let Some(ref mut auth_token) = self.server.auth_token {
1488            *auth_token = expand_env(auth_token);
1489        }
1490        if let Some(ref mut webhook_secret) = self.server.webhook_secret {
1491            *webhook_secret = expand_env(webhook_secret);
1492        }
1493    }
1494
1495    pub fn get_value(&self, key: &str) -> Result<String> {
1496        let parts: Vec<&str> = key.split('.').collect();
1497
1498        match parts.as_slice() {
1499            ["agent", "default_model"] => Ok(self.agent.default_model.clone()),
1500            ["agent", "context_window"] => Ok(self.agent.context_window.to_string()),
1501            ["agent", "reserve_tokens"] => Ok(self.agent.reserve_tokens.to_string()),
1502            ["heartbeat", "enabled"] => Ok(self.heartbeat.enabled.to_string()),
1503            ["heartbeat", "interval"] => Ok(self.heartbeat.interval.clone()),
1504            ["server", "enabled"] => Ok(self.server.enabled.to_string()),
1505            ["server", "port"] => Ok(self.server.port.to_string()),
1506            ["server", "bind"] => Ok(self.server.bind.clone()),
1507            ["memory", "workspace"] => Ok(self.memory.workspace.clone()),
1508            ["logging", "level"] => Ok(self.logging.level.clone()),
1509            _ => anyhow::bail!("Unknown config key: {}", key),
1510        }
1511    }
1512
1513    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1514        let parts: Vec<&str> = key.split('.').collect();
1515
1516        match parts.as_slice() {
1517            ["agent", "default_model"] => self.agent.default_model = value.to_string(),
1518            ["agent", "context_window"] => self.agent.context_window = value.parse()?,
1519            ["agent", "reserve_tokens"] => self.agent.reserve_tokens = value.parse()?,
1520            ["heartbeat", "enabled"] => self.heartbeat.enabled = value.parse()?,
1521            ["heartbeat", "interval"] => self.heartbeat.interval = value.to_string(),
1522            ["server", "enabled"] => self.server.enabled = value.parse()?,
1523            ["server", "port"] => self.server.port = value.parse()?,
1524            ["server", "bind"] => self.server.bind = value.to_string(),
1525            ["memory", "workspace"] => self.memory.workspace = value.to_string(),
1526            ["logging", "level"] => self.logging.level = value.to_string(),
1527            _ => anyhow::bail!("Unknown config key: {}", key),
1528        }
1529
1530        Ok(())
1531    }
1532
1533    /// Get workspace path from resolved Paths.
1534    ///
1535    /// Resolution is handled by `Paths::resolve()`:
1536    /// 1. LOCALGPT_WORKSPACE env var (absolute path override)
1537    /// 2. LOCALGPT_PROFILE env var (creates workspace-{profile} under data_dir)
1538    /// 3. memory.workspace from config file (deprecated compat)
1539    /// 4. Default: data_dir/workspace
1540    pub fn workspace_path(&self) -> PathBuf {
1541        self.paths.workspace.clone()
1542    }
1543}
1544
1545fn expand_env(s: &str) -> String {
1546    if let Some(var_name) = s.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
1547        std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1548    } else if let Some(var_name) = s.strip_prefix('$') {
1549        std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1550    } else {
1551        s.to_string()
1552    }
1553}
1554
1555/// Default config template with helpful comments (used for first-time setup)
1556const DEFAULT_CONFIG_TEMPLATE: &str = r#"# LocalGPT Configuration
1557# Auto-created on first run. Edit as needed.
1558
1559[agent]
1560# Default model: claude-cli/opus, anthropic/claude-sonnet-4-5, openai/gpt-4o, xai/grok-3-mini, etc.
1561default_model = "claude-cli/opus"
1562context_window = 128000
1563reserve_tokens = 8000
1564
1565# Spawn agent (subagent) configuration
1566# max_spawn_depth = 1            # 0 = disabled, 1 = single level (default)
1567# subagent_model = "claude-cli/sonnet"  # Model for subagents (default: same as default_model)
1568
1569# Failover configuration (optional)
1570# Automatically try fallback models if primary fails with retryable errors
1571# (rate limits, server errors, timeouts). Providers tried in order.
1572# fallback_models = ["openai/gpt-4o", "ollama/llama3"]
1573
1574# Loop detection (optional)
1575# Maximum times the same tool can be called with identical arguments
1576# before detection triggers. Default: 3. Set to 0 to disable.
1577# max_tool_repeats = 3
1578
1579# Anthropic API (for anthropic/* models)
1580# [providers.anthropic]
1581# api_key = "${ANTHROPIC_API_KEY}"
1582
1583# OpenAI API (for openai/* models)
1584# [providers.openai]
1585# api_key = "${OPENAI_API_KEY}"
1586
1587# xAI API (for xai/* models)
1588# [providers.xai]
1589# api_key = "${XAI_API_KEY}"
1590# base_url = "https://api.x.ai/v1"
1591
1592# OpenAI-Compatible provider (OpenRouter, DeepSeek, Groq, vLLM, LiteLLM, etc.)
1593# [providers.openai_compatible]
1594# base_url = "https://openrouter.ai/api/v1"
1595# api_key = "${OPENROUTER_API_KEY}"
1596# # Optional extra headers (e.g., OpenRouter attribution)
1597# extra_headers = { "HTTP-Referer" = "https://localgpt.app", "X-Title" = "LocalGPT" }
1598# # Use with: localgpt chat --model openai-compat/deepseek-chat
1599
1600# Claude CLI (for claude-cli/* models, requires claude CLI installed)
1601[providers.claude_cli]
1602command = "claude"
1603
1604[heartbeat]
1605enabled = true
1606interval = "30m"
1607
1608# Maximum wall-clock time for a single heartbeat run (optional).
1609# If the heartbeat LLM turn exceeds this deadline it is cancelled and
1610# a TimedOut event is recorded so the next interval can run on schedule.
1611# Defaults to half the interval (e.g., "15m" when interval = "30m").
1612# timeout = "15m"
1613
1614# Only run during these hours (optional)
1615# [heartbeat.active_hours]
1616# start = "09:00"
1617# end = "22:00"
1618
1619[memory]
1620# Workspace directory for memory files (MEMORY.md, HEARTBEAT.md, etc.)
1621# Default: XDG data dir (~/.local/share/localgpt/workspace)
1622# Override with environment variables:
1623#   LOCALGPT_WORKSPACE=/path/to/workspace  - absolute path override
1624#   LOCALGPT_PROFILE=work                  - uses data_dir/workspace-work
1625# workspace = "~/.local/share/localgpt/workspace"
1626
1627# Session memory settings (for /new command)
1628# session_max_messages = 15    # Max messages to save (0 = unlimited)
1629# session_max_chars = 0        # Max chars per message (0 = unlimited, preserves full content)
1630
1631[server]
1632enabled = true
1633port = 31327
1634bind = "127.0.0.1"
1635# Optional bearer token for API authentication
1636# auth_token = "${LOCALGPT_AUTH_TOKEN}"
1637# Allowed CORS origins. If empty (default), allows localhost only (any port).
1638# cors_origins = ["https://myapp.example.com", "http://localhost:5173"]
1639
1640[logging]
1641level = "info"
1642
1643# Shell sandbox (kernel-enforced isolation for LLM-generated commands)
1644# [sandbox]
1645# enabled = true                        # default: true
1646# level = "auto"                        # auto | full | standard | minimal | none
1647# timeout_secs = 120                    # default: 120
1648# max_output_bytes = 1048576            # default: 1MB
1649#
1650# [sandbox.allow_paths]
1651# read = ["/data/datasets"]             # additional read-only paths
1652# write = ["/tmp/builds"]               # additional writable paths
1653#
1654# [sandbox.network]
1655# policy = "deny"                       # deny | proxy
1656
1657# Web search (optional)
1658# [tools.web_search]
1659# provider = "searxng"            # searxng | brave | tavily | perplexity | none
1660# cache_enabled = true
1661# cache_ttl = 900                 # seconds (default: 15 min)
1662# max_results = 5                 # 1-10
1663# prefer_native = true            # prefer native provider search when available
1664#
1665# [tools.web_search.searxng]
1666# base_url = "http://localhost:8080"
1667# categories = "general"
1668# language = "en"
1669#
1670# [tools.web_search.brave]
1671# api_key = "${BRAVE_API_KEY}"
1672#
1673# [tools.web_search.tavily]
1674# api_key = "${TAVILY_API_KEY}"
1675# search_depth = "basic"          # basic | advanced
1676# include_answer = true
1677#
1678# [tools.web_search.perplexity]
1679# api_key = "${PERPLEXITY_API_KEY}"
1680# model = "sonar"
1681
1682# Telegram bot (optional)
1683# [telegram]
1684# enabled = true
1685# api_token = "${TELEGRAM_BOT_TOKEN}"
1686"#;
1687
1688#[cfg(test)]
1689mod tests {
1690    use super::*;
1691
1692    #[test]
1693    fn test_mcp_server_config_enabled_default() {
1694        // Deserialize without 'enabled' field — should default to true
1695        let toml_str = r#"
1696            name = "test-server"
1697            transport = "stdio"
1698            command = "echo"
1699        "#;
1700        let config: McpServerConfig = toml::from_str(toml_str).unwrap();
1701        assert_eq!(config.name, "test-server");
1702        assert!(config.enabled);
1703    }
1704
1705    #[test]
1706    fn test_mcp_server_config_enabled_explicit_false() {
1707        let toml_str = r#"
1708            name = "disabled-server"
1709            transport = "stdio"
1710            command = "echo"
1711            enabled = false
1712        "#;
1713        let config: McpServerConfig = toml::from_str(toml_str).unwrap();
1714        assert_eq!(config.name, "disabled-server");
1715        assert!(!config.enabled);
1716    }
1717
1718    #[test]
1719    fn test_mcp_server_config_enabled_explicit_true() {
1720        let toml_str = r#"
1721            name = "enabled-server"
1722            transport = "stdio"
1723            command = "echo"
1724            enabled = true
1725        "#;
1726        let config: McpServerConfig = toml::from_str(toml_str).unwrap();
1727        assert!(config.enabled);
1728    }
1729
1730    #[test]
1731    fn test_mcp_server_config_roundtrip() {
1732        let config = McpServerConfig {
1733            name: "roundtrip".to_string(),
1734            transport: "stdio".to_string(),
1735            command: Some("test-cmd".to_string()),
1736            args: vec!["--flag".to_string()],
1737            env: std::collections::HashMap::new(),
1738            url: None,
1739            enabled: false,
1740        };
1741        let serialized = toml::to_string(&config).unwrap();
1742        assert!(serialized.contains("enabled = false"));
1743
1744        let deserialized: McpServerConfig = toml::from_str(&serialized).unwrap();
1745        assert_eq!(deserialized.name, "roundtrip");
1746        assert!(!deserialized.enabled);
1747    }
1748
1749    #[test]
1750    fn test_mcp_config_with_mixed_enabled() {
1751        let toml_str = r#"
1752            [[servers]]
1753            name = "active"
1754            transport = "stdio"
1755            command = "echo"
1756            enabled = true
1757
1758            [[servers]]
1759            name = "inactive"
1760            transport = "stdio"
1761            command = "echo"
1762            enabled = false
1763
1764            [[servers]]
1765            name = "default"
1766            transport = "sse"
1767            url = "http://localhost:8080"
1768        "#;
1769        let config: McpConfig = toml::from_str(toml_str).unwrap();
1770        assert_eq!(config.servers.len(), 3);
1771        assert!(config.servers[0].enabled);
1772        assert!(!config.servers[1].enabled);
1773        assert!(config.servers[2].enabled); // default
1774    }
1775
1776    #[test]
1777    fn test_mcp_filter_servers_empty_allows_all() {
1778        let config = McpConfig {
1779            servers: vec![
1780                McpServerConfig {
1781                    name: "server-a".to_string(),
1782                    ..Default::default()
1783                },
1784                McpServerConfig {
1785                    name: "server-b".to_string(),
1786                    ..Default::default()
1787                },
1788            ],
1789        };
1790        let filtered = config.filter_servers(&[]);
1791        assert_eq!(filtered.len(), 2);
1792    }
1793
1794    #[test]
1795    fn test_mcp_filter_servers_allowlist() {
1796        let config = McpConfig {
1797            servers: vec![
1798                McpServerConfig {
1799                    name: "memory-server".to_string(),
1800                    ..Default::default()
1801                },
1802                McpServerConfig {
1803                    name: "bash-server".to_string(),
1804                    ..Default::default()
1805                },
1806                McpServerConfig {
1807                    name: "web-server".to_string(),
1808                    ..Default::default()
1809                },
1810            ],
1811        };
1812        let filtered = config.filter_servers(&["memory-server".to_string()]);
1813        assert_eq!(filtered.len(), 1);
1814        assert_eq!(filtered[0].name, "memory-server");
1815    }
1816
1817    #[test]
1818    fn test_mcp_filter_servers_case_insensitive() {
1819        let config = McpConfig {
1820            servers: vec![McpServerConfig {
1821                name: "Memory-Server".to_string(),
1822                ..Default::default()
1823            }],
1824        };
1825        let filtered = config.filter_servers(&["memory-server".to_string()]);
1826        assert_eq!(filtered.len(), 1);
1827    }
1828}