use crate::errors::{err, ErrorCode};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub schema_version: u32,
pub record_format: String,
pub team: TeamConfig,
pub index: IndexConfig,
pub retrieval: RetrievalConfig,
pub memory: MemoryConfig,
pub archive: ArchiveConfig,
pub backfill: BackfillConfig,
pub stats: StatsConfig,
pub output: OutputConfig,
pub audit: AuditConfig,
pub redaction: RedactionConfig,
pub integrations: IntegrationsConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
schema_version: 1,
record_format: "mrf1".into(),
team: TeamConfig::default(),
index: IndexConfig::default(),
retrieval: RetrievalConfig::default(),
memory: MemoryConfig::default(),
archive: ArchiveConfig::default(),
backfill: BackfillConfig::default(),
stats: StatsConfig::default(),
output: OutputConfig::default(),
audit: AuditConfig::default(),
redaction: RedactionConfig::default(),
integrations: IntegrationsConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TeamConfig {
pub baseline_ref: String,
pub remote: String,
pub require_current_for_context: bool,
pub max_fetch_age_seconds: u64,
pub require_change_record_in_ci: bool,
pub auto_fetch_on_mcp_start: bool,
pub auto_fetch_interval_seconds: u64,
}
impl Default for TeamConfig {
fn default() -> Self {
Self {
baseline_ref: "refs/remotes/origin/main".into(),
remote: "origin".into(),
require_current_for_context: false,
max_fetch_age_seconds: 86_400,
require_change_record_in_ci: true,
auto_fetch_on_mcp_start: true,
auto_fetch_interval_seconds: 900,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IndexConfig {
pub watch: bool,
pub roots: Vec<String>,
pub max_file_bytes: u64,
pub include_untracked: bool,
pub respect_gitignore: bool,
pub languages: Vec<String>,
pub max_files_warning: u64,
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
watch: true,
roots: vec![".".into()],
max_file_bytes: 2_000_000,
include_untracked: true,
respect_gitignore: true,
languages: vec![
"typescript".into(),
"javascript".into(),
"python".into(),
"rust".into(),
"go".into(),
"lua".into(),
],
max_files_warning: 100_000,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RetrievalConfig {
pub default_token_budget: u32,
pub max_token_budget: u32,
pub graph_hops: u32,
pub max_results_per_type: u32,
pub semantic_fallback: bool,
}
impl Default for RetrievalConfig {
fn default() -> Self {
Self {
default_token_budget: 1_000,
max_token_budget: 8_000,
graph_hops: 1,
max_results_per_type: 8,
semantic_fallback: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryConfig {
pub summary_max_chars: u32,
pub details_max_chars: u32,
pub strict_immutability: bool,
pub canonical_format: String,
pub duplicate_key_warn_score: f64,
pub duplicate_key_block_score: f64,
pub max_records_per_session_warning: u32,
pub max_records_per_session_without_justification: u32,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
summary_max_chars: 500,
details_max_chars: 12_000,
strict_immutability: true,
canonical_format: "mly".into(),
duplicate_key_warn_score: 0.72,
duplicate_key_block_score: 0.88,
max_records_per_session_warning: 5,
max_records_per_session_without_justification: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ArchiveConfig {
pub enabled: bool,
pub remote: String,
pub auto_fetch_details: bool,
pub retain_epoch_summaries: bool,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
enabled: false,
remote: "origin".into(),
auto_fetch_details: false,
retain_epoch_summaries: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BackfillConfig {
pub include_docs_by_default: bool,
pub max_candidates_per_cluster: u32,
pub providers: BackfillProviders,
}
impl Default for BackfillConfig {
fn default() -> Self {
Self {
include_docs_by_default: false,
max_candidates_per_cluster: 5,
providers: BackfillProviders::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BackfillProviders {
pub github: ProviderToggle,
pub llm: LlmProvider,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProviderToggle {
pub enabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LlmProvider {
pub enabled: bool,
pub send_source_bodies: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StatsConfig {
pub enabled: bool,
pub retain_days: u32,
pub store_task_text: bool,
}
impl Default for StatsConfig {
fn default() -> Self {
Self {
enabled: true,
retain_days: 180,
store_task_text: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct OutputConfig {
pub default_context_format: String,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
default_context_format: "mcf".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AuditConfig {
pub level: String,
pub write_git_refs: bool,
pub remote: String,
pub auto_push: bool,
pub max_event_bytes: u64,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
level: "standard".into(),
write_git_refs: true,
remote: "origin".into(),
auto_push: false,
max_event_bytes: 262_144,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RedactionConfig {
pub enabled: bool,
pub patterns: Vec<String>,
}
impl Default for RedactionConfig {
fn default() -> Self {
Self {
enabled: true,
patterns: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IntegrationsConfig {
pub codex: bool,
pub claude: bool,
}
impl Default for IntegrationsConfig {
fn default() -> Self {
Self {
codex: true,
claude: true,
}
}
}
pub const CONFIG_REL_PATH: &str = ".memlay/config.toml";
impl Config {
pub fn load(repo_root: &Path) -> Result<Config> {
let path = repo_root.join(CONFIG_REL_PATH);
if !path.exists() {
return Err(err(
ErrorCode::NotInitialized,
format!("{CONFIG_REL_PATH} not found; run 'memlay init' first"),
));
}
let text = std::fs::read_to_string(&path).map_err(|e| {
err(
ErrorCode::InvalidConfig,
format!("cannot read {CONFIG_REL_PATH}: {e}"),
)
})?;
let cfg: Config = toml::from_str(&text)
.map_err(|e| err(ErrorCode::InvalidConfig, format!("{CONFIG_REL_PATH}: {e}")))?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<()> {
if self.schema_version != 1 {
return Err(err(
ErrorCode::InvalidConfig,
format!(
"unsupported schema_version {}; this build supports 1",
self.schema_version
),
));
}
if self.record_format != "mrf1" {
return Err(err(
ErrorCode::InvalidConfig,
format!(
"unsupported record_format '{}'; this build supports 'mrf1'",
self.record_format
),
));
}
if !(0.0..=1.0).contains(&self.memory.duplicate_key_warn_score)
|| !(0.0..=1.0).contains(&self.memory.duplicate_key_block_score)
{
return Err(err(
ErrorCode::InvalidConfig,
"memory.duplicate_key_*_score values must be between 0.0 and 1.0",
));
}
if self.retrieval.default_token_budget < 200 {
return Err(err(
ErrorCode::InvalidConfig,
"retrieval.default_token_budget must be at least 200",
));
}
Ok(())
}
pub fn default_toml() -> String {
let mut out = String::from(
"# Memlay project configuration. Committed alongside the code it describes.\n# All values shown are the documented defaults (PRD section 19).\n\n",
);
out.push_str(
&toml::to_string_pretty(&Config::default()).expect("default config serializes"),
);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_round_trips() {
let text = Config::default_toml();
let cfg: Config = toml::from_str(&text).unwrap();
cfg.validate().unwrap();
assert_eq!(cfg.team.baseline_ref, "refs/remotes/origin/main");
assert!(!cfg.archive.enabled);
}
#[test]
fn unknown_field_rejected_with_context() {
let e = toml::from_str::<Config>("nonsense = 1\n")
.unwrap_err()
.to_string();
assert!(e.contains("nonsense"), "{e}");
}
#[test]
fn invalid_scores_rejected() {
let mut cfg = Config::default();
cfg.memory.duplicate_key_block_score = 1.5;
assert!(cfg.validate().is_err());
}
}