use anyhow::{Context, Result};
use sapphire_workspace::SyncConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub matrix: Option<MatrixConfig>,
#[serde(default)]
pub discord: Option<DiscordConfig>,
pub anthropic: AnthropicConfig,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default)]
pub serve: Option<ServeConfig>,
pub workspace_dir: Option<String>,
pub sessions_dir: Option<String>,
#[serde(default)]
pub day_boundary_hour: u8,
#[serde(default)]
pub session_policy: SessionPolicy,
#[serde(default)]
pub rooms: HashMap<String, RoomConfig>,
#[serde(default = "default_true")]
pub daily_log_enabled: bool,
#[serde(default = "default_true")]
pub memory_compaction_enabled: bool,
#[serde(default = "default_true")]
pub heartbeat_enabled: bool,
#[serde(default)]
pub standby_mode: bool,
#[serde(default)]
pub sync: Option<SyncConfig>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionPolicy {
#[default]
Reset,
Compact,
None,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct RoomConfig {
pub session_policy: Option<SessionPolicy>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ServeConfig {
#[serde(default = "default_serve_host")]
pub host: String,
#[serde(default = "default_serve_port")]
pub port: u16,
}
fn default_serve_host() -> String {
"127.0.0.1".to_string()
}
fn default_serve_port() -> u16 {
9000
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ToolsConfig {
pub tavily_api_key: Option<String>,
#[serde(default)]
pub mcp_servers: Vec<McpServerConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(flatten)]
pub transport: McpTransportConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum McpTransportConfig {
#[serde(rename = "http")]
Http {
url: String,
#[serde(default)]
api_key: Option<String>,
},
#[serde(rename = "stdio")]
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: std::collections::HashMap<String, String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MatrixConfig {
pub homeserver: String,
pub access_token: String,
pub user_id: String,
pub device_id: String,
#[serde(default, alias = "room_id", deserialize_with = "deserialize_room_ids")]
pub room_ids: Vec<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
pub recovery_key: Option<String>,
pub state_dir: Option<String>,
}
fn deserialize_room_ids<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(s) => Ok(vec![s]),
OneOrMany::Many(v) => Ok(v),
}
}
impl MatrixConfig {
pub fn primary_room_id(&self) -> Option<&str> {
self.room_ids.first().map(|s| s.as_str())
}
pub fn resolved_state_dir(&self) -> PathBuf {
if let Some(dir) = &self.state_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else if let Some(dirs) = directories::ProjectDirs::from("", "", "sapphire-agent") {
dirs.data_local_dir().join("matrix")
} else {
PathBuf::from(".sapphire-agent/matrix")
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DiscordConfig {
pub bot_token: String,
#[serde(default)]
pub channel_ids: Vec<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AnthropicConfig {
pub api_key: String,
#[serde(default = "default_model")]
pub model: String,
pub light_model: Option<String>,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
pub system_prompt: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompressionConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_context_window")]
pub context_window: usize,
#[serde(default = "default_compression_threshold")]
pub threshold: f64,
#[serde(default = "default_preserve_recent")]
pub preserve_recent: usize,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
enabled: true,
context_window: default_context_window(),
threshold: default_compression_threshold(),
preserve_recent: default_preserve_recent(),
}
}
}
fn default_model() -> String {
"claude-opus-4-6".to_string()
}
fn default_max_tokens() -> u32 {
8192
}
fn default_context_window() -> usize {
200_000
}
fn default_compression_threshold() -> f64 {
0.80
}
fn default_preserve_recent() -> usize {
20
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Config =
toml::from_str(&content).with_context(|| "Failed to parse config file")?;
Ok(config)
}
pub fn resolved_workspace_dir(&self, config_path: &Path) -> PathBuf {
if let Some(dir) = &self.workspace_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
}
pub fn resolved_sessions_dir(&self, workspace_dir: &Path) -> PathBuf {
if let Some(dir) = &self.sessions_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else {
workspace_dir.join("sessions")
}
}
pub fn session_policy_for(&self, room_id: &str) -> SessionPolicy {
self.rooms
.get(room_id)
.and_then(|r| r.session_policy)
.unwrap_or(self.session_policy)
}
pub fn default_path() -> PathBuf {
if let Some(dirs) = directories::ProjectDirs::from("", "", "sapphire-agent") {
dirs.config_dir().join("config.toml")
} else {
PathBuf::from("config.toml")
}
}
}