package config
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
LLM LLMConfig `yaml:"llm"`
Agent AgentConfig `yaml:"agent"`
Server ServerConfig `yaml:"server"`
Automation AutomationConfig `yaml:"automation"`
Tools ToolsBlock `yaml:"tools"`
Memory MemoryConfig `yaml:"memory"`
}
type LLMConfig struct {
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
APIKey string `yaml:"api_key"`
}
type AgentConfig struct {
SystemPrompt string `yaml:"system_prompt"`
ContextWindowChars int `yaml:"context_window_chars"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
}
type AutomationConfig struct {
MorningBriefingHour int `yaml:"morning_briefing_hour"`
AfterDinnerRecapHour int `yaml:"after_dinner_recap_hour"`
Timezone string `yaml:"timezone,omitempty"`
}
type MemoryConfig struct {
DBPath string `yaml:"db_path"`
}
type ToolsBlock struct {
Weather WeatherToolConfig `yaml:"weather"`
News NewsToolConfig `yaml:"news"`
Tasks TasksToolConfig `yaml:"tasks"`
Memory MemoryToolConfig `yaml:"memory"`
Code CodeToolConfig `yaml:"code"`
}
type WeatherToolConfig struct {
Enabled bool `yaml:"enabled"`
Lat float64 `yaml:"lat"`
Lon float64 `yaml:"lon"`
}
type NewsToolConfig struct {
Enabled bool `yaml:"enabled"`
Feeds []string `yaml:"feeds"`
Preferences string `yaml:"preferences"`
StrictPreferences bool `yaml:"strict_preferences"`
// Keep backward compatibility with misspelled config key.
Preferrences string `yaml:"preferrences"`
}
type TasksToolConfig struct {
Enabled bool `yaml:"enabled"`
}
type MemoryToolConfig struct {
Enabled bool `yaml:"enabled"`
}
type CodeToolConfig struct {
Enabled bool `yaml:"enabled"`
Workspace string `yaml:"workspace"`
}
// Load reads and validates YAML from path.
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(b, &cfg); err != nil {
return nil, fmt.Errorf("parse yaml: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) Validate() error {
if c.LLM.BaseURL == "" {
return errors.New("config: llm.base_url is required")
}
if u, err := url.Parse(c.LLM.BaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: llm.base_url must be a valid absolute URL (got %q)", c.LLM.BaseURL)
}
if c.LLM.Model == "" {
return errors.New("config: llm.model is required")
}
if c.Agent.SystemPrompt == "" {
return errors.New("config: agent.system_prompt is required")
}
if c.Agent.ContextWindowChars < 0 {
return errors.New("config: agent.context_window_chars must be >= 0")
}
if c.Server.Host == "" {
return errors.New("config: server.host is required")
}
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return fmt.Errorf("config: server.port must be 1-65535 (got %d)", c.Server.Port)
}
if c.Automation.MorningBriefingHour < 0 || c.Automation.MorningBriefingHour > 23 {
return fmt.Errorf("config: automation.morning_briefing_hour must be 0-23 (got %d)", c.Automation.MorningBriefingHour)
}
if c.Automation.AfterDinnerRecapHour < 0 || c.Automation.AfterDinnerRecapHour > 23 {
return fmt.Errorf("config: automation.after_dinner_recap_hour must be 0-23 (got %d)", c.Automation.AfterDinnerRecapHour)
}
if c.Tools.Weather.Enabled {
if c.Tools.Weather.Lat < -90 || c.Tools.Weather.Lat > 90 || c.Tools.Weather.Lon < -180 || c.Tools.Weather.Lon > 180 {
return errors.New("config: tools.weather lat/lon out of range")
}
}
if c.Tools.News.Enabled {
if c.Tools.News.Preferences == "" && c.Tools.News.Preferrences != "" {
c.Tools.News.Preferences = c.Tools.News.Preferrences
}
if len(c.Tools.News.Feeds) == 0 {
return errors.New("config: tools.news.enabled requires non-empty feeds")
}
for _, f := range c.Tools.News.Feeds {
if strings.TrimSpace(f) == "" {
return errors.New("config: tools.news.feeds contains an empty URL")
}
}
}
if c.Tools.Tasks.Enabled || c.Tools.Memory.Enabled {
if strings.TrimSpace(c.Memory.DBPath) == "" {
return errors.New("config: tools.tasks.enabled/tools.memory.enabled require memory.db_path")
}
}
if c.Tools.Code.Enabled {
if strings.TrimSpace(c.Tools.Code.Workspace) == "" {
return errors.New("config: tools.code.enabled requires tools.code.workspace")
}
abs, err := filepath.Abs(c.Tools.Code.Workspace)
if err != nil {
return fmt.Errorf("config: tools.code.workspace: %w", err)
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
return fmt.Errorf("config: tools.code.workspace must be an existing directory (got %q)", c.Tools.Code.Workspace)
}
}
return nil
}