Ryanhub - file viewer
filename: config/config.go
branch: main
back to repo
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
}