from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
ollama_host: str = "http://localhost:11434"
ollama_api_key: str = ""
ollama_default_model: str = "gemma4:31b-cloud"
ollama_num_ctx: int = 65536
ollama_think: bool = True
ollama_request_timeout: int = 30
# Embedding model for memory vector search (Ollama API)
# When embedding_ollama_host is empty, falls back to ollama_host.
embedding_ollama_host: str = ""
embedding_ollama_api_key: str = ""
embedding_model: str = "nomic-embed-text:latest"
embedding_dimensions: int = 768
openai_api_key: str = ""
anthropic_api_key: str = ""
# Web search fallbacks (used when DuckDuckGo returns no results)
# Brave Search API: free tier = 2000 req/month — https://brave.com/search/api/
brave_search_api_key: str = ""
# SearXNG: self-hosted meta-search, e.g. "http://localhost:8888"
searxng_url: str = ""
# Filesystem tool: comma-separated allowed root paths
fs_allowed_paths: str = "*"
# Terminal tool: "*" = allow all commands (recommended for local use)
# or comma-separated list of allowed executables, e.g. "ls,cat,git"
terminal_allowed_commands: str = "*"
# SSH tool: path to JSON file with named connections
ssh_hosts_file: str = "ssh_hosts.json"
# Ollama multi-backend fallback: path to JSON file with server list [{host, api_key?}, ...]
# When set, overrides ollama_host / ollama_api_key and enables server+model fallback.
ollama_backends_file: str = ""
# Database
# Set DATABASE_URL to use PostgreSQL: postgresql://user:pass@host:port/db
# Leave empty to fall back to SQLite (db_path).
database_url: str = ""
db_path: str = "navi.db"
log_level: str = "INFO"
# Directory for user-defined tools (auto-discovered at startup)
tools_dir: str = "tools"
# Directory for user-defined context providers (auto-discovered at startup)
context_providers_dir: str = "context_providers"
# Session file uploads
session_files_dir: str = "session_files"
session_files_max_size_mb: int = 200
# Public base URL used by share_file tool to build download links.
# Change if the server is behind a reverse proxy or runs on a different port.
public_url: str = "http://localhost:8000"
# Gmail IMAP/SMTP (App Password auth)
gmail_address: str = ""
gmail_app_password: str = ""
# LLM call timeouts
# complete() is non-streaming (planning, compression) — blocked until full response
llm_complete_timeout: int = 120
# stream_complete(): how long to wait for the FIRST token (prefill phase)
# Large contexts can take 60-90s to prefill; 180s is a safe upper bound
llm_stream_first_chunk_timeout: int = 180
# stream_complete(): max gap between any two subsequent tokens
llm_stream_chunk_timeout: int = 60
# Context compression
context_compression_enabled: bool = True
context_compression_threshold: float = 0.70 # trigger at 70% of ollama_num_ctx
context_keep_recent: int = 8 # conversational turns to keep verbatim
context_summary_temperature: float = 0.3
context_summary_max_tokens: int = 3000 # max output tokens for the summary LLM call
output_reserve_tokens: int = 2048 # headroom reserved for model response in context checks
# Global personality prompt prepended to every agent's system prompt.
# Multi-line values don't survive .env parsing reliably, so prefer
# navi_persona_file (path to a plain .txt file) over inline navi_persona.
navi_persona: str = ""
navi_persona_file: str = ""
@model_validator(mode="after")
def _load_persona_from_file(self) -> "Settings":
if not self.navi_persona and self.navi_persona_file:
try:
self.navi_persona = Path(self.navi_persona_file).read_text(encoding="utf-8").strip()
except Exception:
pass
return self
@property
def fs_allowed_paths_list(self) -> list[str]:
return [p.strip() for p in self.fs_allowed_paths.split(",") if p.strip()]
@property
def terminal_allowed_commands_list(self) -> list[str]:
return [c.strip() for c in self.terminal_allowed_commands.split(",") if c.strip()]
settings = Settings()