use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::models::Tier;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EmbeddingModel {
MiniLmL6V2,
NomicEmbedV15,
}
impl std::str::FromStr for EmbeddingModel {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"mini_lm_l6_v2" => Ok(Self::MiniLmL6V2),
"nomic_embed_v15" => Ok(Self::NomicEmbedV15),
other => Err(format!(
"unknown embedding_model {other:?}: expected one of \
\"mini_lm_l6_v2\", \"nomic_embed_v15\""
)),
}
}
}
impl EmbeddingModel {
pub fn dim(self) -> usize {
match self {
Self::MiniLmL6V2 => 384,
Self::NomicEmbedV15 => 768,
}
}
pub fn hf_model_id(&self) -> &str {
match self {
Self::MiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
Self::NomicEmbedV15 => "nomic-ai/nomic-embed-text-v1.5",
}
}
fn canonical_aliases(self) -> &'static [&'static str] {
match self {
Self::MiniLmL6V2 => MINILM_CANONICAL_ALIASES,
Self::NomicEmbedV15 => NOMIC_CANONICAL_ALIASES,
}
}
#[must_use]
pub fn from_canonical_id(s: &str) -> Option<Self> {
let needle = s.trim();
if needle.is_empty() {
return None;
}
[Self::MiniLmL6V2, Self::NomicEmbedV15]
.into_iter()
.find(|model| {
model
.canonical_aliases()
.iter()
.any(|alias| alias.eq_ignore_ascii_case(needle))
})
}
}
const MINILM_CANONICAL_ALIASES: &[&str] = &[
"mini_lm_l6_v2",
"sentence-transformers/all-MiniLM-L6-v2",
"all-MiniLM-L6-v2",
"all-minilm",
];
const NOMIC_CANONICAL_ALIASES: &[&str] = &[
"nomic_embed_v15",
"nomic-embed-text-v1.5",
"nomic-embed-text",
"nomic-ai/nomic-embed-text-v1.5",
];
pub mod config_keys {
pub const ARCHIVE_MAX_DAYS: &str = "archive_max_days";
pub const ARCHIVE_ON_GC: &str = "archive_on_gc";
pub const AUTO_TAG_MODEL: &str = "auto_tag_model";
pub const CROSS_ENCODER: &str = "cross_encoder";
pub const DEFAULT_NAMESPACE: &str = "default_namespace";
pub const EMBEDDING_MODEL: &str = "embedding_model";
pub const MAX_MEMORY_MB: &str = "max_memory_mb";
pub const OLLAMA_URL: &str = "ollama_url";
pub const SECTION_EMBEDDINGS: &str = "embeddings";
}
#[must_use]
pub fn default_tier_llm_model() -> &'static str {
backend_default_model(crate::llm::BACKEND_OLLAMA)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FeatureTier {
Keyword,
Semantic,
Smart,
Autonomous,
}
impl FeatureTier {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"keyword" => Some(Self::Keyword),
"semantic" => Some(Self::Semantic),
"smart" => Some(Self::Smart),
"autonomous" => Some(Self::Autonomous),
_ => None,
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Keyword => "keyword",
Self::Semantic => "semantic",
Self::Smart => "smart",
Self::Autonomous => "autonomous",
}
}
pub fn config(self) -> TierConfig {
match self {
Self::Keyword => TierConfig {
tier: self,
embedding_model: None,
llm_model: None,
cross_encoder: false,
max_memory_mb: 0,
},
Self::Semantic => TierConfig {
tier: self,
embedding_model: Some(EmbeddingModel::MiniLmL6V2),
llm_model: None,
cross_encoder: false,
max_memory_mb: 256,
},
Self::Smart => TierConfig {
tier: self,
embedding_model: Some(EmbeddingModel::NomicEmbedV15),
llm_model: Some(default_tier_llm_model().to_string()),
cross_encoder: false,
max_memory_mb: 1024,
},
Self::Autonomous => TierConfig {
tier: self,
embedding_model: Some(EmbeddingModel::NomicEmbedV15),
llm_model: Some(default_tier_llm_model().to_string()),
cross_encoder: true,
max_memory_mb: 4096,
},
}
}
#[allow(dead_code)]
pub fn from_memory_budget(mb: usize) -> Self {
if mb >= 4096 {
Self::Autonomous
} else if mb >= 1024 {
Self::Smart
} else if mb >= 256 {
Self::Semantic
} else {
Self::Keyword
}
}
}
impl std::fmt::Display for FeatureTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TierConfig {
pub tier: FeatureTier,
pub embedding_model: Option<EmbeddingModel>,
pub llm_model: Option<String>,
pub cross_encoder: bool,
pub max_memory_mb: usize,
}
impl TierConfig {
pub fn capabilities(&self) -> Capabilities {
self.capabilities_with_resolved(&ResolvedModels::from_tier_preset(self))
}
#[must_use]
pub fn capabilities_with_resolved(&self, models: &ResolvedModels) -> Capabilities {
let has_embeddings = self.embedding_model.is_some();
let has_llm = self.llm_model.is_some();
Capabilities {
schema_version: "2".to_string(),
tier: self.tier.as_str().to_string(),
version: crate::PKG_VERSION.to_string(),
features: CapabilityFeatures {
keyword_search: true,
semantic_search: has_embeddings,
hybrid_recall: has_embeddings,
query_expansion: has_llm,
auto_consolidation: has_llm,
auto_tagging: has_llm,
contradiction_analysis: has_llm,
cross_encoder_reranking: self.cross_encoder,
memory_reflection: PlannedFeature {
planned: false,
version: "v0.7.0".to_string(),
enabled: true,
},
embedder_loaded: false,
recall_mode_active: RecallMode::Disabled,
reranker_active: RerankerMode::Off,
reflection_boost: ReflectionBoostReport::default(),
},
models: build_capability_models(self, models),
permissions: CapabilityPermissions {
mode: active_permissions_mode().as_str().to_string(),
active_rules: 0,
rule_summary: Vec::new(),
inheritance: Some("enforced".to_string()),
decision_counts: Some(permissions_decision_counts()),
},
hooks: CapabilityHooks::default(),
compaction: CapabilityCompaction::planned(),
approval: CapabilityApproval {
pending_requests: 0,
deferred_audit_dlq_size: 0,
},
transcripts: CapabilityTranscripts::shipped(),
hnsw: CapabilityHnsw::default(),
kg_backend: None,
memory_kinds: default_memory_kinds(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capabilities {
pub schema_version: String,
pub tier: String,
pub version: String,
pub features: CapabilityFeatures,
pub models: CapabilityModels,
pub permissions: CapabilityPermissions,
pub hooks: CapabilityHooks,
pub compaction: CapabilityCompaction,
pub approval: CapabilityApproval,
pub transcripts: CapabilityTranscripts,
#[serde(default)]
pub hnsw: CapabilityHnsw,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kg_backend: Option<String>,
#[serde(default = "default_memory_kinds")]
pub memory_kinds: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct ConfidenceTierThresholds {
pub confirmed: f64,
pub likely: f64,
pub ambiguous: f64,
}
impl Default for ConfidenceTierThresholds {
fn default() -> Self {
Self {
confirmed: 0.95,
likely: 0.7,
ambiguous: 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecallMode {
Hybrid,
KeywordOnly,
Degraded,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RerankerMode {
Neural,
LexicalFallback,
Off,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlannedFeature {
pub planned: bool,
pub version: String,
pub enabled: bool,
}
impl PlannedFeature {
#[must_use]
pub fn planned(version: &str) -> Self {
Self {
planned: true,
version: version.to_string(),
enabled: false,
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityFeatures {
pub keyword_search: bool,
pub semantic_search: bool,
pub hybrid_recall: bool,
pub query_expansion: bool,
pub auto_consolidation: bool,
pub auto_tagging: bool,
pub contradiction_analysis: bool,
pub cross_encoder_reranking: bool,
pub memory_reflection: PlannedFeature,
#[serde(default)]
pub embedder_loaded: bool,
#[serde(default = "default_recall_mode")]
pub recall_mode_active: RecallMode,
#[serde(default = "default_reranker_mode")]
pub reranker_active: RerankerMode,
#[serde(default = "default_reflection_boost")]
pub reflection_boost: ReflectionBoostReport,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ReflectionBoostReport {
pub boost: f32,
pub per_depth_increment: f32,
pub max_depth_cap: u32,
}
impl Default for ReflectionBoostReport {
fn default() -> Self {
Self {
boost: crate::reranker::DEFAULT_REFLECTION_BOOST,
per_depth_increment: crate::reranker::DEFAULT_REFLECTION_PER_DEPTH_INCREMENT,
max_depth_cap: crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP,
}
}
}
impl From<&crate::reranker::ReflectionBoostConfig> for ReflectionBoostReport {
fn from(cfg: &crate::reranker::ReflectionBoostConfig) -> Self {
Self {
boost: cfg.boost,
per_depth_increment: cfg.per_depth_increment,
max_depth_cap: cfg.max_depth_cap,
}
}
}
fn default_reflection_boost() -> ReflectionBoostReport {
ReflectionBoostReport::default()
}
fn default_memory_kinds() -> Vec<String> {
vec!["observation".to_string(), "reflection".to_string()]
}
fn default_recall_mode() -> RecallMode {
RecallMode::Disabled
}
fn default_reranker_mode() -> RerankerMode {
RerankerMode::Off
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityModels {
pub embedding: String,
pub embedding_dim: usize,
pub llm: String,
pub cross_encoder: String,
}
#[must_use]
pub fn build_capability_models(tier: &TierConfig, models: &ResolvedModels) -> CapabilityModels {
let llm = if models.llm.model.is_empty() {
"none".to_string()
} else if models.llm.is_ollama_native() {
models.llm.model.clone()
} else {
models.llm.display_label()
};
let embedding = if tier.embedding_model.is_none() {
"none".to_string()
} else {
models.embeddings.model.clone()
};
let embedding_dim = if tier.embedding_model.is_none() {
0
} else {
models.embeddings.embedding_dim.map_or_else(
|| tier.embedding_model.map_or(0, EmbeddingModel::dim),
|d| d as usize,
)
};
let cross_encoder = if models.reranker.enabled || tier.cross_encoder {
models.reranker.model.clone()
} else {
"none".to_string()
};
CapabilityModels {
embedding,
embedding_dim,
llm,
cross_encoder,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilityPermissions {
pub mode: String,
pub active_rules: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rule_summary: Vec<String>,
#[serde(default)]
pub inheritance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_counts: Option<PermissionsDecisionCounts>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityHooks {
pub registered_count: usize,
#[serde(default = "default_webhook_events")]
pub webhook_events: Vec<String>,
#[serde(default = "default_hook_events_count")]
pub hook_events_count: usize,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub auto_export_spawn_failed_total: u64,
}
pub const HOOK_EVENTS_COUNT: usize = 25;
fn default_hook_events_count() -> usize {
HOOK_EVENTS_COUNT
}
impl Default for CapabilityHooks {
fn default() -> Self {
Self {
registered_count: 0,
webhook_events: default_webhook_events(),
hook_events_count: HOOK_EVENTS_COUNT,
auto_export_spawn_failed_total: 0,
}
}
}
fn default_webhook_events() -> Vec<String> {
use crate::mcp::registry::tool_names as tn;
vec![
tn::MEMORY_STORE.to_string(),
tn::MEMORY_PROMOTE.to_string(),
tn::MEMORY_DELETE.to_string(),
crate::subscriptions::webhook_events::MEMORY_LINK_CREATED.to_string(),
crate::subscriptions::webhook_events::MEMORY_LINK_INVALIDATED.to_string(),
crate::subscriptions::webhook_events::MEMORY_CONSOLIDATED.to_string(),
crate::subscriptions::webhook_events::APPROVAL_REQUESTED.to_string(),
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityCompaction {
#[serde(flatten)]
pub status: PlannedFeature,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_minutes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run_stats: Option<serde_json::Value>,
}
impl CapabilityCompaction {
#[must_use]
pub fn planned() -> Self {
Self {
status: PlannedFeature::planned("v0.8+"),
interval_minutes: None,
last_run_at: None,
last_run_stats: None,
}
}
}
impl Default for CapabilityCompaction {
fn default() -> Self {
Self::planned()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilityApproval {
pub pending_requests: usize,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub deferred_audit_dlq_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityTranscripts {
#[serde(flatten)]
pub status: PlannedFeature,
#[serde(default, skip_serializing_if = "is_zero_usize")]
pub total_count: usize,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub total_size_mb: u64,
}
impl CapabilityTranscripts {
#[must_use]
pub fn planned() -> Self {
Self {
status: PlannedFeature::planned("v0.7+"),
total_count: 0,
total_size_mb: 0,
}
}
#[must_use]
pub fn shipped() -> Self {
Self {
status: PlannedFeature {
planned: false,
version: crate::PKG_VERSION.to_string(),
enabled: false,
},
total_count: 0,
total_size_mb: 0,
}
}
}
impl Default for CapabilityTranscripts {
fn default() -> Self {
Self::planned()
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_usize(n: &usize) -> bool {
*n == 0
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_u64(n: &u64) -> bool {
*n == 0
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilityHnsw {
pub evictions_total: u64,
pub evicted_recently: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilityReflection {
pub implemented: bool,
pub depth_bounded: bool,
pub max_default: u32,
pub attestation: String,
pub curator_mode: String,
}
impl CapabilityReflection {
#[must_use]
pub fn current() -> Self {
Self {
implemented: true,
depth_bounded: true,
max_default: crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP,
attestation: "Ed25519".to_string(),
curator_mode: if cfg!(feature = "sal") {
IMPLEMENTED.to_string()
} else {
CURATOR_MODE_REQUIRES_SAL.to_string()
},
}
}
}
fn default_capability_reflection() -> CapabilityReflection {
CapabilityReflection::current()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilitySkills {
pub implemented: bool,
pub standard: String,
pub tools: Vec<String>,
pub round_trip: String,
}
pub const SKILL_TOOL_NAMES: &[&str] = &[
crate::mcp::registry::tool_names::MEMORY_SKILL_REGISTER,
crate::mcp::registry::tool_names::MEMORY_SKILL_LIST,
crate::mcp::registry::tool_names::MEMORY_SKILL_GET,
crate::mcp::registry::tool_names::MEMORY_SKILL_RESOURCE,
crate::mcp::registry::tool_names::MEMORY_SKILL_EXPORT,
crate::mcp::registry::tool_names::MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
crate::mcp::registry::tool_names::MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
];
impl CapabilitySkills {
#[must_use]
pub fn current() -> Self {
Self {
implemented: true,
standard: "agentskills.io".to_string(),
tools: SKILL_TOOL_NAMES.iter().map(|s| (*s).to_string()).collect(),
round_trip: "verified".to_string(),
}
}
}
fn default_capability_skills() -> CapabilitySkills {
CapabilitySkills::current()
}
const IMPLEMENTED: &str = "implemented";
const CURATOR_MODE_REQUIRES_SAL: &str = "requires_sal_feature";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilityForensic {
pub verify_reflection_chain: String,
pub export_forensic_bundle: String,
pub verify_forensic_bundle: String,
}
impl CapabilityForensic {
#[must_use]
pub fn current() -> Self {
Self {
verify_reflection_chain: IMPLEMENTED.to_string(),
export_forensic_bundle: IMPLEMENTED.to_string(),
verify_forensic_bundle: IMPLEMENTED.to_string(),
}
}
}
fn default_capability_forensic() -> CapabilityForensic {
CapabilityForensic::current()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilityGovernance {
pub rules_engine: String,
pub enforced_actions: Vec<String>,
pub bypass_impossibility_tests: u32,
#[serde(default)]
pub l1_6_attest: bool,
}
pub const ENFORCED_AGENT_ACTIONS: &[&str] = &[
crate::governance::agent_action::action_kinds::BASH,
crate::governance::agent_action::action_kinds::FILESYSTEM_WRITE,
crate::governance::agent_action::action_kinds::NETWORK_REQUEST,
crate::governance::agent_action::action_kinds::PROCESS_SPAWN,
];
pub const GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS: u32 = 6;
impl CapabilityGovernance {
#[must_use]
pub fn current() -> Self {
Self {
rules_engine: "operator_signed".to_string(),
enforced_actions: ENFORCED_AGENT_ACTIONS
.iter()
.map(|s| (*s).to_string())
.collect(),
bypass_impossibility_tests: GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS,
l1_6_attest: crate::governance::rules_store::l1_6_attest_active(),
}
}
}
fn default_capability_governance() -> CapabilityGovernance {
CapabilityGovernance::current()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilityAtomisation {
pub tool: String,
pub cli: String,
pub auto: String,
pub recall_preference: String,
pub forensic: String,
pub curator: String,
pub link_relation: String,
}
impl CapabilityAtomisation {
#[must_use]
pub fn current() -> Self {
Self {
tool: IMPLEMENTED.to_string(),
cli: IMPLEMENTED.to_string(),
auto: IMPLEMENTED.to_string(),
recall_preference: IMPLEMENTED.to_string(),
forensic: IMPLEMENTED.to_string(),
curator: IMPLEMENTED.to_string(),
link_relation: "derives_from".to_string(),
}
}
}
fn default_capability_atomisation() -> CapabilityAtomisation {
CapabilityAtomisation::current()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilityMemoryKindVocab {
pub vocabulary: Vec<String>,
pub recall_filter: String,
pub cli_filter: String,
pub auto_classify: String,
pub auto_classify_modes: Vec<String>,
}
impl CapabilityMemoryKindVocab {
#[must_use]
pub fn current() -> Self {
Self {
vocabulary: crate::models::MemoryKind::all()
.iter()
.map(|k| k.as_str().to_string())
.collect(),
recall_filter: IMPLEMENTED.to_string(),
cli_filter: IMPLEMENTED.to_string(),
auto_classify: IMPLEMENTED.to_string(),
auto_classify_modes: vec![
"off".to_string(),
"regex_only".to_string(),
"regex_then_llm".to_string(),
],
}
}
}
fn default_capability_memory_kind_vocab() -> CapabilityMemoryKindVocab {
CapabilityMemoryKindVocab::current()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CapabilityConfidenceCalibration {
pub auto_derive: String,
pub shadow_mode: String,
pub freshness_decay: String,
pub calibration_cli: String,
pub calibration_tool: String,
pub signals_schema: String,
pub default_half_life_days: f64,
#[serde(default)]
pub tier_thresholds: ConfidenceTierThresholds,
}
impl CapabilityConfidenceCalibration {
#[must_use]
pub fn current() -> Self {
Self {
auto_derive: IMPLEMENTED.to_string(),
shadow_mode: IMPLEMENTED.to_string(),
freshness_decay: IMPLEMENTED.to_string(),
calibration_cli: IMPLEMENTED.to_string(),
calibration_tool: IMPLEMENTED.to_string(),
signals_schema: "v1".to_string(),
default_half_life_days: crate::confidence::DEFAULT_HALF_LIFE_DAYS,
tier_thresholds: ConfidenceTierThresholds::default(),
}
}
}
fn default_capability_confidence_calibration() -> CapabilityConfidenceCalibration {
CapabilityConfidenceCalibration::current()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilitiesV1 {
pub tier: String,
pub version: String,
pub features: CapabilityFeaturesV1,
pub models: CapabilityModels,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityFeaturesV1 {
pub keyword_search: bool,
pub semantic_search: bool,
pub hybrid_recall: bool,
pub query_expansion: bool,
pub auto_consolidation: bool,
pub auto_tagging: bool,
pub contradiction_analysis: bool,
pub cross_encoder_reranking: bool,
pub memory_reflection: bool,
#[serde(default)]
pub embedder_loaded: bool,
}
impl Capabilities {
#[must_use]
pub fn to_v1(&self) -> CapabilitiesV1 {
CapabilitiesV1 {
tier: self.tier.clone(),
version: self.version.clone(),
features: CapabilityFeaturesV1 {
keyword_search: self.features.keyword_search,
semantic_search: self.features.semantic_search,
hybrid_recall: self.features.hybrid_recall,
query_expansion: self.features.query_expansion,
auto_consolidation: self.features.auto_consolidation,
auto_tagging: self.features.auto_tagging,
contradiction_analysis: self.features.contradiction_analysis,
cross_encoder_reranking: self.features.cross_encoder_reranking,
memory_reflection: self.features.memory_reflection.enabled,
embedder_loaded: self.features.embedder_loaded,
},
models: self.models.clone(),
}
}
#[must_use]
pub fn to_v3(
&self,
summary: String,
to_describe_to_user: String,
tools: Vec<ToolEntry>,
agent_permitted_families: Option<Vec<String>>,
your_harness_supports_deferred_registration: Option<bool>,
) -> CapabilitiesV3 {
CapabilitiesV3 {
schema_version: "3".to_string(),
summary,
to_describe_to_user,
tools,
agent_permitted_families,
your_harness_supports_deferred_registration,
tier: self.tier.clone(),
version: self.version.clone(),
features: self.features.clone(),
models: self.models.clone(),
permissions: self.permissions.clone(),
hooks: self.hooks.clone(),
compaction: self.compaction.clone(),
approval: self.approval.clone(),
transcripts: self.transcripts.clone(),
hnsw: self.hnsw.clone(),
kg_backend: self.kg_backend.clone(),
memory_kinds: self.memory_kinds.clone(),
reflection: CapabilityReflection::current(),
skills: CapabilitySkills::current(),
forensic: CapabilityForensic::current(),
governance: CapabilityGovernance::current(),
atomisation: CapabilityAtomisation::current(),
memory_kind_vocab: CapabilityMemoryKindVocab::current(),
confidence_calibration: CapabilityConfidenceCalibration::current(),
provenance_substrate_layer: default_capability_provenance_substrate_layer(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolEntry {
pub name: String,
pub family: String,
pub loaded: bool,
pub callable_now: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<ToolExample>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolExample {
pub call: serde_json::Value,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilitiesV3 {
pub schema_version: String,
pub summary: String,
pub to_describe_to_user: String,
pub tools: Vec<ToolEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_permitted_families: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub your_harness_supports_deferred_registration: Option<bool>,
pub tier: String,
pub version: String,
pub features: CapabilityFeatures,
pub models: CapabilityModels,
pub permissions: CapabilityPermissions,
pub hooks: CapabilityHooks,
pub compaction: CapabilityCompaction,
pub approval: CapabilityApproval,
pub transcripts: CapabilityTranscripts,
#[serde(default)]
pub hnsw: CapabilityHnsw,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kg_backend: Option<String>,
#[serde(default = "default_memory_kinds")]
pub memory_kinds: Vec<String>,
#[serde(default = "default_capability_reflection")]
pub reflection: CapabilityReflection,
#[serde(default = "default_capability_skills")]
pub skills: CapabilitySkills,
#[serde(default = "default_capability_forensic")]
pub forensic: CapabilityForensic,
#[serde(default = "default_capability_governance")]
pub governance: CapabilityGovernance,
#[serde(default = "default_capability_atomisation")]
pub atomisation: CapabilityAtomisation,
#[serde(default = "default_capability_memory_kind_vocab")]
pub memory_kind_vocab: CapabilityMemoryKindVocab,
#[serde(default = "default_capability_confidence_calibration")]
pub confidence_calibration: CapabilityConfidenceCalibration,
#[serde(default = "default_capability_provenance_substrate_layer")]
pub provenance_substrate_layer: CapabilityProvenanceSubstrateLayer,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilityProvenanceSubstrateLayer {
#[serde(default)]
pub posture: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub enforcement_layers: Vec<String>,
#[serde(default)]
pub honest_limitations: Vec<String>,
#[serde(default)]
pub spec_references: SpecReferences,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SpecReferences {
#[serde(default)]
pub do_calculus: String,
#[serde(default)]
pub interactional_agency: String,
}
#[must_use]
pub fn default_capability_provenance_substrate_layer() -> CapabilityProvenanceSubstrateLayer {
CapabilityProvenanceSubstrateLayer {
posture: "do_calculus_aligned".to_string(),
summary: "ai-memory implements the do-calculus intervention/observation \
distinction at the substrate layer via Form 4 fact-provenance, \
Form 6 MemoryKind vocabulary, Form 7 agent-EXTERNAL governance, \
the V-4 signed-events cross-row hash chain, and the seven Gap \
provenance framework; stops cross-session delusion amplification \
but not intra-session hallucination (consumer LLM responsibility)."
.to_string(),
enforcement_layers: vec![
"form_4_fact_provenance".to_string(),
"form_6_memory_kind".to_string(),
"form_7_agent_external_governance".to_string(),
"signed_events_v4_chain".to_string(),
"seven_gap_framework".to_string(),
],
honest_limitations: vec![
"intra_session_hallucination_is_consumer_responsibility".to_string(),
"federation_reliability_via_dlq_not_silent_drop".to_string(),
],
spec_references: SpecReferences {
do_calculus: "Pearl (2009)".to_string(),
interactional_agency: "Ortega and de Freitas (2026)".to_string(),
},
}
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TtlConfig {
pub short_ttl_secs: Option<i64>,
pub mid_ttl_secs: Option<i64>,
pub long_ttl_secs: Option<i64>,
pub short_extend_secs: Option<i64>,
pub mid_extend_secs: Option<i64>,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)]
pub struct ResolvedTtl {
pub short_ttl_secs: Option<i64>,
pub mid_ttl_secs: Option<i64>,
pub long_ttl_secs: Option<i64>,
pub short_extend_secs: i64,
pub mid_extend_secs: i64,
}
impl Default for ResolvedTtl {
fn default() -> Self {
Self {
short_ttl_secs: Tier::Short.default_ttl_secs(),
mid_ttl_secs: Tier::Mid.default_ttl_secs(),
long_ttl_secs: Tier::Long.default_ttl_secs(),
short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
}
}
}
const MAX_TTL_SECS: i64 = 315_360_000;
#[allow(dead_code)]
impl ResolvedTtl {
pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
let defaults = Self::default();
let Some(c) = cfg else {
return defaults;
};
let clamp_ttl = |v: i64| -> Option<i64> {
if v <= 0 {
None
} else {
Some(v.min(MAX_TTL_SECS))
}
};
Self {
short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
short_extend_secs: c
.short_extend_secs
.unwrap_or(defaults.short_extend_secs)
.max(0),
mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
}
}
pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
match tier {
Tier::Short => self.short_ttl_secs,
Tier::Mid => self.mid_ttl_secs,
Tier::Long => self.long_ttl_secs,
}
}
pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
match tier {
Tier::Short => Some(self.short_extend_secs),
Tier::Mid => Some(self.mid_extend_secs),
Tier::Long => None,
}
}
}
pub const DEFAULT_TRANSCRIPT_TTL_SECS: i64 = 2_592_000;
pub const DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS: i64 = crate::SECS_PER_WEEK;
const MAX_TRANSCRIPT_LIFECYCLE_SECS: i64 = 315_360_000;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TranscriptsConfig {
pub default_ttl_secs: Option<i64>,
pub archive_grace_secs: Option<i64>,
pub namespaces: Option<std::collections::HashMap<String, TranscriptNamespaceConfig>>,
pub max_decompressed_bytes: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TranscriptNamespaceConfig {
pub default_ttl_secs: Option<i64>,
pub archive_grace_secs: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_extract: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedTranscriptLifecycle {
pub default_ttl_secs: i64,
pub archive_grace_secs: i64,
}
impl Default for ResolvedTranscriptLifecycle {
fn default() -> Self {
Self {
default_ttl_secs: DEFAULT_TRANSCRIPT_TTL_SECS,
archive_grace_secs: DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS,
}
}
}
impl TranscriptsConfig {
#[must_use]
pub fn resolve(&self, namespace: &str) -> ResolvedTranscriptLifecycle {
let ns_table = self.namespaces.as_ref();
let pick_ns = |field: fn(&TranscriptNamespaceConfig) -> Option<i64>| -> Option<i64> {
let table = ns_table?;
if let Some(ns) = table.get(namespace) {
if let Some(v) = field(ns) {
return Some(v);
}
}
let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
.iter()
.filter_map(|(k, v)| {
let prefix = k.strip_suffix("/*")?;
if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
Some((prefix, v))
} else {
None
}
})
.collect();
prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
for (_, ns) in &prefix_hits {
if let Some(v) = field(ns) {
return Some(v);
}
}
if let Some(ns) = table.get("*") {
if let Some(v) = field(ns) {
return Some(v);
}
}
None
};
let clamp = |v: i64, fallback: i64| -> i64 {
if v <= 0 {
fallback
} else {
v.min(MAX_TRANSCRIPT_LIFECYCLE_SECS)
}
};
let ttl = pick_ns(|n| n.default_ttl_secs)
.or(self.default_ttl_secs)
.map_or(DEFAULT_TRANSCRIPT_TTL_SECS, |v| {
clamp(v, DEFAULT_TRANSCRIPT_TTL_SECS)
});
let grace = pick_ns(|n| n.archive_grace_secs)
.or(self.archive_grace_secs)
.map_or(DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS, |v| {
clamp(v, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS)
});
ResolvedTranscriptLifecycle {
default_ttl_secs: ttl,
archive_grace_secs: grace,
}
}
#[must_use]
pub fn auto_extract_for(&self, namespace: &str) -> bool {
let Some(table) = self.namespaces.as_ref() else {
return false;
};
if let Some(ns) = table.get(namespace) {
if let Some(v) = ns.auto_extract {
return v;
}
}
let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
.iter()
.filter_map(|(k, v)| {
let prefix = k.strip_suffix("/*")?;
if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
Some((prefix, v))
} else {
None
}
})
.collect();
prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
for (_, ns) in &prefix_hits {
if let Some(v) = ns.auto_extract {
return v;
}
}
if let Some(ns) = table.get("*") {
if let Some(v) = ns.auto_extract {
return v;
}
}
false
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecallScoringConfig {
pub half_life_days_short: Option<f64>,
pub half_life_days_mid: Option<f64>,
pub half_life_days_long: Option<f64>,
#[serde(default)]
pub legacy_scoring: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct ResolvedScoring {
pub half_life_days_short: f64,
pub half_life_days_mid: f64,
pub half_life_days_long: f64,
pub legacy_scoring: bool,
}
impl Default for ResolvedScoring {
fn default() -> Self {
Self {
half_life_days_short: 7.0,
half_life_days_mid: 30.0,
half_life_days_long: 365.0,
legacy_scoring: false,
}
}
}
impl ResolvedScoring {
const MIN_HALF_LIFE: f64 = 0.1;
const MAX_HALF_LIFE: f64 = 36_500.0;
pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
let defaults = Self::default();
let Some(c) = cfg else {
return defaults;
};
let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
Self {
half_life_days_short: c
.half_life_days_short
.map_or(defaults.half_life_days_short, clamp),
half_life_days_mid: c
.half_life_days_mid
.map_or(defaults.half_life_days_mid, clamp),
half_life_days_long: c
.half_life_days_long
.map_or(defaults.half_life_days_long, clamp),
legacy_scoring: c.legacy_scoring,
}
}
pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
match tier {
Tier::Short => self.half_life_days_short,
Tier::Mid => self.half_life_days_mid,
Tier::Long => self.half_life_days_long,
}
}
#[must_use]
pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
if self.legacy_scoring || age_days <= 0.0 {
return 1.0;
}
let half_life = self.half_life_for_tier(tier);
(-std::f64::consts::LN_2 * age_days / half_life).exp()
}
}
const CONFIG_DIR: &str = ".config/ai-memory";
const CONFIG_FILE: &str = "config.toml";
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct AppConfig {
pub tier: Option<String>,
pub db: Option<String>,
#[deprecated(
since = "0.7.0",
note = "use the sectioned `[llm].base_url` / `[embeddings].url` (#1146); slated for removal in v0.8.0"
)]
pub ollama_url: Option<String>,
#[deprecated(
since = "0.7.0",
note = "use `[embeddings].url` (#1146); slated for removal in v0.8.0"
)]
pub embed_url: Option<String>,
#[deprecated(
since = "0.7.0",
note = "use `[embeddings].model` (#1146); slated for removal in v0.8.0"
)]
pub embedding_model: Option<String>,
#[deprecated(
since = "0.7.0",
note = "use `[llm].model` (#1146); slated for removal in v0.8.0"
)]
pub llm_model: Option<String>,
#[deprecated(
since = "0.7.0",
note = "use `[llm.auto_tag].model` (#1146); slated for removal in v0.8.0"
)]
pub auto_tag_model: Option<String>,
#[deprecated(
since = "0.7.0",
note = "use `[reranker].enabled` (#1146); slated for removal in v0.8.0"
)]
pub cross_encoder: Option<bool>,
#[deprecated(
since = "0.7.0",
note = "use `[storage].default_namespace` (#1146); slated for removal in v0.8.0"
)]
pub default_namespace: Option<String>,
#[deprecated(
since = "0.7.0",
note = "auto-tier resolution now resolves via the sectioned [storage] block (#1146); slated for removal in v0.8.0"
)]
pub max_memory_mb: Option<usize>,
pub ttl: Option<TtlConfig>,
#[deprecated(
since = "0.7.0",
note = "use `[storage].archive_on_gc` (#1146); slated for removal in v0.8.0"
)]
pub archive_on_gc: Option<bool>,
#[serde(default, skip_serializing)]
pub api_key: Option<String>,
#[deprecated(
since = "0.7.0",
note = "archive purge resolution moves under the sectioned [storage] block (#1146); slated for removal in v0.8.0"
)]
pub archive_max_days: Option<i64>,
pub identity: Option<IdentityConfig>,
pub scoring: Option<RecallScoringConfig>,
pub autonomous_hooks: Option<bool>,
pub logging: Option<LoggingConfig>,
pub audit: Option<AuditConfig>,
pub boot: Option<BootConfig>,
pub mcp: Option<McpConfig>,
pub permissions: Option<PermissionsConfig>,
pub transcripts: Option<TranscriptsConfig>,
pub hooks: Option<HooksConfig>,
pub subscriptions: Option<SubscriptionsConfig>,
pub verify: Option<VerifyConfig>,
pub postgres_statement_timeout_secs: Option<u64>,
pub postgres_pool_max_connections: Option<u32>,
pub postgres_pool_min_connections: Option<u32>,
pub postgres_acquire_timeout_secs: Option<u64>,
pub request_timeout_secs: Option<u64>,
pub llm_call_timeout_secs: Option<u64>,
pub mcp_federation_forward_url: Option<String>,
pub agents: Option<AgentsConfig>,
pub governance: Option<GovernanceConfig>,
pub confidence: Option<ConfidenceConfig>,
pub admin: Option<AdminConfig>,
pub schema_version: Option<u32>,
pub llm: Option<LlmSection>,
pub embeddings: Option<EmbeddingsSection>,
pub reranker: Option<RerankerSection>,
pub curator: Option<CuratorSection>,
pub storage: Option<StorageSection>,
pub limits: Option<LimitsSection>,
}
#[allow(deprecated)] impl std::fmt::Debug for AppConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppConfig")
.field("tier", &self.tier)
.field("db", &self.db)
.field(config_keys::OLLAMA_URL, &self.ollama_url)
.field("embed_url", &self.embed_url)
.field(config_keys::EMBEDDING_MODEL, &self.embedding_model)
.field("llm_model", &self.llm_model)
.field(config_keys::AUTO_TAG_MODEL, &self.auto_tag_model)
.field(config_keys::CROSS_ENCODER, &self.cross_encoder)
.field(config_keys::DEFAULT_NAMESPACE, &self.default_namespace)
.field(config_keys::MAX_MEMORY_MB, &self.max_memory_mb)
.field("ttl", &self.ttl)
.field(config_keys::ARCHIVE_ON_GC, &self.archive_on_gc)
.field(
"api_key",
&self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
)
.field(config_keys::ARCHIVE_MAX_DAYS, &self.archive_max_days)
.field("identity", &self.identity)
.field("scoring", &self.scoring)
.field("autonomous_hooks", &self.autonomous_hooks)
.field("logging", &self.logging)
.field("audit", &self.audit)
.field("boot", &self.boot)
.field("mcp", &self.mcp)
.field("permissions", &self.permissions)
.field("transcripts", &self.transcripts)
.field("hooks", &self.hooks)
.field("subscriptions", &self.subscriptions)
.field("verify", &self.verify)
.field(
"postgres_statement_timeout_secs",
&self.postgres_statement_timeout_secs,
)
.field(
"postgres_pool_max_connections",
&self.postgres_pool_max_connections,
)
.field(
"postgres_pool_min_connections",
&self.postgres_pool_min_connections,
)
.field(
"postgres_acquire_timeout_secs",
&self.postgres_acquire_timeout_secs,
)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("llm_call_timeout_secs", &self.llm_call_timeout_secs)
.field(
"mcp_federation_forward_url",
&self.mcp_federation_forward_url,
)
.field("agents", &self.agents)
.field("governance", &self.governance)
.field("confidence", &self.confidence)
.field("admin", &self.admin)
.field("schema_version", &self.schema_version)
.field("llm", &self.llm)
.field(config_keys::SECTION_EMBEDDINGS, &self.embeddings)
.field("reranker", &self.reranker)
.field("storage", &self.storage)
.field("limits", &self.limits)
.finish()
}
}
impl AppConfig {
pub fn zeroize_secrets(&mut self) {
use zeroize::Zeroize;
if let Some(key) = self.api_key.as_mut() {
key.zeroize();
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct GovernanceConfig {
#[serde(default)]
pub require_operator_pubkey: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AdminConfig {
#[serde(default)]
pub agent_ids: Vec<String>,
}
impl AdminConfig {
#[must_use]
pub fn validated_agent_ids(&self) -> Vec<String> {
let mut out = Vec::with_capacity(self.agent_ids.len());
for id in &self.agent_ids {
match crate::validate::validate_agent_id(id) {
Ok(()) => out.push(id.clone()),
Err(e) => {
tracing::warn!("[admin] dropping invalid agent_id '{id}' from allowlist: {e}");
}
}
}
out
}
}
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LlmSection {
pub backend: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub api_key_file: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub auto_tag: Option<LlmAutoTagSection>,
}
impl std::fmt::Debug for LlmSection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LlmSection")
.field("backend", &self.backend)
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("api_key_env", &self.api_key_env)
.field("api_key_file", &self.api_key_file)
.field(
"api_key",
&self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
)
.field("auto_tag", &self.auto_tag)
.finish()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LlmAutoTagSection {
pub backend: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub api_key_file: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct EmbeddingsSection {
pub backend: Option<String>,
pub url: Option<String>,
pub base_url: Option<String>,
pub model: Option<String>,
pub api_key: Option<String>,
pub api_key_env: Option<String>,
pub api_key_file: Option<String>,
pub dim: Option<u32>,
pub backfill_batch: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RerankerSection {
pub enabled: Option<bool>,
pub model: Option<String>,
pub max_seq_tokens: Option<usize>,
pub score_floor: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CuratorSection {
#[serde(default)]
pub reflection_namespaces: Option<
std::collections::HashMap<String, crate::curator::reflection_pass::ReflectionPassConfig>,
>,
#[serde(default)]
pub confidence_decay_half_life_days: Option<std::collections::HashMap<String, f64>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageSection {
pub default_namespace: Option<String>,
pub archive_on_gc: Option<bool>,
pub archive_max_days: Option<i64>,
pub max_memory_mb: Option<usize>,
pub db_mmap_size_bytes: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LimitsSection {
pub max_memories_per_day: Option<i64>,
pub max_storage_bytes: Option<i64>,
pub max_links_per_day: Option<i64>,
pub max_page_size: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource {
Cli,
Env,
Config,
Legacy,
CompiledDefault,
}
impl ConfigSource {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Cli => "cli",
Self::Env => "env",
Self::Config => "config",
Self::Legacy => "legacy",
Self::CompiledDefault => "compiled-default",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySource {
ProcessEnv,
AliasFallback(String),
ConfigEnvVar(String),
ConfigFile(String),
None,
Error(String),
}
impl KeySource {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::ProcessEnv => "process-env",
Self::AliasFallback(_) => "alias-fallback",
Self::ConfigEnvVar(_) => "config-env-var",
Self::ConfigFile(_) => "config-file",
Self::None => "none",
Self::Error(_) => "error",
}
}
#[must_use]
pub fn is_present(&self) -> bool {
!matches!(self, Self::None | Self::Error(_))
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ResolvedLlm {
pub backend: String,
pub model: String,
pub base_url: String,
api_key: Option<String>,
pub api_key_source: KeySource,
pub source: ConfigSource,
}
impl ResolvedLlm {
#[must_use]
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
#[must_use]
pub fn is_ollama_native(&self) -> bool {
self.backend == crate::llm::BACKEND_OLLAMA
}
#[must_use]
pub fn display_label(&self) -> String {
format!("{}:{}", self.backend, self.model)
}
}
impl std::fmt::Debug for ResolvedLlm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedLlm")
.field("backend", &self.backend)
.field("model", &self.model)
.field("base_url", &self.base_url)
.field(
"api_key",
&self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
)
.field("api_key_source", &self.api_key_source)
.field("source", &self.source)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ResolvedEmbeddings {
pub backend: String,
pub url: String,
pub model: String,
pub backfill_batch: u32,
pub embedding_dim: Option<u32>,
pub requested_dim: Option<u32>,
api_key: Option<String>,
pub key_source: KeySource,
pub source: ConfigSource,
}
impl ResolvedEmbeddings {
#[must_use]
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
#[must_use]
pub fn from_parts(
backend: String,
url: String,
model: String,
embedding_dim: Option<u32>,
api_key: Option<String>,
) -> Self {
Self {
backend,
url,
model,
backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
embedding_dim,
requested_dim: None,
api_key,
key_source: KeySource::None,
source: ConfigSource::CompiledDefault,
}
}
#[must_use]
pub fn with_requested_dim(mut self, dim: Option<u32>) -> Self {
self.requested_dim = dim;
self
}
}
impl std::fmt::Debug for ResolvedEmbeddings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedEmbeddings")
.field("backend", &self.backend)
.field("url", &self.url)
.field("model", &self.model)
.field("backfill_batch", &self.backfill_batch)
.field(
crate::models::field_names::EMBEDDING_DIM,
&self.embedding_dim,
)
.field("requested_dim", &self.requested_dim)
.field(
"api_key",
&self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
)
.field("key_source", &self.key_source)
.field("source", &self.source)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedReranker {
pub enabled: bool,
pub model: String,
pub max_seq_tokens: usize,
pub source: ConfigSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedStorage {
pub default_namespace: String,
pub archive_on_gc: bool,
pub archive_max_days: Option<i64>,
pub max_memory_mb: Option<usize>,
pub db_mmap_size_bytes: i64,
pub default_namespace_source: ConfigSource,
pub source: ConfigSource,
}
impl ResolvedStorage {
#[must_use]
pub fn explicit_default_namespace(&self) -> Option<&str> {
if self.default_namespace_source == ConfigSource::CompiledDefault {
None
} else {
Some(self.default_namespace.as_str())
}
}
}
static CONFIGURED_DEFAULT_NAMESPACE: std::sync::RwLock<Option<String>> =
std::sync::RwLock::new(None);
pub fn set_configured_default_namespace(namespace: Option<String>) {
let mut slot = CONFIGURED_DEFAULT_NAMESPACE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = namespace.filter(|s| !s.trim().is_empty());
}
#[must_use]
pub fn configured_default_namespace() -> Option<String> {
CONFIGURED_DEFAULT_NAMESPACE
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn lock_configured_default_namespace_for_test() -> std::sync::MutexGuard<'static, ()> {
static GATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
GATE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLimits {
pub max_memories_per_day: i64,
pub max_storage_bytes: i64,
pub max_links_per_day: i64,
pub max_page_size: usize,
pub source: ConfigSource,
}
pub const ENV_MAX_MEMORIES_PER_DAY: &str = "AI_MEMORY_MAX_MEMORIES_PER_DAY";
pub const ENV_MAX_STORAGE_BYTES: &str = "AI_MEMORY_MAX_STORAGE_BYTES";
pub const ENV_MAX_LINKS_PER_DAY: &str = "AI_MEMORY_MAX_LINKS_PER_DAY";
pub const ENV_MAX_PAGE_SIZE: &str = "AI_MEMORY_MAX_PAGE_SIZE";
pub const ENV_DB_MMAP_SIZE: &str = "AI_MEMORY_DB_MMAP_SIZE";
pub const ENV_RERANK_MAX_SEQ: &str = "AI_MEMORY_RERANK_MAX_SEQ";
pub const ENV_RERANK_SCORE_FLOOR: &str = "AI_MEMORY_RERANK_SCORE_FLOOR";
pub const ENV_PG_POOL_MAX: &str = "AI_MEMORY_PG_POOL_MAX";
pub const ENV_PG_POOL_MIN: &str = "AI_MEMORY_PG_POOL_MIN";
pub const ENV_PG_ACQUIRE_TIMEOUT_SECS: &str = "AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS";
pub const ENV_LLM_API_KEY: &str = "AI_MEMORY_LLM_API_KEY";
pub const ENV_EMBED_BACKEND: &str = "AI_MEMORY_EMBED_BACKEND";
pub const ENV_EMBED_BASE_URL: &str = "AI_MEMORY_EMBED_BASE_URL";
pub const ENV_EMBED_MODEL: &str = "AI_MEMORY_EMBED_MODEL";
pub const ENV_EMBED_API_KEY: &str = "AI_MEMORY_EMBED_API_KEY";
pub const ENV_EMBED_BACKFILL_BATCH: &str = "AI_MEMORY_EMBED_BACKFILL_BATCH";
pub(crate) const DEFAULT_EMBED_MODEL: &str = "nomic-embed-text-v1.5";
pub(crate) const DEFAULT_EMBED_BACKFILL_BATCH: u32 = 100;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedModels {
pub llm: ResolvedLlm,
pub embeddings: ResolvedEmbeddings,
pub reranker: ResolvedReranker,
}
impl Default for ResolvedModels {
fn default() -> Self {
Self {
llm: ResolvedLlm {
backend: "ollama".to_string(),
model: String::new(),
base_url: "http://localhost:11434".to_string(),
api_key: None,
api_key_source: KeySource::None,
source: ConfigSource::CompiledDefault,
},
embeddings: ResolvedEmbeddings {
backend: "ollama".to_string(),
url: "http://localhost:11434".to_string(),
model: String::new(),
backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
embedding_dim: None,
requested_dim: None,
api_key: None,
key_source: KeySource::None,
source: ConfigSource::CompiledDefault,
},
reranker: ResolvedReranker {
enabled: false,
model: "ms-marco-MiniLM-L-6-v2".to_string(),
max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
source: ConfigSource::CompiledDefault,
},
}
}
}
impl ResolvedModels {
#[must_use]
pub fn from_tier_preset(tier: &TierConfig) -> Self {
Self {
llm: ResolvedLlm {
backend: "ollama".to_string(),
model: tier.llm_model.clone().unwrap_or_default(),
base_url: "http://localhost:11434".to_string(),
api_key: None,
api_key_source: KeySource::None,
source: ConfigSource::CompiledDefault,
},
embeddings: ResolvedEmbeddings {
backend: "ollama".to_string(),
url: "http://localhost:11434".to_string(),
model: tier
.embedding_model
.map(|m| m.hf_model_id().to_string())
.unwrap_or_default(),
backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
embedding_dim: tier.embedding_model.map(|m| m.dim() as u32),
requested_dim: None,
api_key: None,
key_source: KeySource::None,
source: ConfigSource::CompiledDefault,
},
reranker: ResolvedReranker {
enabled: tier.cross_encoder,
model: "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string(),
max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
source: ConfigSource::CompiledDefault,
},
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentsConfig {
#[serde(default)]
pub defaults: Option<AgentDefaults>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentDefaults {
#[serde(default)]
pub recall_scope: Option<RecallScope>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecallScope {
#[serde(default)]
pub namespaces: Option<Vec<String>>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub tier: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ConfidenceConfig {
pub shadow_retention_days: Option<i64>,
}
impl ConfidenceConfig {
#[must_use]
pub fn effective_shadow_retention_days(&self) -> i64 {
self.shadow_retention_days
.unwrap_or(crate::confidence::shadow::DEFAULT_SHADOW_RETENTION_DAYS)
}
}
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
pub const DEFAULT_LLM_CALL_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HooksConfig {
pub subscription: Option<HooksSubscriptionConfig>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct HooksSubscriptionConfig {
#[serde(default, skip_serializing)]
pub hmac_secret: Option<String>,
}
impl std::fmt::Debug for HooksSubscriptionConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HooksSubscriptionConfig")
.field(
"hmac_secret",
&self
.hmac_secret
.as_ref()
.map(|_| crate::REDACTED_PLACEHOLDER),
)
.finish()
}
}
impl HooksSubscriptionConfig {
pub fn zeroize_secrets(&mut self) {
if let Some(secret) = self.hmac_secret.as_mut() {
use zeroize::Zeroize;
secret.zeroize();
}
}
}
impl Drop for HooksSubscriptionConfig {
fn drop(&mut self) {
self.zeroize_secrets();
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VerifyConfig {
#[serde(default)]
pub require_nonce: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubscriptionsConfig {
#[serde(default)]
pub allow_loopback_webhooks: bool,
}
impl AppConfig {
#[must_use]
pub fn effective_hooks_hmac_secret(&self) -> Option<String> {
self.hooks
.as_ref()
.and_then(|h| h.subscription.as_ref())
.and_then(|s| s.hmac_secret.clone())
}
#[must_use]
pub fn effective_recall_scope(&self) -> Option<&RecallScope> {
self.agents
.as_ref()
.and_then(|a| a.defaults.as_ref())
.and_then(|d| d.recall_scope.as_ref())
}
#[must_use]
pub fn effective_allow_loopback_webhooks(&self) -> bool {
if let Ok(raw) = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS") {
match raw.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => return true,
"0" | "false" | "no" | "off" | "" => return false,
other => {
eprintln!(
"ai-memory: AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS={other:?} is not a valid \
boolean (expected 1/true/yes/on or 0/false/no/off); falling back to \
config.toml"
);
}
}
}
self.subscriptions
.as_ref()
.is_some_and(|s| s.allow_loopback_webhooks)
}
}
pub fn set_active_hooks_hmac_secret(secret: Option<String>) {
if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
.hooks_hmac_secret
.write()
{
*w = secret;
}
}
#[must_use]
pub fn active_hooks_hmac_secret() -> Option<String> {
crate::runtime_context::RuntimeContext::global()
.hooks_hmac_secret
.read()
.ok()
.and_then(|g| g.clone())
}
pub fn set_active_max_decompressed_bytes(cap: Option<usize>) {
if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
.max_decompressed_bytes
.write()
{
*w = cap;
}
}
#[must_use]
pub fn active_max_decompressed_bytes() -> usize {
crate::runtime_context::RuntimeContext::global()
.max_decompressed_bytes
.read()
.ok()
.and_then(|g| *g)
.unwrap_or(crate::transcripts::MAX_DECOMPRESSED_BYTES)
}
static ALLOW_LOOPBACK_WEBHOOKS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(cfg!(test));
pub fn set_allow_loopback_webhooks(allow: bool) {
ALLOW_LOOPBACK_WEBHOOKS.store(allow, std::sync::atomic::Ordering::SeqCst);
}
#[must_use]
pub fn allow_loopback_webhooks() -> bool {
ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PermissionsMode {
Enforce,
Advisory,
Off,
}
impl Default for PermissionsMode {
fn default() -> Self {
Self::Advisory
}
}
impl PermissionsMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Enforce => "enforce",
Self::Advisory => "advisory",
Self::Off => "off",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<PermissionsMode>,
#[serde(default)]
pub rules: Vec<crate::permissions::PermissionRule>,
}
static ACTIVE_PERMISSIONS_MODE: std::sync::RwLock<Option<PermissionsMode>> =
std::sync::RwLock::new(None);
pub fn set_active_permissions_mode(mode: PermissionsMode) {
if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
*w = Some(mode);
}
}
const UNINITIALIZED_PERMISSIONS_MODE_FALLBACK: PermissionsMode = PermissionsMode::Advisory;
#[must_use]
pub fn active_permissions_mode() -> PermissionsMode {
match ACTIVE_PERMISSIONS_MODE.read().ok().and_then(|g| *g) {
Some(mode) => mode,
None => {
static UNINIT_GATE_WARN_ONCE: std::sync::Once = std::sync::Once::new();
UNINIT_GATE_WARN_ONCE.call_once(|| {
tracing::warn!(
target: crate::governance::GOVERNANCE_TRACE_TARGET,
fallback = UNINITIALIZED_PERMISSIONS_MODE_FALLBACK.as_str(),
"permissions mode consulted before boot installed it; using the \
pre-init fallback. Production entry points install the resolved \
mode (secure default: enforce) during boot — if you see this in \
a running daemon, the boot ordering regressed."
);
});
UNINITIALIZED_PERMISSIONS_MODE_FALLBACK
}
}
}
#[doc(hidden)]
pub fn override_active_permissions_mode_for_test(mode: PermissionsMode) {
set_active_permissions_mode(mode);
}
#[doc(hidden)]
pub fn clear_permissions_mode_override_for_test() {
if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
*w = None;
}
}
#[doc(hidden)]
#[must_use]
pub fn lock_permissions_mode_for_test() -> std::sync::MutexGuard<'static, ()> {
use std::sync::Mutex;
static GATE_LOCK: Mutex<()> = Mutex::new(());
GATE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
use std::sync::atomic::{AtomicU64, Ordering};
struct DecisionCounters {
enforce: AtomicU64,
advisory: AtomicU64,
off: AtomicU64,
}
impl DecisionCounters {
const fn new() -> Self {
Self {
enforce: AtomicU64::new(0),
advisory: AtomicU64::new(0),
off: AtomicU64::new(0),
}
}
fn counter_for(&self, mode: PermissionsMode) -> &AtomicU64 {
match mode {
PermissionsMode::Enforce => &self.enforce,
PermissionsMode::Advisory => &self.advisory,
PermissionsMode::Off => &self.off,
}
}
}
static DECISION_COUNTERS: DecisionCounters = DecisionCounters::new();
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PermissionsDecisionCounts {
pub enforce: u64,
pub advisory: u64,
pub off: u64,
}
pub fn record_permissions_decision(mode: PermissionsMode) {
DECISION_COUNTERS
.counter_for(mode)
.fetch_add(1, Ordering::Relaxed);
}
#[must_use]
pub fn permissions_decision_counts() -> PermissionsDecisionCounts {
PermissionsDecisionCounts {
enforce: DECISION_COUNTERS.enforce.load(Ordering::Relaxed),
advisory: DECISION_COUNTERS.advisory.load(Ordering::Relaxed),
off: DECISION_COUNTERS.off.load(Ordering::Relaxed),
}
}
#[doc(hidden)]
pub fn reset_permissions_decision_counts_for_test() {
DECISION_COUNTERS.enforce.store(0, Ordering::SeqCst);
DECISION_COUNTERS.advisory.store(0, Ordering::SeqCst);
DECISION_COUNTERS.off.store(0, Ordering::SeqCst);
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LoggingConfig {
pub enabled: Option<bool>,
pub path: Option<String>,
pub max_size_mb: Option<u64>,
pub max_files: Option<usize>,
pub retention_days: Option<u32>,
pub structured: Option<bool>,
pub level: Option<String>,
pub rotation: Option<String>,
pub filename_prefix: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditConfig {
pub enabled: Option<bool>,
pub path: Option<String>,
pub schema_version: Option<u32>,
pub redact_content: Option<bool>,
pub hash_chain: Option<bool>,
pub attestation_cadence_minutes: Option<u32>,
pub append_only: Option<bool>,
pub retention_days: Option<u32>,
pub compliance: Option<AuditComplianceConfig>,
}
impl AuditConfig {
#[must_use]
pub fn effective_retention_days(&self) -> u32 {
let mut chosen = self.retention_days.unwrap_or(90);
if let Some(comp) = &self.compliance {
for preset in comp.applied_presets() {
if let Some(d) = preset.retention_days
&& d > chosen
{
chosen = d;
}
}
}
chosen
}
#[must_use]
pub fn effective_attestation_cadence_minutes(&self) -> u32 {
let base = self.attestation_cadence_minutes.unwrap_or(60);
let mut chosen = base;
if let Some(comp) = &self.compliance {
for preset in comp.applied_presets() {
if let Some(m) = preset.attestation_cadence_minutes
&& m > 0
&& (chosen == 0 || m < chosen)
{
chosen = m;
}
}
}
chosen
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BootConfig {
pub enabled: Option<bool>,
pub redact_titles: Option<bool>,
}
impl BootConfig {
#[must_use]
pub fn effective_enabled(&self) -> bool {
if let Ok(v) = std::env::var("AI_MEMORY_BOOT_ENABLED") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "0" | "false" | "no" | "off") {
return false;
}
if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
return true;
}
}
self.enabled.unwrap_or(true)
}
#[must_use]
pub fn effective_redact_titles(&self) -> bool {
self.redact_titles.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpConfig {
pub profile: Option<String>,
pub allowlist: Option<std::collections::HashMap<String, Vec<String>>>,
#[serde(default)]
pub profile_hint_in_errors: bool,
}
impl McpConfig {
#[must_use]
pub fn allowlist_decision(&self, agent_id: Option<&str>, family: &str) -> AllowlistDecision {
let table = match self.allowlist.as_ref() {
Some(t) if !t.is_empty() => t,
_ => return AllowlistDecision::Disabled,
};
let aid = agent_id.unwrap_or("");
if let Some(families) = table.get(aid) {
return decide(families, family);
}
let mut keys: Vec<&String> = table
.keys()
.filter(|k| k.as_str() != "*" && aid.starts_with(k.as_str()))
.collect();
keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
if let Some(k) = keys.first() {
if let Some(families) = table.get(*k) {
return decide(families, family);
}
}
if let Some(families) = table.get("*") {
return decide(families, family);
}
AllowlistDecision::Deny
}
}
fn decide(families: &[String], requested: &str) -> AllowlistDecision {
if families.iter().any(|f| f == "full" || f == requested) {
AllowlistDecision::Allow
} else {
AllowlistDecision::Deny
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AllowlistDecision {
Disabled,
Allow,
Deny,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditComplianceConfig {
pub soc2: Option<CompliancePreset>,
pub hipaa: Option<CompliancePreset>,
pub gdpr: Option<CompliancePreset>,
pub fedramp: Option<CompliancePreset>,
}
impl AuditComplianceConfig {
pub fn applied_presets(&self) -> impl Iterator<Item = &CompliancePreset> {
[
self.soc2.as_ref(),
self.hipaa.as_ref(),
self.gdpr.as_ref(),
self.fedramp.as_ref(),
]
.into_iter()
.flatten()
.filter(|p| p.applied.unwrap_or(false))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompliancePreset {
pub applied: Option<bool>,
pub retention_days: Option<u32>,
pub redact_content: Option<bool>,
pub attestation_cadence_minutes: Option<u32>,
pub encrypt_at_rest: Option<bool>,
pub pseudonymize_actors: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdentityConfig {
#[serde(default)]
pub anonymize_default: bool,
}
#[must_use]
pub fn parse_duration_string(s: &str) -> Option<chrono::Duration> {
let trimmed = s.trim().to_ascii_lowercase();
if trimmed.is_empty() {
return None;
}
let (num_part, unit_part) = trimmed.split_at(
trimmed
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(trimmed.len()),
);
let n: i64 = num_part.parse().ok()?;
if n < 0 {
return None;
}
match unit_part.trim() {
"s" | "sec" | "secs" | "second" | "seconds" => Some(chrono::Duration::seconds(n)),
"m" | "min" | "mins" | "minute" | "minutes" => Some(chrono::Duration::minutes(n)),
"h" | "hr" | "hrs" | "hour" | "hours" => Some(chrono::Duration::hours(n)),
"d" | "day" | "days" => Some(chrono::Duration::days(n)),
"w" | "wk" | "wks" | "week" | "weeks" => Some(chrono::Duration::weeks(n)),
_ => None,
}
}
fn backend_default_model(backend: &str) -> &'static str {
match backend {
"xai" => "grok-4.3",
"openai" => "gpt-5",
"anthropic" => "claude-opus-4.7",
"gemini" => "gemini-2.0-flash",
"deepseek" => "deepseek-chat",
"kimi" | "moonshot" => "moonshot-v1-8k",
"qwen" | "dashscope" => "qwen-max",
"mistral" => "mistral-large-latest",
"groq" => "llama-3.3-70b-versatile",
"together" => "meta-llama/Llama-3.3-70B-Instruct-Turbo",
"cerebras" => "llama-3.3-70b",
"openrouter" => "openai/gpt-5",
"fireworks" => "accounts/fireworks/models/llama-v3p3-70b-instruct",
"lmstudio" => "local-model",
_ => "gemma3:4b",
}
}
fn backend_default_base_url(backend: &str) -> &'static str {
match backend {
"openai" => "https://api.openai.com/v1",
"xai" => "https://api.x.ai/v1",
"anthropic" => "https://api.anthropic.com/v1",
"gemini" => "https://generativelanguage.googleapis.com/v1beta/openai",
"deepseek" => "https://api.deepseek.com/v1",
"kimi" | "moonshot" => "https://api.moonshot.cn/v1",
"qwen" | "dashscope" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
"mistral" => "https://api.mistral.ai/v1",
"groq" => "https://api.groq.com/openai/v1",
"together" => "https://api.together.xyz/v1",
"cerebras" => "https://api.cerebras.ai/v1",
"openrouter" => "https://openrouter.ai/api/v1",
"fireworks" => "https://api.fireworks.ai/inference/v1",
"lmstudio" => "http://localhost:1234/v1",
_ => "http://localhost:11434",
}
}
fn alias_api_key_env_vars_for_resolver(alias: &str) -> &'static [&'static str] {
match alias {
"openai" => &["OPENAI_API_KEY"],
"xai" => &["XAI_API_KEY"],
"anthropic" => &["ANTHROPIC_API_KEY"],
"gemini" => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
"deepseek" => &["DEEPSEEK_API_KEY"],
"kimi" | "moonshot" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
"qwen" | "dashscope" => &["DASHSCOPE_API_KEY", "QWEN_API_KEY"],
"mistral" => &["MISTRAL_API_KEY"],
"groq" => &["GROQ_API_KEY"],
"together" => &["TOGETHER_API_KEY"],
"cerebras" => &["CEREBRAS_API_KEY"],
"openrouter" => &["OPENROUTER_API_KEY"],
"fireworks" => &["FIREWORKS_API_KEY"],
_ => &[],
}
}
fn canonicalise_embedding_model(raw: String) -> String {
match raw.trim() {
"nomic_embed_v15" => "nomic-embed-text-v1.5".to_string(),
"mini_lm_l6_v2" => "sentence-transformers/all-MiniLM-L6-v2".to_string(),
_ => raw,
}
}
pub const KNOWN_EMBEDDING_DIMS: &[(&str, u32)] = &[
("nomic-embed-text-v1.5", 768),
("nomic-embed-text", 768),
("nomic-ai/nomic-embed-text-v1.5", 768),
("sentence-transformers/all-MiniLM-L6-v2", 384),
("all-MiniLM-L6-v2", 384),
("all-minilm", 384),
("all-minilm:l6-v2", 384),
("bge-large-en", 1024),
("bge-large-en-v1.5", 1024),
("baai/bge-large-en-v1.5", 1024),
("bge-base-en", 768),
("bge-base-en-v1.5", 768),
("baai/bge-base-en-v1.5", 768),
("bge-small-en", 384),
("bge-small-en-v1.5", 384),
("baai/bge-small-en-v1.5", 384),
("bge-m3", 1024),
("baai/bge-m3", 1024),
("mxbai-embed-large", 1024),
("mxbai-embed-large-v1", 1024),
("mixedbread-ai/mxbai-embed-large-v1", 1024),
("text-embedding-3-small", 1536),
("text-embedding-3-large", 3072),
("text-embedding-ada-002", 1536),
("embedding-001", 768),
("text-embedding-004", 768),
("google/gemini-embedding-2", 3072),
("gemini-embedding-2", 3072),
("ibm-granite/granite-embedding-125m-english", 768),
("granite-embedding", 768),
("snowflake-arctic-embed", 1024),
("snowflake-arctic-embed:l", 1024),
("snowflake-arctic-embed-l", 1024),
("snowflake-arctic-embed:m", 768),
("snowflake-arctic-embed:s", 384),
];
#[must_use]
pub fn canonical_embedding_dim(model: &str) -> Option<u32> {
let needle = model.trim();
if needle.is_empty() {
return None;
}
KNOWN_EMBEDDING_DIMS
.iter()
.find(|(id, _)| id.eq_ignore_ascii_case(needle))
.map(|(_, dim)| *dim)
}
fn resolve_api_key(backend: &str, llm: Option<&LlmSection>) -> (Option<String>, KeySource) {
resolve_api_key_ladder(
ENV_LLM_API_KEY,
backend,
llm.and_then(|l| l.api_key_env.as_deref()),
llm.and_then(|l| l.api_key_file.as_deref()),
"llm",
)
}
fn resolve_embed_api_key(
backend: &str,
embeddings: Option<&EmbeddingsSection>,
) -> (Option<String>, KeySource) {
resolve_api_key_ladder(
ENV_EMBED_API_KEY,
backend,
embeddings.and_then(|e| e.api_key_env.as_deref()),
embeddings.and_then(|e| e.api_key_file.as_deref()),
"embeddings",
)
}
#[must_use]
pub fn is_api_embed_backend(backend: &str) -> bool {
!backend
.trim()
.eq_ignore_ascii_case(crate::llm::BACKEND_OLLAMA)
}
fn resolve_api_key_ladder(
primary_env: &str,
backend: &str,
api_key_env: Option<&str>,
api_key_file: Option<&str>,
section: &str,
) -> (Option<String>, KeySource) {
if let Some(k) = std::env::var(primary_env)
.ok()
.filter(|s| !s.trim().is_empty())
{
return (Some(k), KeySource::ProcessEnv);
}
for name in alias_api_key_env_vars_for_resolver(backend) {
if let Some(k) = std::env::var(name).ok().filter(|s| !s.trim().is_empty()) {
return (Some(k), KeySource::AliasFallback((*name).to_string()));
}
}
if let Some(name) = api_key_env.filter(|s| !s.trim().is_empty()) {
return match std::env::var(name) {
Ok(v) if !v.trim().is_empty() => (Some(v), KeySource::ConfigEnvVar(name.to_string())),
Ok(_) => (
None,
KeySource::Error(format!(
"[{section}].api_key_env = {name:?} resolves to an empty env var"
)),
),
Err(_) => (
None,
KeySource::Error(format!(
"[{section}].api_key_env = {name:?} is not set in the process env"
)),
),
};
}
if let Some(raw_path) = api_key_file.filter(|s| !s.trim().is_empty()) {
let field = format!("[{section}].api_key_file");
let path = expand_tilde(raw_path);
let path_display = path.display().to_string();
if let Err(reason) = enforce_api_key_file_perms(&path, &field) {
return (None, KeySource::Error(reason));
}
return match std::fs::read_to_string(&path) {
Ok(contents) => {
let key = contents.lines().next().unwrap_or("").trim().to_string();
if key.is_empty() {
(
None,
KeySource::Error(format!("{field} = {path_display:?} is empty")),
)
} else {
(Some(key), KeySource::ConfigFile(path_display))
}
}
Err(e) => (
None,
KeySource::Error(format!("{field} = {path_display:?} could not be read: {e}")),
),
};
}
(None, KeySource::None)
}
fn enforce_api_key_file_perms(path: &Path, field: &str) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path).map_err(|e| {
format!(
"{field} = {:?} could not be stat'd for perms check: {e}",
path.display(),
)
})?;
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
let opt_in = std::env::var("AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS")
.ok()
.is_some_and(|s| {
let t = s.trim().to_ascii_lowercase();
matches!(t.as_str(), "1" | "true" | "yes" | "on")
});
if !opt_in {
return Err(format!(
"{field} = {:?} has lax permissions \
(mode = {:o}; expected 0400 or stricter). Run \
`chmod 0400 {}` to fix, or set \
`AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1` to \
bypass (NOT recommended for production).",
path.display(),
mode & 0o777,
path.display()
));
}
tracing::warn!(
"{field} = {:?} has lax permissions (mode = {:o}); \
accepted because AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1",
path.display(),
mode & 0o777
);
}
}
#[cfg(not(unix))]
{
let _ = (path, field);
}
Ok(())
}
fn expand_tilde(s: &str) -> PathBuf {
if s == "~" {
return std::env::var("HOME").map_or_else(|_| PathBuf::from(s), PathBuf::from);
}
if let Some(rest) = s.strip_prefix("~/") {
return std::env::var("HOME")
.map_or_else(|_| PathBuf::from(s), |h| PathBuf::from(h).join(rest));
}
PathBuf::from(s)
}
impl AppConfig {
pub fn config_path() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
}
pub fn load() -> Self {
if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
return Self::default();
}
let Some(path) = Self::config_path() else {
return Self::default();
};
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(contents) => {
Self::warn_unknown_top_level_keys(path, &contents);
match toml::from_str::<Self>(&contents) {
Ok(cfg) => match cfg.validate_secret_handling() {
Ok(()) => {
eprintln!("ai-memory: loaded config from {}", path.display());
cfg.warn_legacy_schema_drift(path);
cfg
}
Err(reason) => {
eprintln!(
"ai-memory: config rejected ({}): {}\n\
ai-memory: falling back to default config — \
fix the issue and restart. \
See https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
path.display(),
reason
);
Self::default()
}
},
Err(e) => {
eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
Self::default()
}
}
}
Err(_) => Self::default(),
}
}
#[allow(deprecated)]
fn warn_legacy_schema_drift(&self, path: &Path) {
use std::sync::Once;
static WARN_ONCE: Once = Once::new();
let has_legacy = self.llm_model.is_some()
|| self.ollama_url.is_some()
|| self.embed_url.is_some()
|| self.embedding_model.is_some()
|| self.cross_encoder.is_some()
|| self.default_namespace.is_some()
|| self.archive_on_gc.is_some()
|| self.archive_max_days.is_some()
|| self.max_memory_mb.is_some()
|| self.auto_tag_model.is_some();
if !has_legacy {
return;
}
let v2 = matches!(self.schema_version, Some(v) if v >= 2);
WARN_ONCE.call_once(|| {
if v2 {
eprintln!(
"ai-memory: WARN — schema_version = {:?} but legacy v1 fields \
are still present in {} (llm_model / ollama_url / embed_url / \
embedding_model / cross_encoder / default_namespace / \
archive_on_gc / archive_max_days / max_memory_mb / \
auto_tag_model). Under v2 the legacy fields are IGNORED in \
favor of [llm] / [embeddings] / [reranker] / [storage] \
sections. Run `ai-memory config migrate` to remove them.",
self.schema_version,
path.display(),
);
} else {
eprintln!(
"ai-memory: WARN — legacy v1 flat-field configuration shape \
detected in {}. The [llm] / [embeddings] / [reranker] / \
[storage] sectioned schema (v2) is the canonical shape; \
legacy fields continue to work in v0.7.x but will be \
removed in v0.8.0. Run `ai-memory config migrate` to \
upgrade in place (a timestamped .bak is written). See \
https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
path.display(),
);
}
});
}
fn validate_secret_handling(&self) -> Result<(), String> {
if let Some(llm) = &self.llm {
if llm.api_key.is_some() {
return Err("inline `api_key = \"<literal>\"` in [llm] is forbidden — \
use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
process env var, or `api_key_file = \"/path/to/key\"` to \
reference a file (mode 0400 enforced). Inline secrets in \
config.toml (typically world-readable) are a credential \
leak."
.to_string());
}
if llm.api_key_env.is_some() && llm.api_key_file.is_some() {
return Err("[llm].api_key_env and [llm].api_key_file are mutually \
exclusive — set exactly one (or neither, to fall back \
to the per-vendor env-var chain)."
.to_string());
}
if let Some(auto_tag) = &llm.auto_tag {
if auto_tag.api_key_env.is_some() && auto_tag.api_key_file.is_some() {
return Err("[llm.auto_tag].api_key_env and \
[llm.auto_tag].api_key_file are mutually exclusive."
.to_string());
}
}
}
if let Some(embeddings) = &self.embeddings {
if embeddings.api_key.is_some() {
return Err(
"inline `api_key = \"<literal>\"` in [embeddings] is forbidden — \
use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
process env var, or `api_key_file = \"/path/to/key\"` to \
reference a file (mode 0400 enforced). Inline secrets in \
config.toml (typically world-readable) are a credential \
leak."
.to_string(),
);
}
if embeddings.api_key_env.is_some() && embeddings.api_key_file.is_some() {
return Err(
"[embeddings].api_key_env and [embeddings].api_key_file are \
mutually exclusive — set exactly one (or neither, to fall \
back to the per-vendor env-var chain)."
.to_string(),
);
}
}
Ok(())
}
fn warn_unknown_top_level_keys(path: &Path, contents: &str) {
const EXPECTED_KEYS: &[&str] = &[
"tier",
"db",
config_keys::OLLAMA_URL,
"embed_url",
config_keys::EMBEDDING_MODEL,
"llm_model",
config_keys::AUTO_TAG_MODEL,
config_keys::CROSS_ENCODER,
config_keys::DEFAULT_NAMESPACE,
config_keys::MAX_MEMORY_MB,
"ttl",
config_keys::ARCHIVE_ON_GC,
"api_key",
config_keys::ARCHIVE_MAX_DAYS,
"identity",
"scoring",
"autonomous_hooks",
"logging",
"audit",
"boot",
"mcp",
"permissions",
"transcripts",
"hooks",
"subscriptions",
"postgres_statement_timeout_secs",
"postgres_pool_max_connections",
"postgres_pool_min_connections",
"postgres_acquire_timeout_secs",
"request_timeout_secs",
"llm_call_timeout_secs",
"verify",
"mcp_federation_forward_url",
"agents",
"governance",
"confidence",
"admin",
"schema_version",
"llm",
config_keys::SECTION_EMBEDDINGS,
"reranker",
"curator",
"storage",
"limits",
];
let value: toml::Value = match toml::from_str(contents) {
Ok(v) => v,
Err(_) => return,
};
let Some(table) = value.as_table() else {
return;
};
let expected_list = EXPECTED_KEYS.join(", ");
for key in table.keys() {
if !EXPECTED_KEYS.contains(&key.as_str()) {
tracing::warn!(
"[config] unknown key '{key}' in {path} — top-level AppConfig fields are: {expected_keys}. This key is silently ignored (no behavior change).",
key = key,
path = path.display(),
expected_keys = expected_list,
);
}
}
}
#[must_use]
pub fn effective_permissions_mode(&self) -> PermissionsMode {
if let Ok(raw) = std::env::var("AI_MEMORY_PERMISSIONS_MODE") {
match raw.to_ascii_lowercase().as_str() {
"enforce" => return PermissionsMode::Enforce,
"advisory" => return PermissionsMode::Advisory,
"off" => return PermissionsMode::Off,
other => {
eprintln!(
"ai-memory: AI_MEMORY_PERMISSIONS_MODE={other:?} is not a valid mode \
(expected enforce / advisory / off); falling back to config.toml"
);
}
}
}
let configured = self.permissions.as_ref().and_then(|p| p.mode);
let (mode, _warn) = crate::permissions::resolve_v07_default_mode(configured);
mode
}
#[must_use]
pub fn effective_permission_rules(&self) -> Vec<crate::permissions::PermissionRule> {
self.permissions
.as_ref()
.map(|p| p.rules.clone())
.unwrap_or_default()
}
pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
}
pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
let default_db = PathBuf::from("ai-memory.db");
if cli_db != default_db {
return cli_db.to_path_buf();
}
self.db
.as_ref()
.map_or_else(|| cli_db.to_path_buf(), |s| expand_tilde(s))
}
#[allow(deprecated)]
pub fn effective_ollama_url(&self) -> &str {
self.ollama_url
.as_deref()
.unwrap_or("http://localhost:11434")
}
pub fn effective_ttl(&self) -> ResolvedTtl {
ResolvedTtl::from_config(self.ttl.as_ref())
}
pub fn effective_scoring(&self) -> ResolvedScoring {
ResolvedScoring::from_config(self.scoring.as_ref())
}
#[allow(deprecated)]
pub fn effective_archive_on_gc(&self) -> bool {
self.archive_on_gc.unwrap_or(true)
}
#[must_use]
pub fn effective_request_timeout_secs(&self) -> u64 {
self.request_timeout_secs
.unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS)
}
#[must_use]
pub fn effective_llm_call_timeout_secs(&self) -> u64 {
self.llm_call_timeout_secs
.unwrap_or(DEFAULT_LLM_CALL_TIMEOUT_SECS)
}
pub fn effective_profile(
&self,
cli_or_env: Option<&str>,
) -> Result<crate::profile::Profile, crate::profile::ProfileParseError> {
let raw = cli_or_env
.or_else(|| self.mcp.as_ref().and_then(|m| m.profile.as_deref()))
.unwrap_or("core");
crate::profile::Profile::parse(raw)
}
pub fn effective_autonomous_hooks(&self) -> bool {
if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
return true;
}
if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
return false;
}
}
self.autonomous_hooks.unwrap_or(false)
}
pub fn effective_anonymize_default(&self) -> bool {
if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
return true;
}
if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
return false;
}
}
self.identity.as_ref().is_some_and(|i| i.anonymize_default)
}
pub fn effective_logging(&self) -> LoggingConfig {
self.logging.clone().unwrap_or_default()
}
pub fn effective_audit(&self) -> AuditConfig {
self.audit.clone().unwrap_or_default()
}
#[must_use]
pub fn effective_transcripts(&self) -> TranscriptsConfig {
self.transcripts.clone().unwrap_or_default()
}
pub fn effective_boot(&self) -> BootConfig {
self.boot.clone().unwrap_or_default()
}
#[allow(deprecated)]
pub fn effective_embed_url(&self) -> &str {
self.embed_url
.as_deref()
.or(self.ollama_url.as_deref())
.unwrap_or("http://localhost:11434")
}
#[must_use]
#[allow(deprecated)]
pub fn resolve_llm(
&self,
cli_backend: Option<&str>,
cli_model: Option<&str>,
cli_base_url: Option<&str>,
) -> ResolvedLlm {
let env_backend = std::env::var("AI_MEMORY_LLM_BACKEND")
.ok()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let cfg_backend = self
.llm
.as_ref()
.and_then(|l| l.backend.as_ref())
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let (backend, source) = if let Some(b) = cli_backend.map(str::to_ascii_lowercase) {
(b, ConfigSource::Cli)
} else if let Some(b) = env_backend.clone() {
(b, ConfigSource::Env)
} else if let Some(b) = cfg_backend {
(b, ConfigSource::Config)
} else if self.llm_model.is_some() || self.ollama_url.is_some() {
("ollama".to_string(), ConfigSource::Legacy)
} else {
("ollama".to_string(), ConfigSource::CompiledDefault)
};
let model = cli_model
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| {
std::env::var("AI_MEMORY_LLM_MODEL")
.ok()
.filter(|s| !s.trim().is_empty())
})
.or_else(|| {
self.llm
.as_ref()
.and_then(|l| l.model.clone())
.filter(|s| !s.trim().is_empty())
})
.or_else(|| self.llm_model.clone().filter(|s| !s.trim().is_empty()))
.unwrap_or_else(|| backend_default_model(&backend).to_string());
let base_url = cli_base_url
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| {
std::env::var("AI_MEMORY_LLM_BASE_URL")
.ok()
.filter(|s| !s.trim().is_empty())
})
.or_else(|| {
self.llm
.as_ref()
.and_then(|l| l.base_url.clone())
.filter(|s| !s.trim().is_empty())
})
.or_else(|| {
if backend == "ollama" {
self.ollama_url.clone()
} else {
None
}
})
.unwrap_or_else(|| backend_default_base_url(&backend).to_string());
let (api_key, api_key_source) = resolve_api_key(&backend, self.llm.as_ref());
ResolvedLlm {
backend,
model,
base_url,
api_key,
api_key_source,
source,
}
}
#[must_use]
#[allow(deprecated)]
pub fn resolve_llm_auto_tag(&self) -> ResolvedLlm {
let parent = self.resolve_llm(None, None, None);
let sub = self.llm.as_ref().and_then(|l| l.auto_tag.as_ref());
let backend = sub
.and_then(|s| s.backend.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| parent.backend.clone());
let model = sub
.and_then(|s| s.model.clone())
.filter(|s| !s.trim().is_empty())
.or_else(|| self.auto_tag_model.clone().filter(|s| !s.trim().is_empty()))
.unwrap_or_else(|| {
if backend == "ollama" {
"gemma3:4b".to_string()
} else {
parent.model.clone()
}
});
let base_url = sub
.and_then(|s| s.base_url.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
if backend == parent.backend {
parent.base_url.clone()
} else {
backend_default_base_url(&backend).to_string()
}
});
let (api_key, api_key_source) = if backend == parent.backend {
(parent.api_key.clone(), parent.api_key_source.clone())
} else {
let synthetic = sub.map(|s| LlmSection {
backend: Some(backend.clone()),
model: None,
base_url: None,
api_key_env: s.api_key_env.clone(),
api_key_file: s.api_key_file.clone(),
api_key: None,
auto_tag: None,
});
resolve_api_key(&backend, synthetic.as_ref())
};
ResolvedLlm {
backend,
model,
base_url,
api_key,
api_key_source,
source: parent.source,
}
}
#[must_use]
#[allow(deprecated)]
pub fn resolve_embeddings(&self) -> ResolvedEmbeddings {
let cfg = self.embeddings.as_ref();
let env_backend = std::env::var(ENV_EMBED_BACKEND)
.ok()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let backend = env_backend
.clone()
.or_else(|| {
cfg.and_then(|e| e.backend.as_ref())
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| crate::llm::BACKEND_OLLAMA.to_string());
let url = std::env::var(ENV_EMBED_BASE_URL)
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
cfg.and_then(|e| e.base_url.clone())
.filter(|s| !s.trim().is_empty())
})
.or_else(|| {
cfg.and_then(|e| e.url.clone())
.filter(|s| !s.trim().is_empty())
})
.or_else(|| self.embed_url.clone().filter(|s| !s.trim().is_empty()))
.or_else(|| self.ollama_url.clone().filter(|s| !s.trim().is_empty()))
.or_else(|| {
if is_api_embed_backend(&backend) {
crate::llm::default_base_url_for_alias(&backend).map(str::to_string)
} else {
None
}
})
.unwrap_or_else(|| crate::llm::DEFAULT_OLLAMA_URL.to_string());
let model = std::env::var(ENV_EMBED_MODEL)
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
cfg.and_then(|e| e.model.clone())
.filter(|s| !s.trim().is_empty())
})
.or_else(|| {
self.embedding_model
.clone()
.filter(|s| !s.trim().is_empty())
})
.map(canonicalise_embedding_model)
.unwrap_or_else(|| DEFAULT_EMBED_MODEL.to_string());
let backfill_batch_env = std::env::var(ENV_EMBED_BACKFILL_BATCH)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
let backfill_batch_cfg = cfg.and_then(|e| e.backfill_batch);
let backfill_batch_raw = backfill_batch_env.or(backfill_batch_cfg);
let backfill_batch = match backfill_batch_raw {
Some(n) if (1..=10000).contains(&n) => n,
Some(n) => {
tracing::warn!(
"{ENV_EMBED_BACKFILL_BATCH}={n} outside 1..=10000 — falling back to default {DEFAULT_EMBED_BACKFILL_BATCH}"
);
DEFAULT_EMBED_BACKFILL_BATCH
}
None => DEFAULT_EMBED_BACKFILL_BATCH,
};
let source = if env_backend.is_some() {
ConfigSource::Env
} else if cfg.is_some() {
ConfigSource::Config
} else if self.embed_url.is_some()
|| self.embedding_model.is_some()
|| self.ollama_url.is_some()
{
ConfigSource::Legacy
} else {
ConfigSource::CompiledDefault
};
let embedding_dim = cfg
.and_then(|e| e.dim)
.filter(|d| *d > 0)
.or_else(|| canonical_embedding_dim(&model));
let requested_dim = cfg.and_then(|e| e.dim).filter(|d| *d > 0);
let (api_key, key_source) = resolve_embed_api_key(&backend, cfg);
ResolvedEmbeddings {
backend,
url,
model,
backfill_batch,
embedding_dim,
requested_dim,
api_key,
key_source,
source,
}
}
#[must_use]
#[allow(deprecated)]
pub fn resolve_reranker(&self) -> ResolvedReranker {
let cfg = self.reranker.as_ref();
let enabled = cfg
.and_then(|r| r.enabled)
.or(self.cross_encoder)
.unwrap_or(false);
let model = cfg
.and_then(|r| r.model.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "ms-marco-MiniLM-L-6-v2".to_string());
let admissible = |n: &usize| *n > 0 && *n <= crate::reranker::CROSS_ENCODER_MAX_SEQ;
let max_seq_tokens = std::env::var(ENV_RERANK_MAX_SEQ)
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(admissible)
.or_else(|| cfg.and_then(|r| r.max_seq_tokens).filter(admissible))
.unwrap_or(crate::reranker::RERANK_MAX_SEQ_DEFAULT);
let source = if cfg.is_some() {
ConfigSource::Config
} else if self.cross_encoder.is_some() {
ConfigSource::Legacy
} else {
ConfigSource::CompiledDefault
};
ResolvedReranker {
enabled,
model,
max_seq_tokens,
source,
}
}
#[must_use]
pub fn resolve_reranker_score_floor(&self) -> crate::reranker::RerankerScoreFloor {
std::env::var(ENV_RERANK_SCORE_FLOOR)
.ok()
.as_deref()
.and_then(crate::reranker::RerankerScoreFloor::parse)
.or_else(|| {
self.reranker
.as_ref()
.and_then(|r| r.score_floor.as_deref())
.and_then(crate::reranker::RerankerScoreFloor::parse)
})
.unwrap_or(crate::reranker::RerankerScoreFloor::Off)
}
#[must_use]
pub fn reflection_namespace_enabled(&self, namespace: &str) -> bool {
self.curator
.as_ref()
.and_then(|c| c.reflection_namespaces.as_ref())
.and_then(|m| m.get(namespace))
.is_some_and(|cfg| cfg.enabled)
}
#[must_use]
pub fn confidence_decay_half_life_for(&self, namespace: &str) -> f64 {
self.curator
.as_ref()
.and_then(|c| c.confidence_decay_half_life_days.as_ref())
.and_then(|m| m.get(namespace))
.copied()
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(crate::confidence::DEFAULT_HALF_LIFE_DAYS)
}
#[must_use]
pub fn confidence_decay_half_life_overrides(&self) -> std::collections::HashMap<String, f64> {
self.curator
.as_ref()
.and_then(|c| c.confidence_decay_half_life_days.as_ref())
.map(|m| {
m.iter()
.filter(|(_, v)| v.is_finite() && **v > 0.0)
.map(|(k, v)| (k.clone(), *v))
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn resolve_models(&self) -> ResolvedModels {
ResolvedModels {
llm: self.resolve_llm(None, None, None),
embeddings: self.resolve_embeddings(),
reranker: self.resolve_reranker(),
}
}
#[must_use]
#[allow(deprecated)]
pub fn resolve_storage(&self) -> ResolvedStorage {
let cfg = self.storage.as_ref();
let section_ns = cfg
.and_then(|s| s.default_namespace.clone())
.filter(|s| !s.trim().is_empty());
let legacy_ns = self
.default_namespace
.clone()
.filter(|s| !s.trim().is_empty());
let default_namespace_source = if section_ns.is_some() {
ConfigSource::Config
} else if legacy_ns.is_some() {
ConfigSource::Legacy
} else {
ConfigSource::CompiledDefault
};
let default_namespace = section_ns
.or(legacy_ns)
.unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string());
let archive_on_gc = cfg
.and_then(|s| s.archive_on_gc)
.or(self.archive_on_gc)
.unwrap_or(true);
let archive_max_days = cfg
.and_then(|s| s.archive_max_days)
.or(self.archive_max_days);
let max_memory_mb = cfg.and_then(|s| s.max_memory_mb).or(self.max_memory_mb);
let db_mmap_size_bytes = std::env::var(ENV_DB_MMAP_SIZE)
.ok()
.and_then(|s| s.trim().parse::<i64>().ok())
.filter(|n| *n >= 0)
.or_else(|| cfg.and_then(|s| s.db_mmap_size_bytes).filter(|n| *n >= 0))
.unwrap_or(crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES);
let source = if cfg.is_some() {
ConfigSource::Config
} else if self.default_namespace.is_some()
|| self.archive_on_gc.is_some()
|| self.archive_max_days.is_some()
|| self.max_memory_mb.is_some()
{
ConfigSource::Legacy
} else {
ConfigSource::CompiledDefault
};
ResolvedStorage {
default_namespace,
archive_on_gc,
archive_max_days,
max_memory_mb,
db_mmap_size_bytes,
default_namespace_source,
source,
}
}
#[must_use]
pub fn resolve_limits(&self) -> ResolvedLimits {
let cfg = self.limits.as_ref();
fn env_pos_i64(name: &str) -> Option<i64> {
std::env::var(name)
.ok()
.and_then(|s| s.trim().parse::<i64>().ok())
.filter(|n| *n > 0)
}
fn env_pos_usize(name: &str) -> Option<usize> {
std::env::var(name)
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|n| *n > 0)
}
let mem_env = env_pos_i64(ENV_MAX_MEMORIES_PER_DAY);
let mem_cfg = cfg.and_then(|l| l.max_memories_per_day).filter(|n| *n > 0);
let max_memories_per_day = mem_env
.or(mem_cfg)
.unwrap_or(crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY);
let bytes_env = env_pos_i64(ENV_MAX_STORAGE_BYTES);
let bytes_cfg = cfg.and_then(|l| l.max_storage_bytes).filter(|n| *n > 0);
let max_storage_bytes = bytes_env
.or(bytes_cfg)
.unwrap_or(crate::quotas::DEFAULT_MAX_STORAGE_BYTES);
let links_env = env_pos_i64(ENV_MAX_LINKS_PER_DAY);
let links_cfg = cfg.and_then(|l| l.max_links_per_day).filter(|n| *n > 0);
let max_links_per_day = links_env
.or(links_cfg)
.unwrap_or(crate::quotas::DEFAULT_MAX_LINKS_PER_DAY);
let page_env = env_pos_usize(ENV_MAX_PAGE_SIZE);
let page_cfg = cfg.and_then(|l| l.max_page_size).filter(|n| *n > 0);
let max_page_size = page_env
.or(page_cfg)
.unwrap_or(crate::handlers::MAX_BULK_SIZE);
let source = if mem_env.is_some()
|| bytes_env.is_some()
|| links_env.is_some()
|| page_env.is_some()
{
ConfigSource::Env
} else if mem_cfg.is_some()
|| bytes_cfg.is_some()
|| links_cfg.is_some()
|| page_cfg.is_some()
{
ConfigSource::Config
} else {
ConfigSource::CompiledDefault
};
ResolvedLimits {
max_memories_per_day,
max_storage_bytes,
max_links_per_day,
max_page_size,
source,
}
}
#[cfg(feature = "sal")]
#[must_use]
pub fn resolve_pg_pool(&self) -> crate::store::PoolConfig {
fn env_pos_u32(name: &str) -> Option<u32> {
std::env::var(name)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| *n > 0)
}
fn env_pos_u64(name: &str) -> Option<u64> {
std::env::var(name)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
}
let defaults = crate::store::PoolConfig::default();
let max_connections = env_pos_u32(ENV_PG_POOL_MAX)
.or_else(|| self.postgres_pool_max_connections.filter(|n| *n > 0))
.unwrap_or(defaults.max_connections);
let min_connections = env_pos_u32(ENV_PG_POOL_MIN)
.or_else(|| self.postgres_pool_min_connections.filter(|n| *n > 0))
.unwrap_or(defaults.min_connections);
let acquire_timeout_secs = env_pos_u64(ENV_PG_ACQUIRE_TIMEOUT_SECS)
.or_else(|| self.postgres_acquire_timeout_secs.filter(|n| *n > 0))
.unwrap_or(defaults.acquire_timeout_secs);
crate::store::PoolConfig {
max_connections,
min_connections,
acquire_timeout_secs,
}
}
pub fn write_default_if_missing() {
let Some(path) = Self::config_path() else {
return;
};
if path.exists() {
return;
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let default_toml = r#"# ai-memory configuration
# See: https://github.com/alphaonedev/ai-memory-mcp
# Feature tier: keyword, semantic, smart, autonomous
# tier = "semantic"
# Path to SQLite database
# db = "~/.claude/ai-memory.db"
# Ollama base URL (for smart/autonomous tiers)
# ollama_url = "http://localhost:11434"
# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
# embedding_model = "mini_lm_l6_v2"
# LLM model tag for Ollama
# llm_model = "gemma4:e2b"
# Dedicated model for auto_tag (short structured output).
# Defaults to gemma3:4b. Reasoning-heavy features still use llm_model.
# auto_tag_model = "gemma3:4b"
# Enable neural cross-encoder reranking (autonomous tier)
# cross_encoder = true
# Default namespace for new memories
# default_namespace = "global"
# Memory budget in MB (for auto tier selection)
# max_memory_mb = 4096
# Archive expired memories before GC deletion (default: true)
# archive_on_gc = true
# Postgres connection-pool sizing (postgres store only; sqlite ignores).
# Precedence per field: AI_MEMORY_PG_POOL_MAX / _MIN /
# _ACQUIRE_TIMEOUT_SECS env > these fields > compiled default.
# Non-positive / unparseable values fall through to the default.
# postgres_pool_max_connections = 16 # hard ceiling on open connections
# postgres_pool_min_connections = 2 # always-open warm-connection floor
# postgres_acquire_timeout_secs = 30 # acquire() wait before erroring (secs)
# Per-tier TTL overrides (uncomment to customize)
# [ttl]
# short_ttl_secs = 21600 # 6 hours (default)
# mid_ttl_secs = 604800 # 7 days (default)
# long_ttl_secs = 0 # 0 = never expires (default)
# short_extend_secs = 3600 # +1h on access (default)
# mid_extend_secs = 86400 # +1d on access (default)
# v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
# Default-OFF. Uncomment + set enabled = true to capture every
# `tracing::*` call site to a rotating on-disk log file. See
# `docs/security/audit-trail.md` §SIEM ingestion guide for Splunk /
# Datadog / Elastic / Loki recipes.
# [logging]
# enabled = false
# path = "~/.local/state/ai-memory/logs/"
# max_size_mb = 100
# max_files = 30
# retention_days = 90
# structured = false # true = emit JSON lines for SIEM ingest
# level = "info" # tracing EnvFilter directive
# rotation = "daily" # minutely | hourly | daily | never
# v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF.
# When enabled, every memory mutation emits one hash-chained JSON
# line per event suitable for SOC2 / HIPAA / GDPR / FedRAMP evidence.
# `ai-memory audit verify` walks the chain; `ai-memory logs tail`
# streams events.
# [audit]
# enabled = false
# path = "~/.local/state/ai-memory/audit/"
# schema_version = 1
# redact_content = true # v1 schema never emits content; reserved
# hash_chain = true
# attestation_cadence_minutes = 60
# append_only = true # best-effort chflags(2) / FS_IOC_SETFLAGS
# Compliance presets. Set `applied = true` and the documented retention
# / cadence values override the defaults above. See
# `docs/security/audit-trail.md` §Compliance.
# [audit.compliance.soc2]
# applied = false
# retention_days = 730
# redact_content = true
# attestation_cadence_minutes = 60
#
# [audit.compliance.hipaa]
# applied = false
# retention_days = 2190
# redact_content = true
# encrypt_at_rest = true # pair with --features sqlcipher
#
# [audit.compliance.gdpr]
# applied = false
# retention_days = 1095
# redact_content = true
# pseudonymize_actors = true # reserved for v0.7+
#
# [audit.compliance.fedramp]
# applied = false
# retention_days = 1095
# redact_content = true
# attestation_cadence_minutes = 30
# v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy controls.
# Default-ON (omit the section entirely for the historical pre-v0.6.3.1
# behavior). Two knobs:
#
# - `enabled = false` silences `ai-memory boot` entirely: empty stdout,
# empty stderr, exit 0. The SessionStart hook injects nothing. Use on
# privacy-sensitive hosts where memory titles must never enter CI
# logs. The env var `AI_MEMORY_BOOT_ENABLED=0` takes precedence over
# this config (same precedence pattern as PR-5's log-dir resolution).
#
# - `redact_titles = true` keeps the manifest header but replaces row
# `title` fields with `<redacted>` — useful for compliance contexts
# that need the audit-trail signal of "boot ran with N memories"
# without exposing memory subjects.
# [boot]
# enabled = true
# redact_titles = false
"#;
let _ = std::fs::write(&path, default_toml);
}
}
#[cfg(test)]
#[allow(deprecated)] mod tests {
use super::*;
fn env_var_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn tier_roundtrip() {
for tier in [
FeatureTier::Keyword,
FeatureTier::Semantic,
FeatureTier::Smart,
FeatureTier::Autonomous,
] {
assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
}
}
#[test]
fn budget_selection() {
assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
assert_eq!(
FeatureTier::from_memory_budget(4096),
FeatureTier::Autonomous
);
assert_eq!(
FeatureTier::from_memory_budget(8192),
FeatureTier::Autonomous
);
}
#[test]
fn embedding_dimensions() {
assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
}
#[test]
fn embedding_model_from_str() {
use std::str::FromStr;
assert_eq!(
EmbeddingModel::from_str("mini_lm_l6_v2").unwrap(),
EmbeddingModel::MiniLmL6V2
);
assert_eq!(
EmbeddingModel::from_str("nomic_embed_v15").unwrap(),
EmbeddingModel::NomicEmbedV15
);
assert_eq!(
EmbeddingModel::from_str("MINI_LM_L6_V2").unwrap(),
EmbeddingModel::MiniLmL6V2
);
assert_eq!(
EmbeddingModel::from_str("Nomic_Embed_V15").unwrap(),
EmbeddingModel::NomicEmbedV15
);
assert_eq!(
EmbeddingModel::from_str(" mini_lm_l6_v2 ").unwrap(),
EmbeddingModel::MiniLmL6V2
);
let err = EmbeddingModel::from_str("garbage").unwrap_err();
assert!(err.contains("garbage"), "err message lost the input: {err}");
assert!(
err.contains("mini_lm_l6_v2") && err.contains("nomic_embed_v15"),
"err message should list valid options: {err}"
);
}
#[test]
fn embedding_model_from_canonical_id_accepts_all_forms() {
for id in [
"nomic_embed_v15",
"nomic-embed-text-v1.5",
"nomic-embed-text",
"nomic-ai/nomic-embed-text-v1.5",
] {
assert_eq!(
EmbeddingModel::from_canonical_id(id),
Some(EmbeddingModel::NomicEmbedV15),
"nomic alias {id:?} must resolve"
);
}
for id in [
"mini_lm_l6_v2",
"sentence-transformers/all-MiniLM-L6-v2",
"all-MiniLM-L6-v2",
"all-minilm",
] {
assert_eq!(
EmbeddingModel::from_canonical_id(id),
Some(EmbeddingModel::MiniLmL6V2),
"minilm alias {id:?} must resolve"
);
}
assert_eq!(
EmbeddingModel::from_canonical_id(&canonicalise_embedding_model(
"nomic_embed_v15".to_string()
)),
Some(EmbeddingModel::NomicEmbedV15)
);
assert_eq!(
EmbeddingModel::from_canonical_id(" NOMIC-EMBED-TEXT-V1.5 "),
Some(EmbeddingModel::NomicEmbedV15)
);
assert_eq!(EmbeddingModel::from_canonical_id("bge-large-en"), None);
assert_eq!(EmbeddingModel::from_canonical_id("mxbai-embed-large"), None);
assert_eq!(EmbeddingModel::from_canonical_id(""), None);
assert_eq!(EmbeddingModel::from_canonical_id(" "), None);
}
#[test]
fn autonomous_has_cross_encoder() {
let cfg = FeatureTier::Autonomous.config();
assert!(cfg.cross_encoder);
let caps = cfg.capabilities();
assert!(caps.features.cross_encoder_reranking);
assert!(!caps.features.memory_reflection.planned);
assert!(caps.features.memory_reflection.enabled);
assert_eq!(caps.features.memory_reflection.version, "v0.7.0");
}
#[test]
fn keyword_has_no_models() {
let cfg = FeatureTier::Keyword.config();
assert!(cfg.embedding_model.is_none());
assert!(cfg.llm_model.is_none());
assert!(!cfg.cross_encoder);
assert_eq!(cfg.max_memory_mb, 0);
}
#[test]
fn capabilities_serialize() {
let caps = FeatureTier::Smart.config().capabilities();
let json = serde_json::to_string_pretty(&caps).unwrap();
assert!(json.contains("\"tier\": \"smart\""));
assert!(json.contains("nomic"));
assert!(json.contains(default_tier_llm_model()));
}
#[test]
fn capabilities_v2_zero_state_round_trip() {
let _gate = lock_permissions_mode_for_test();
clear_permissions_mode_override_for_test();
let caps = FeatureTier::Keyword.config().capabilities();
let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
assert_eq!(val["schema_version"], "2");
assert_eq!(val["permissions"]["mode"], "advisory");
assert_eq!(val["permissions"]["active_rules"], 0);
assert!(
val["permissions"].get("rule_summary").is_none(),
"v2 honesty patch drops `permissions.rule_summary` (no per-rule serializer)"
);
assert_eq!(val["permissions"]["inheritance"], "enforced");
assert_eq!(val["hooks"]["registered_count"], 0);
assert!(
val["hooks"].get("by_event").is_none(),
"v2 honesty patch drops `hooks.by_event` (no event registry)"
);
assert_eq!(val["hooks"]["registered_count"], 0);
assert!(
val["hooks"].get("by_event").is_none(),
"v2 drops hooks.by_event (no event registry)"
);
let events = val["hooks"]["webhook_events"].as_array().unwrap();
assert_eq!(events.len(), 7);
for expected in [
"memory_store",
"memory_promote",
"memory_delete",
"memory_link_created",
"memory_link_invalidated",
"memory_consolidated",
"approval_requested",
] {
assert!(
events.iter().any(|v| v.as_str() == Some(expected)),
"webhook_events missing {expected}"
);
}
assert_eq!(val["compaction"]["planned"], true);
assert_eq!(val["compaction"]["enabled"], false);
assert_eq!(val["compaction"]["version"], "v0.8+");
assert!(
val["compaction"].get("interval_minutes").is_none(),
"Option::None values must be skipped in serialization"
);
assert!(val["compaction"].get("last_run_at").is_none());
assert!(val["compaction"].get("last_run_stats").is_none());
assert_eq!(val["approval"]["pending_requests"], 0);
assert!(
val["approval"].get("subscribers").is_none(),
"v2 honesty patch drops `approval.subscribers` (no subscription API)"
);
assert!(
val["approval"].get("default_timeout_seconds").is_none(),
"v2 honesty patch drops `approval.default_timeout_seconds` (no sweeper)"
);
assert_eq!(val["transcripts"]["planned"], false);
assert_eq!(val["transcripts"]["enabled"], false);
assert_eq!(val["transcripts"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(val["features"]["memory_reflection"]["planned"], false);
assert_eq!(val["features"]["memory_reflection"]["enabled"], true);
assert_eq!(val["features"]["memory_reflection"]["version"], "v0.7.0");
assert_eq!(val["features"]["recall_mode_active"], "disabled");
assert_eq!(val["features"]["reranker_active"], "off");
assert!(
val.get("kg_backend").is_none(),
"kg_backend must be skipped from JSON when None (pre-J2 zero-state)"
);
let restored: Capabilities = serde_json::from_value(val).unwrap();
assert_eq!(restored.schema_version, "2");
assert_eq!(restored.permissions.mode, "advisory");
assert!(restored.compaction.status.planned);
assert!(!restored.transcripts.status.planned);
assert_eq!(restored.features.recall_mode_active, RecallMode::Disabled);
assert_eq!(restored.features.reranker_active, RerankerMode::Off);
assert!(restored.kg_backend.is_none());
}
#[test]
fn capabilities_kg_backend_serialises_when_set() {
let mut caps = FeatureTier::Keyword.config().capabilities();
caps.kg_backend = Some("age".to_string());
let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
assert_eq!(val["kg_backend"], "age");
caps.kg_backend = Some("cte".to_string());
let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
assert_eq!(val["kg_backend"], "cte");
let restored: Capabilities = serde_json::from_value(val).unwrap();
assert_eq!(restored.kg_backend.as_deref(), Some("cte"));
}
#[test]
fn capabilities_v1_projection_preserves_legacy_shape() {
let caps = FeatureTier::Autonomous.config().capabilities();
let v1 = caps.to_v1();
let val: serde_json::Value = serde_json::to_value(&v1).unwrap();
assert!(
val.get("schema_version").is_none(),
"v1 has no schema_version"
);
assert!(
val.get("permissions").is_none(),
"v1 has no permissions block"
);
assert!(val.get("hooks").is_none());
assert!(val.get("compaction").is_none());
assert!(val.get("approval").is_none());
assert!(val.get("transcripts").is_none());
assert!(val["tier"].is_string());
assert!(val["version"].is_string());
assert!(val["features"].is_object());
assert!(val["models"].is_object());
assert!(val["features"]["memory_reflection"].is_boolean());
assert_eq!(val["features"]["memory_reflection"], true);
assert!(val["features"].get("recall_mode_active").is_none());
assert!(val["features"].get("reranker_active").is_none());
}
#[test]
fn config_default_is_empty() {
let cfg = AppConfig::default();
assert!(cfg.tier.is_none());
assert!(cfg.db.is_none());
assert!(cfg.ollama_url.is_none());
}
#[test]
fn config_parse_toml() {
let toml_str = r#"
tier = "smart"
db = "/tmp/test.db"
ollama_url = "http://localhost:11434"
cross_encoder = true
"#;
let cfg: AppConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.tier.as_deref(), Some("smart"));
assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
assert!(cfg.cross_encoder.unwrap());
}
#[test]
fn resolved_ttl_defaults_match_hardcoded() {
let resolved = ResolvedTtl::default();
assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR));
assert_eq!(resolved.mid_ttl_secs, Some(crate::SECS_PER_WEEK));
assert_eq!(resolved.long_ttl_secs, None);
assert_eq!(resolved.short_extend_secs, crate::SECS_PER_HOUR);
assert_eq!(resolved.mid_extend_secs, crate::SECS_PER_DAY);
}
#[test]
fn resolved_ttl_from_partial_config() {
let cfg = TtlConfig {
mid_ttl_secs: Some(90 * crate::SECS_PER_DAY), ..Default::default()
};
let resolved = ResolvedTtl::from_config(Some(&cfg));
assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR)); assert_eq!(resolved.mid_ttl_secs, Some(90 * crate::SECS_PER_DAY)); assert_eq!(resolved.long_ttl_secs, None); }
#[test]
fn resolved_ttl_zero_means_no_expiry() {
let cfg = TtlConfig {
short_ttl_secs: Some(0),
mid_ttl_secs: Some(0),
..Default::default()
};
let resolved = ResolvedTtl::from_config(Some(&cfg));
assert_eq!(resolved.short_ttl_secs, None); assert_eq!(resolved.mid_ttl_secs, None);
}
#[test]
fn resolved_ttl_clamps_overflow() {
let cfg = TtlConfig {
mid_ttl_secs: Some(i64::MAX),
short_extend_secs: Some(-crate::SECS_PER_HOUR),
..Default::default()
};
let resolved = ResolvedTtl::from_config(Some(&cfg));
assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
assert_eq!(resolved.short_extend_secs, 0);
}
#[test]
fn ttl_config_parse_toml() {
let toml_str = r#"
tier = "semantic"
archive_on_gc = false
[ttl]
mid_ttl_secs = 7776000
short_extend_secs = 7200
"#;
let cfg: AppConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
assert!(!cfg.effective_archive_on_gc());
}
#[test]
fn resolved_ttl_tier_methods() {
let resolved = ResolvedTtl::default();
assert_eq!(
resolved.ttl_for_tier(&Tier::Short),
Some(6 * crate::SECS_PER_HOUR)
);
assert_eq!(
resolved.ttl_for_tier(&Tier::Mid),
Some(crate::SECS_PER_WEEK)
);
assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
assert_eq!(
resolved.extend_for_tier(&Tier::Short),
Some(crate::SECS_PER_HOUR)
);
assert_eq!(
resolved.extend_for_tier(&Tier::Mid),
Some(crate::SECS_PER_DAY)
);
assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
}
#[test]
fn config_effective_tier() {
let cfg = AppConfig {
tier: Some("smart".to_string()),
..Default::default()
};
assert_eq!(
cfg.effective_tier(Some("autonomous")),
FeatureTier::Autonomous
);
assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
}
#[test]
fn scoring_defaults_match_spec() {
let s = ResolvedScoring::default();
assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
assert!(!s.legacy_scoring);
}
#[test]
fn scoring_from_config_overrides() {
let cfg = RecallScoringConfig {
half_life_days_short: Some(3.5),
half_life_days_mid: Some(14.0),
half_life_days_long: Some(730.0),
legacy_scoring: false,
};
let s = ResolvedScoring::from_config(Some(&cfg));
assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
}
#[test]
fn scoring_clamps_out_of_range() {
let cfg = RecallScoringConfig {
half_life_days_short: Some(-10.0),
half_life_days_mid: Some(0.0),
half_life_days_long: Some(1_000_000.0),
legacy_scoring: false,
};
let s = ResolvedScoring::from_config(Some(&cfg));
assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
}
#[test]
fn scoring_decay_at_half_life_is_half() {
let s = ResolvedScoring::default();
let d = s.decay_multiplier(&Tier::Short, 7.0);
assert!((d - 0.5).abs() < 1e-9);
let d = s.decay_multiplier(&Tier::Mid, 30.0);
assert!((d - 0.5).abs() < 1e-9);
let d = s.decay_multiplier(&Tier::Long, 365.0);
assert!((d - 0.5).abs() < 1e-9);
}
#[test]
fn scoring_decay_monotonic() {
let s = ResolvedScoring::default();
let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
assert!(d_new > d_old);
assert!(d_new < 1.0);
assert!(d_old > 0.0);
}
#[test]
fn scoring_decay_zero_age_is_one() {
let s = ResolvedScoring::default();
assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn scoring_legacy_disables_decay() {
let cfg = RecallScoringConfig {
legacy_scoring: true,
..Default::default()
};
let s = ResolvedScoring::from_config(Some(&cfg));
assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn effective_scoring_on_empty_config() {
let cfg = AppConfig::default();
let s = cfg.effective_scoring();
assert_eq!(s.half_life_days_short, 7.0);
assert!(!s.legacy_scoring);
}
#[test]
fn scoring_roundtrip_through_toml() {
let toml_src = r"
[scoring]
half_life_days_short = 5.0
half_life_days_mid = 25.0
legacy_scoring = false
";
let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
let s = cfg.effective_scoring();
assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
}
#[test]
fn effective_tier_cli_overrides_config() {
let cfg = AppConfig {
tier: Some("smart".to_string()),
..AppConfig::default()
};
assert_eq!(
cfg.effective_tier(Some("autonomous")),
FeatureTier::Autonomous
);
assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
}
#[test]
fn effective_tier_unknown_falls_back_to_semantic() {
let cfg = AppConfig::default();
assert_eq!(
cfg.effective_tier(Some("invalid-tier")),
FeatureTier::Semantic
);
assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
}
#[test]
fn effective_profile_cli_or_env_overrides_config() {
let cfg = AppConfig {
mcp: Some(McpConfig {
profile: Some("graph".to_string()),
allowlist: None,
..McpConfig::default()
}),
..AppConfig::default()
};
assert_eq!(
cfg.effective_profile(Some("admin")).unwrap(),
crate::profile::Profile::admin()
);
assert_eq!(
cfg.effective_profile(None).unwrap(),
crate::profile::Profile::graph()
);
}
#[test]
fn effective_profile_falls_back_to_core_default() {
let cfg = AppConfig::default();
assert_eq!(
cfg.effective_profile(None).unwrap(),
crate::profile::Profile::core()
);
}
#[test]
fn effective_profile_surfaces_parse_error_for_unknown_family() {
let cfg = AppConfig::default();
assert!(matches!(
cfg.effective_profile(Some("xyz")),
Err(crate::profile::ProfileParseError::UnknownFamily(_))
));
}
#[test]
fn effective_profile_surfaces_parse_error_for_mixed_case() {
let cfg = AppConfig::default();
assert!(matches!(
cfg.effective_profile(Some("Core")),
Err(crate::profile::ProfileParseError::CaseMismatch(_))
));
}
fn allowlist_table(rows: &[(&str, &[&str])]) -> McpConfig {
let mut map = std::collections::HashMap::new();
for (k, v) in rows {
map.insert(
(*k).to_string(),
v.iter().map(|s| (*s).to_string()).collect(),
);
}
McpConfig {
profile: None,
allowlist: Some(map),
..McpConfig::default()
}
}
#[test]
fn allowlist_disabled_when_table_absent() {
let cfg = McpConfig::default();
assert_eq!(
cfg.allowlist_decision(Some("alice"), "graph"),
AllowlistDecision::Disabled
);
}
#[test]
fn allowlist_disabled_when_table_empty() {
let cfg = McpConfig {
profile: None,
allowlist: Some(std::collections::HashMap::new()),
..McpConfig::default()
};
assert_eq!(
cfg.allowlist_decision(Some("alice"), "graph"),
AllowlistDecision::Disabled
);
}
#[test]
fn allowlist_exact_match_grants_or_denies_per_family_set() {
let cfg = allowlist_table(&[("alice", &["core", "graph"]), ("*", &["core"])]);
assert_eq!(
cfg.allowlist_decision(Some("alice"), "graph"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("alice"), "power"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_full_grants_every_family() {
let cfg = allowlist_table(&[("bob", &["full"])]);
assert_eq!(
cfg.allowlist_decision(Some("bob"), "graph"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("bob"), "archive"),
AllowlistDecision::Allow
);
}
#[test]
fn allowlist_wildcard_default_for_unknown_agents() {
let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
assert_eq!(
cfg.allowlist_decision(Some("eve"), "core"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("eve"), "graph"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_default_deny_when_no_wildcard() {
let cfg = allowlist_table(&[("alice", &["full"])]);
assert_eq!(
cfg.allowlist_decision(Some("eve"), "core"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_longest_prefix_match_wins() {
let cfg = allowlist_table(&[
("ai:", &["core"]),
("ai:claude-code", &["full"]),
("*", &["core"]),
]);
assert_eq!(
cfg.allowlist_decision(Some("ai:claude-code@host"), "graph"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("ai:codex@host"), "graph"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_no_agent_id_uses_wildcard() {
let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
assert_eq!(
cfg.allowlist_decision(None, "core"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(None, "graph"),
AllowlistDecision::Deny
);
}
#[test]
fn effective_db_cli_path_wins_when_non_default() {
let cfg = AppConfig {
db: Some("/from/config.db".to_string()),
..AppConfig::default()
};
let cli_path = Path::new("/from/cli.db");
assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
}
#[test]
fn effective_db_falls_back_to_config_when_cli_default() {
let cfg = AppConfig {
db: Some("/from/config.db".to_string()),
..AppConfig::default()
};
assert_eq!(
cfg.effective_db(Path::new("ai-memory.db")),
PathBuf::from("/from/config.db")
);
}
#[test]
fn effective_db_falls_back_to_cli_when_no_config() {
let cfg = AppConfig::default();
let cli_path = Path::new("ai-memory.db");
assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
}
#[test]
fn effective_db_expands_tilde_against_home() {
let _g = env_var_lock();
let prev_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", "/expanded/home") };
let cfg = AppConfig {
db: Some("~/.claude/ai-memory.db".to_string()),
..AppConfig::default()
};
assert_eq!(
cfg.effective_db(Path::new("ai-memory.db")),
PathBuf::from("/expanded/home/.claude/ai-memory.db")
);
let cfg_bare = AppConfig {
db: Some("~".to_string()),
..AppConfig::default()
};
assert_eq!(
cfg_bare.effective_db(Path::new("ai-memory.db")),
PathBuf::from("/expanded/home")
);
match prev_home {
Some(h) => unsafe { std::env::set_var("HOME", h) },
None => unsafe { std::env::remove_var("HOME") },
}
}
#[test]
fn effective_ollama_url_default_when_unset() {
let cfg = AppConfig::default();
assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
}
#[test]
fn effective_ollama_url_uses_configured_value() {
let cfg = AppConfig {
ollama_url: Some("http://my-host:9999".to_string()),
..AppConfig::default()
};
assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
}
#[test]
fn effective_embed_url_falls_back_to_ollama_url() {
let cfg = AppConfig {
ollama_url: Some("http://ollama:11434".to_string()),
..AppConfig::default()
};
assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
}
#[test]
fn effective_embed_url_uses_dedicated_value_when_set() {
let cfg = AppConfig {
ollama_url: Some("http://ollama:11434".to_string()),
embed_url: Some("http://embed:8080".to_string()),
..AppConfig::default()
};
assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
}
#[test]
fn effective_embed_url_uses_default_when_neither_set() {
let cfg = AppConfig::default();
assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
}
#[test]
fn effective_archive_on_gc_default_is_true() {
let cfg = AppConfig::default();
assert!(cfg.effective_archive_on_gc());
}
#[test]
fn effective_archive_on_gc_respects_explicit_false() {
let cfg = AppConfig {
archive_on_gc: Some(false),
..AppConfig::default()
};
assert!(!cfg.effective_archive_on_gc());
}
#[test]
fn effective_autonomous_hooks_default_is_false() {
let _g = env_var_lock();
unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
let cfg = AppConfig::default();
assert!(!cfg.effective_autonomous_hooks());
}
#[test]
fn effective_autonomous_hooks_config_value_used_when_env_unset() {
let _g = env_var_lock();
unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
let cfg = AppConfig {
autonomous_hooks: Some(true),
..AppConfig::default()
};
assert!(cfg.effective_autonomous_hooks());
}
#[test]
fn effective_anonymize_default_falls_back_to_config() {
let _g = env_var_lock();
unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
let cfg = AppConfig::default();
assert!(!cfg.effective_anonymize_default());
}
#[test]
fn write_default_if_missing_creates_file_then_noops() {
let _g = env_var_lock();
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("HOME", tmp.path()) };
AppConfig::write_default_if_missing();
let expected = AppConfig::config_path().unwrap();
assert!(expected.exists(), "config not written at {expected:?}");
let original = std::fs::read_to_string(&expected).unwrap();
assert!(original.contains("ai-memory configuration"));
std::fs::write(&expected, "# user-edited\n").unwrap();
AppConfig::write_default_if_missing();
let after = std::fs::read_to_string(&expected).unwrap();
assert_eq!(after, "# user-edited\n");
}
#[test]
fn config_path_returns_some_when_home_set() {
let _g = env_var_lock();
unsafe { std::env::set_var("HOME", "/some/home") };
let path = AppConfig::config_path().unwrap();
assert!(path.starts_with("/some/home"));
}
#[test]
fn load_from_returns_default_for_missing_file() {
let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
assert!(cfg.tier.is_none());
assert!(cfg.db.is_none());
}
#[test]
fn load_from_returns_default_for_unparseable_toml() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
let cfg = AppConfig::load_from(tmp.path());
assert!(cfg.tier.is_none());
}
#[test]
fn load_from_parses_valid_toml() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"
tier = "smart"
db = "/disk.db"
"#,
)
.unwrap();
let cfg = AppConfig::load_from(tmp.path());
assert_eq!(cfg.tier.as_deref(), Some("smart"));
assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
}
#[test]
fn auto_extract_default_off_when_no_namespaces_block() {
let cfg = TranscriptsConfig::default();
assert!(!cfg.auto_extract_for("agent/claude"));
assert!(!cfg.auto_extract_for("anything"));
}
#[test]
fn auto_extract_exact_namespace_match_wins() {
let mut nss = std::collections::HashMap::new();
nss.insert(
"agent/claude".into(),
TranscriptNamespaceConfig {
auto_extract: Some(true),
..Default::default()
},
);
nss.insert(
"*".into(),
TranscriptNamespaceConfig {
auto_extract: Some(false),
..Default::default()
},
);
let cfg = TranscriptsConfig {
namespaces: Some(nss),
..Default::default()
};
assert!(cfg.auto_extract_for("agent/claude"));
assert!(!cfg.auto_extract_for("agent/gpt"));
}
#[test]
fn auto_extract_prefix_match_then_wildcard_fallback() {
let mut nss = std::collections::HashMap::new();
nss.insert(
"team/security/*".into(),
TranscriptNamespaceConfig {
auto_extract: Some(true),
..Default::default()
},
);
nss.insert(
"*".into(),
TranscriptNamespaceConfig {
auto_extract: Some(false),
..Default::default()
},
);
let cfg = TranscriptsConfig {
namespaces: Some(nss),
..Default::default()
};
assert!(cfg.auto_extract_for("team/security/audit"));
assert!(!cfg.auto_extract_for("team/eng/main"));
}
#[test]
fn auto_extract_unset_field_inherits_default_off() {
let mut nss = std::collections::HashMap::new();
nss.insert(
"agent/claude".into(),
TranscriptNamespaceConfig {
default_ttl_secs: Some(crate::SECS_PER_HOUR),
auto_extract: None,
..Default::default()
},
);
let cfg = TranscriptsConfig {
namespaces: Some(nss),
..Default::default()
};
assert!(!cfg.auto_extract_for("agent/claude"));
}
#[test]
fn load_from_warns_on_unknown_top_level_key_but_still_loads() {
let toml_src = "tier = \"autonomous\"\n\n[memory]\ntier = \"ignored\"\n";
let tmp = tempfile::NamedTempFile::new().expect("create temp file");
std::fs::write(tmp.path(), toml_src).expect("write temp config");
let cfg = AppConfig::load_from(tmp.path());
assert_eq!(
cfg.tier.as_deref(),
Some("autonomous"),
"top-level `tier` must survive even when an unknown `[memory]` table is present",
);
}
#[test]
fn warn_unknown_top_level_keys_covers_every_appconfig_field() {
let cfg = AppConfig {
tier: Some("keyword".into()),
db: Some(String::new()),
ollama_url: Some(String::new()),
embed_url: Some(String::new()),
embedding_model: Some(String::new()),
llm_model: Some(String::new()),
auto_tag_model: Some(String::new()),
cross_encoder: Some(false),
default_namespace: Some(String::new()),
max_memory_mb: Some(0),
ttl: Some(TtlConfig::default()),
archive_on_gc: Some(false),
api_key: Some(String::new()),
archive_max_days: Some(0),
identity: Some(IdentityConfig::default()),
scoring: Some(RecallScoringConfig::default()),
autonomous_hooks: Some(false),
logging: Some(LoggingConfig::default()),
audit: Some(AuditConfig::default()),
boot: Some(BootConfig::default()),
mcp: Some(McpConfig::default()),
permissions: Some(PermissionsConfig::default()),
transcripts: Some(TranscriptsConfig::default()),
hooks: Some(HooksConfig::default()),
subscriptions: Some(SubscriptionsConfig::default()),
postgres_statement_timeout_secs: Some(30),
postgres_pool_max_connections: Some(16),
postgres_pool_min_connections: Some(2),
postgres_acquire_timeout_secs: Some(30),
request_timeout_secs: Some(60),
llm_call_timeout_secs: Some(30),
verify: Some(VerifyConfig::default()),
mcp_federation_forward_url: Some(String::new()),
agents: Some(AgentsConfig::default()),
governance: Some(GovernanceConfig::default()),
confidence: Some(ConfidenceConfig::default()),
admin: Some(AdminConfig::default()),
schema_version: Some(2),
llm: Some(LlmSection::default()),
embeddings: Some(EmbeddingsSection::default()),
reranker: Some(RerankerSection::default()),
curator: Some(CuratorSection::default()),
storage: Some(StorageSection::default()),
limits: Some(LimitsSection::default()),
};
let serialised = toml::to_string(&cfg).expect("serialise AppConfig to TOML");
let value: toml::Value =
toml::from_str(&serialised).expect("re-parse serialised AppConfig");
let table = value.as_table().expect("serialised AppConfig is a table");
const EXPECTED_KEYS: &[&str] = &[
"tier",
"db",
"ollama_url",
"embed_url",
"embedding_model",
"llm_model",
"auto_tag_model",
"cross_encoder",
"default_namespace",
"max_memory_mb",
"ttl",
"archive_on_gc",
"api_key",
"archive_max_days",
"identity",
"scoring",
"autonomous_hooks",
"logging",
"audit",
"boot",
"mcp",
"permissions",
"transcripts",
"hooks",
"subscriptions",
"postgres_statement_timeout_secs",
"postgres_pool_max_connections",
"postgres_pool_min_connections",
"postgres_acquire_timeout_secs",
"request_timeout_secs",
"llm_call_timeout_secs",
"verify",
"mcp_federation_forward_url",
"agents",
"governance",
"confidence",
"admin",
"schema_version",
"llm",
"embeddings",
"reranker",
"curator",
"storage",
"limits",
];
for key in table.keys() {
assert!(
EXPECTED_KEYS.contains(&key.as_str()),
"AppConfig field `{key}` is not in EXPECTED_KEYS — \
update `warn_unknown_top_level_keys` to keep parity",
);
}
}
#[test]
fn auto_tag_model_default_falls_back_to_none_and_template_documents_default_gemma3_4b() {
let cfg = AppConfig::default();
assert!(
cfg.auto_tag_model.is_none(),
"fresh AppConfig must leave auto_tag_model = None so callers \
fall back to llm_model"
);
let _g = env_var_lock();
let tmp = tempfile::tempdir().expect("tempdir");
unsafe { std::env::set_var("HOME", tmp.path()) };
AppConfig::write_default_if_missing();
let written = AppConfig::config_path().expect("config_path resolves");
let contents = std::fs::read_to_string(&written).expect("default toml written");
assert!(
contents.contains("auto_tag_model"),
"default config.toml must document the auto_tag_model knob; \
got:\n{contents}"
);
assert!(
contents.contains("gemma3:4b"),
"default config.toml must mention gemma3:4b as the L15 \
recommended default; got:\n{contents}"
);
}
#[test]
fn tier_llm_model_is_agnostic_gate() {
assert!(FeatureTier::Keyword.config().llm_model.is_none());
assert!(FeatureTier::Semantic.config().llm_model.is_none());
assert_eq!(
FeatureTier::Smart.config().llm_model.as_deref(),
Some(default_tier_llm_model())
);
assert_eq!(
FeatureTier::Autonomous.config().llm_model.as_deref(),
Some(default_tier_llm_model())
);
assert_eq!(
default_tier_llm_model(),
backend_default_model(crate::llm::BACKEND_OLLAMA)
);
}
#[test]
fn feature_tier_display_matches_as_str() {
assert_eq!(format!("{}", FeatureTier::Keyword), "keyword");
assert_eq!(format!("{}", FeatureTier::Semantic), "semantic");
assert_eq!(format!("{}", FeatureTier::Smart), "smart");
assert_eq!(format!("{}", FeatureTier::Autonomous), "autonomous");
}
#[test]
fn default_recall_mode_is_disabled() {
assert_eq!(default_recall_mode(), RecallMode::Disabled);
}
#[test]
fn default_reranker_mode_is_off() {
assert_eq!(default_reranker_mode(), RerankerMode::Off);
}
#[test]
fn default_hook_events_count_matches_constant() {
assert_eq!(default_hook_events_count(), HOOK_EVENTS_COUNT);
}
#[test]
fn default_reflection_boost_returns_default_report() {
let r = default_reflection_boost();
let d = ReflectionBoostReport::default();
assert_eq!(format!("{r:?}"), format!("{d:?}"));
}
#[test]
fn permissions_mode_default_is_advisory() {
let m: PermissionsMode = Default::default();
assert_eq!(m, PermissionsMode::Advisory);
}
#[test]
fn active_permissions_mode_uses_named_fallback_when_unset_then_honors_setter() {
let _serialise = lock_permissions_mode_for_test();
clear_permissions_mode_override_for_test();
assert_eq!(
active_permissions_mode(),
UNINITIALIZED_PERMISSIONS_MODE_FALLBACK,
"unset gate must return the named pre-init fallback"
);
set_active_permissions_mode(PermissionsMode::Enforce);
assert_eq!(
active_permissions_mode(),
PermissionsMode::Enforce,
"installed mode must win over the fallback"
);
clear_permissions_mode_override_for_test();
}
#[test]
fn set_allow_loopback_webhooks_round_trips() {
let prior = ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst);
set_allow_loopback_webhooks(true);
assert!(ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
set_allow_loopback_webhooks(false);
assert!(!ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
ALLOW_LOOPBACK_WEBHOOKS.store(prior, std::sync::atomic::Ordering::SeqCst);
}
#[test]
fn reset_permissions_decision_counts_zeros_all_atomics() {
let _serialise = lock_permissions_mode_for_test();
reset_permissions_decision_counts_for_test();
record_permissions_decision(PermissionsMode::Enforce);
record_permissions_decision(PermissionsMode::Enforce);
record_permissions_decision(PermissionsMode::Enforce);
record_permissions_decision(PermissionsMode::Enforce);
record_permissions_decision(PermissionsMode::Enforce);
record_permissions_decision(PermissionsMode::Advisory);
record_permissions_decision(PermissionsMode::Advisory);
record_permissions_decision(PermissionsMode::Advisory);
record_permissions_decision(PermissionsMode::Off);
let pre = permissions_decision_counts();
assert_eq!(pre.enforce, 5);
assert_eq!(pre.advisory, 3);
assert_eq!(pre.off, 1);
reset_permissions_decision_counts_for_test();
let post = permissions_decision_counts();
assert_eq!(post.enforce, 0);
assert_eq!(post.advisory, 0);
assert_eq!(post.off, 0);
}
#[test]
fn effective_allow_loopback_webhooks_env_var_true_returns_true() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
unsafe {
std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "yes");
}
let cfg = AppConfig::default();
assert!(cfg.effective_allow_loopback_webhooks());
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
}
}
}
#[test]
fn effective_allow_loopback_webhooks_env_var_false_returns_false() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
unsafe {
std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "no");
}
let cfg = AppConfig::default();
assert!(!cfg.effective_allow_loopback_webhooks());
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
}
}
}
#[test]
fn effective_allow_loopback_webhooks_env_var_invalid_falls_back_to_config() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
unsafe {
std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "kinda");
}
let cfg = AppConfig::default();
assert!(!cfg.effective_allow_loopback_webhooks());
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
}
}
}
#[test]
fn effective_permissions_mode_env_var_enforce_wins() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
unsafe {
std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "enforce");
}
let cfg = AppConfig::default();
assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Enforce);
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
}
}
}
#[test]
fn effective_permissions_mode_env_var_advisory_wins() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
unsafe {
std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "ADVISORY");
}
let cfg = AppConfig::default();
assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Advisory);
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
}
}
}
#[test]
fn effective_permissions_mode_env_var_off_wins() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
unsafe {
std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "off");
}
let cfg = AppConfig::default();
assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Off);
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
}
}
}
#[test]
fn effective_permissions_mode_env_var_invalid_falls_back_to_config() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
unsafe {
std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "weird");
}
let cfg = AppConfig::default();
let _ = cfg.effective_permissions_mode();
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
}
}
}
#[test]
fn effective_permission_rules_returns_empty_when_unset() {
let cfg = AppConfig::default();
let rules = cfg.effective_permission_rules();
assert!(rules.is_empty());
}
#[test]
fn app_config_load_with_no_config_env_returns_default() {
let _g = env_var_lock();
let prior = std::env::var("AI_MEMORY_NO_CONFIG").ok();
unsafe {
std::env::set_var("AI_MEMORY_NO_CONFIG", "1");
}
let cfg = AppConfig::load();
assert!(
cfg.tier.is_none()
|| cfg.tier == Some("semantic".to_string())
|| cfg.tier == Some("keyword".to_string())
);
unsafe {
match prior {
Some(v) => std::env::set_var("AI_MEMORY_NO_CONFIG", v),
None => std::env::remove_var("AI_MEMORY_NO_CONFIG"),
}
}
}
#[test]
fn capability_compaction_default_is_planned() {
let d: CapabilityCompaction = Default::default();
let planned = CapabilityCompaction::planned();
assert_eq!(format!("{d:?}"), format!("{planned:?}"));
}
#[test]
fn capability_transcripts_default_is_planned() {
let d: CapabilityTranscripts = Default::default();
let planned = CapabilityTranscripts::planned();
assert_eq!(format!("{d:?}"), format!("{planned:?}"));
}
#[test]
fn default_capability_reflection_helper_returns_current() {
let helper = default_capability_reflection();
let current = CapabilityReflection::current();
assert_eq!(format!("{helper:?}"), format!("{current:?}"));
}
#[test]
fn issue_1672_curator_mode_honest_per_sal_feature() {
let cm = CapabilityReflection::current().curator_mode;
if cfg!(feature = "sal") {
assert_eq!(cm, IMPLEMENTED);
} else {
assert_eq!(cm, CURATOR_MODE_REQUIRES_SAL);
}
}
#[test]
fn default_capability_skills_helper_returns_current() {
let helper = default_capability_skills();
let current = CapabilitySkills::current();
assert_eq!(helper, current);
}
#[test]
fn default_capability_forensic_helper_returns_current() {
let helper = default_capability_forensic();
let current = CapabilityForensic::current();
assert_eq!(helper, current);
}
#[test]
fn default_capability_governance_helper_returns_current() {
let helper = default_capability_governance();
let current = CapabilityGovernance::current();
assert_eq!(helper, current);
}
#[test]
fn default_capability_atomisation_helper_returns_current() {
let helper = default_capability_atomisation();
let current = CapabilityAtomisation::current();
assert_eq!(helper, current);
}
#[test]
fn resolved_transcript_lifecycle_default_uses_compiled_defaults() {
let r: ResolvedTranscriptLifecycle = Default::default();
assert_eq!(r.default_ttl_secs, DEFAULT_TRANSCRIPT_TTL_SECS);
assert_eq!(r.archive_grace_secs, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS);
}
#[test]
fn default_memory_kinds_lists_observation_and_reflection() {
let kinds = default_memory_kinds();
assert_eq!(
kinds,
vec!["observation".to_string(), "reflection".to_string()]
);
}
#[test]
fn confidence_tier_thresholds_match_model_constants() {
let defaults = ConfidenceTierThresholds::default();
assert!(
(defaults.confirmed - crate::models::ConfidenceTier::CONFIRMED_MIN).abs()
< f64::EPSILON,
"ConfidenceTierThresholds.confirmed must match ConfidenceTier::CONFIRMED_MIN"
);
assert!(
(defaults.likely - crate::models::ConfidenceTier::LIKELY_MIN).abs() < f64::EPSILON,
"ConfidenceTierThresholds.likely must match ConfidenceTier::LIKELY_MIN"
);
assert!(
(defaults.ambiguous - 0.0).abs() < f64::EPSILON,
"ambiguous floor is fixed at 0.0"
);
}
#[test]
fn capability_confidence_calibration_carries_tier_thresholds() {
let surface = CapabilityConfidenceCalibration::current();
assert!((surface.tier_thresholds.confirmed - 0.95).abs() < f64::EPSILON);
assert!((surface.tier_thresholds.likely - 0.7).abs() < f64::EPSILON);
assert!((surface.tier_thresholds.ambiguous - 0.0).abs() < f64::EPSILON);
}
fn empty_app_config() -> AppConfig {
AppConfig {
schema_version: Some(2),
..AppConfig::default()
}
}
fn scrub_llm_env() {
for k in [
"AI_MEMORY_LLM_BACKEND",
"AI_MEMORY_LLM_MODEL",
"AI_MEMORY_LLM_BASE_URL",
"AI_MEMORY_LLM_API_KEY",
"XAI_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"DEEPSEEK_API_KEY",
"AI_MEMORY_EMBED_BACKFILL_BATCH",
"AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS",
] {
unsafe {
std::env::remove_var(k);
}
}
}
fn scrub_embed_env() {
for k in [
ENV_EMBED_BACKEND,
ENV_EMBED_BASE_URL,
ENV_EMBED_MODEL,
ENV_EMBED_API_KEY,
ENV_EMBED_BACKFILL_BATCH,
"OPENROUTER_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
] {
unsafe {
std::env::remove_var(k);
}
}
}
fn scrub_limits_env() {
for k in [
ENV_MAX_MEMORIES_PER_DAY,
ENV_MAX_STORAGE_BYTES,
ENV_MAX_LINKS_PER_DAY,
ENV_MAX_PAGE_SIZE,
] {
unsafe {
std::env::remove_var(k);
}
}
}
#[test]
fn resolve_limits_compiled_default_when_nothing_configured() {
let _g = env_var_lock();
scrub_limits_env();
let cfg = empty_app_config();
let r = cfg.resolve_limits();
assert_eq!(
r.max_memories_per_day,
crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
);
assert_eq!(
r.max_storage_bytes,
crate::quotas::DEFAULT_MAX_STORAGE_BYTES
);
assert_eq!(
r.max_links_per_day,
crate::quotas::DEFAULT_MAX_LINKS_PER_DAY
);
assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
assert_eq!(r.source, ConfigSource::CompiledDefault);
}
#[test]
fn resolve_limits_config_section_when_no_env() {
let _g = env_var_lock();
scrub_limits_env();
let mut cfg = empty_app_config();
cfg.limits = Some(LimitsSection {
max_memories_per_day: Some(5_000_000),
max_storage_bytes: Some(9_000_000_000),
max_links_per_day: Some(4_000_000),
max_page_size: Some(250_000),
});
let r = cfg.resolve_limits();
assert_eq!(r.max_memories_per_day, 5_000_000);
assert_eq!(r.max_storage_bytes, 9_000_000_000);
assert_eq!(r.max_links_per_day, 4_000_000);
assert_eq!(r.max_page_size, 250_000);
assert_eq!(r.source, ConfigSource::Config);
}
#[test]
fn resolve_limits_env_overrides_config_section() {
let _g = env_var_lock();
scrub_limits_env();
unsafe {
std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "7000000");
std::env::set_var(ENV_MAX_PAGE_SIZE, "123456");
}
let mut cfg = empty_app_config();
cfg.limits = Some(LimitsSection {
max_memories_per_day: Some(5_000_000),
max_storage_bytes: Some(9_000_000_000),
max_links_per_day: Some(4_000_000),
max_page_size: Some(250_000),
});
let r = cfg.resolve_limits();
assert_eq!(r.max_memories_per_day, 7_000_000, "env beats config");
assert_eq!(r.max_page_size, 123_456, "env beats config");
assert_eq!(r.max_storage_bytes, 9_000_000_000);
assert_eq!(r.max_links_per_day, 4_000_000);
assert_eq!(r.source, ConfigSource::Env);
scrub_limits_env();
}
#[test]
fn resolve_limits_zero_and_garbage_env_fall_through() {
let _g = env_var_lock();
scrub_limits_env();
unsafe {
std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "0"); std::env::set_var(ENV_MAX_STORAGE_BYTES, "not-a-number"); std::env::set_var(ENV_MAX_PAGE_SIZE, "-5"); }
let cfg = empty_app_config();
let r = cfg.resolve_limits();
assert_eq!(
r.max_memories_per_day,
crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
);
assert_eq!(
r.max_storage_bytes,
crate::quotas::DEFAULT_MAX_STORAGE_BYTES
);
assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
assert_eq!(r.source, ConfigSource::CompiledDefault);
scrub_limits_env();
}
#[test]
fn resolve_limits_zero_config_value_falls_through_to_default() {
let _g = env_var_lock();
scrub_limits_env();
let mut cfg = empty_app_config();
cfg.limits = Some(LimitsSection {
max_page_size: Some(0), ..LimitsSection::default()
});
let r = cfg.resolve_limits();
assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
assert_eq!(r.source, ConfigSource::CompiledDefault);
}
#[test]
fn resolve_limits_section_round_trips_through_toml() {
let toml = r#"
schema_version = 2
[limits]
max_memories_per_day = 10000000
max_storage_bytes = 50000000000
max_links_per_day = 8000000
max_page_size = 1000000
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse [limits] toml");
let l = cfg.limits.as_ref().expect("limits section present");
assert_eq!(l.max_memories_per_day, Some(10_000_000));
assert_eq!(l.max_storage_bytes, Some(50_000_000_000));
assert_eq!(l.max_links_per_day, Some(8_000_000));
assert_eq!(l.max_page_size, Some(1_000_000));
let _g = env_var_lock();
scrub_limits_env();
let r = cfg.resolve_limits();
assert_eq!(r.max_memories_per_day, 10_000_000);
assert_eq!(r.max_page_size, 1_000_000);
assert_eq!(r.source, ConfigSource::Config);
}
#[cfg(feature = "sal")]
fn scrub_pg_pool_env() {
for k in [
ENV_PG_POOL_MAX,
ENV_PG_POOL_MIN,
ENV_PG_ACQUIRE_TIMEOUT_SECS,
] {
unsafe {
std::env::remove_var(k);
}
}
}
#[cfg(feature = "sal")]
#[test]
fn resolve_pg_pool_compiled_default_when_nothing_configured() {
let _g = env_var_lock();
scrub_pg_pool_env();
let cfg = empty_app_config();
let r = cfg.resolve_pg_pool();
assert_eq!(r, crate::store::PoolConfig::default());
}
#[cfg(feature = "sal")]
#[test]
fn resolve_pg_pool_config_overrides_default() {
let _g = env_var_lock();
scrub_pg_pool_env();
let mut cfg = empty_app_config();
cfg.postgres_pool_max_connections = Some(64);
cfg.postgres_pool_min_connections = Some(8);
cfg.postgres_acquire_timeout_secs = Some(15);
let r = cfg.resolve_pg_pool();
assert_eq!(r.max_connections, 64);
assert_eq!(r.min_connections, 8);
assert_eq!(r.acquire_timeout_secs, 15);
}
#[cfg(feature = "sal")]
#[test]
fn resolve_pg_pool_env_overrides_config() {
let _g = env_var_lock();
scrub_pg_pool_env();
unsafe {
std::env::set_var(ENV_PG_POOL_MAX, "100");
std::env::set_var(ENV_PG_ACQUIRE_TIMEOUT_SECS, "45");
}
let mut cfg = empty_app_config();
cfg.postgres_pool_max_connections = Some(64);
cfg.postgres_pool_min_connections = Some(8);
cfg.postgres_acquire_timeout_secs = Some(15);
let r = cfg.resolve_pg_pool();
assert_eq!(r.max_connections, 100, "env beats config");
assert_eq!(r.acquire_timeout_secs, 45, "env beats config");
assert_eq!(r.min_connections, 8);
scrub_pg_pool_env();
}
#[cfg(feature = "sal")]
#[test]
fn resolve_pg_pool_zero_and_garbage_fall_through() {
let _g = env_var_lock();
scrub_pg_pool_env();
unsafe {
std::env::set_var(ENV_PG_POOL_MAX, "0"); std::env::set_var(ENV_PG_POOL_MIN, "not-a-number"); }
let mut cfg = empty_app_config();
cfg.postgres_acquire_timeout_secs = Some(0);
let r = cfg.resolve_pg_pool();
assert_eq!(r, crate::store::PoolConfig::default());
scrub_pg_pool_env();
}
#[cfg(feature = "sal")]
#[test]
fn pg_pool_env_const_names_byte_match_documented() {
assert_eq!(ENV_PG_POOL_MAX, "AI_MEMORY_PG_POOL_MAX");
assert_eq!(ENV_PG_POOL_MIN, "AI_MEMORY_PG_POOL_MIN");
assert_eq!(
ENV_PG_ACQUIRE_TIMEOUT_SECS,
"AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS"
);
}
#[test]
fn resolve_llm_1146_compiled_default_when_nothing_configured() {
let _g = env_var_lock();
scrub_llm_env();
let cfg = empty_app_config();
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(resolved.backend, "ollama");
assert_eq!(resolved.model, "gemma3:4b");
assert_eq!(resolved.base_url, "http://localhost:11434");
assert_eq!(resolved.source, ConfigSource::CompiledDefault);
assert_eq!(resolved.api_key_source, KeySource::None);
assert!(resolved.api_key().is_none());
}
#[test]
fn resolve_llm_1146_env_overrides_config_section() {
let _g = env_var_lock();
scrub_llm_env();
unsafe {
std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
std::env::set_var("AI_MEMORY_LLM_MODEL", "grok-99");
std::env::set_var("AI_MEMORY_LLM_API_KEY", "env-key");
}
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some("openai".into()),
model: Some("gpt-4".into()),
..LlmSection::default()
});
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(resolved.backend, "xai", "env must beat config");
assert_eq!(resolved.model, "grok-99");
assert_eq!(resolved.source, ConfigSource::Env);
assert_eq!(resolved.api_key_source, KeySource::ProcessEnv);
assert_eq!(resolved.api_key(), Some("env-key"));
scrub_llm_env();
}
#[test]
fn resolve_llm_1146_cli_overrides_env() {
let _g = env_var_lock();
scrub_llm_env();
unsafe {
std::env::set_var("AI_MEMORY_LLM_BACKEND", "ollama");
std::env::set_var("AI_MEMORY_LLM_MODEL", "ollama-model");
}
let cfg = empty_app_config();
let resolved = cfg.resolve_llm(Some("xai"), Some("grok-4.3"), Some("https://x"));
assert_eq!(resolved.backend, "xai", "CLI flag must beat env");
assert_eq!(resolved.model, "grok-4.3");
assert_eq!(resolved.base_url, "https://x");
assert_eq!(resolved.source, ConfigSource::Cli);
scrub_llm_env();
}
#[test]
fn resolve_llm_1146_config_section_when_no_env() {
let _g = env_var_lock();
scrub_llm_env();
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some("xai".into()),
model: Some("grok-4.3".into()),
..LlmSection::default()
});
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(resolved.backend, "xai");
assert_eq!(resolved.model, "grok-4.3");
assert_eq!(
resolved.base_url, "https://api.x.ai/v1",
"vendor-default base_url applied"
);
assert_eq!(resolved.source, ConfigSource::Config);
}
#[test]
fn resolve_llm_1146_tier_model_override_clobbers_config_model_1440() {
let _g = env_var_lock();
scrub_llm_env();
let configured_backend = "openrouter";
let configured_model = "google/gemma-4-26b-a4b-it";
let tier_default_model = crate::config::FeatureTier::Autonomous.config().llm_model;
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some(configured_backend.into()),
model: Some(configured_model.into()),
..LlmSection::default()
});
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(resolved.backend, configured_backend);
assert_eq!(resolved.model, configured_model);
let tier_override = tier_default_model.expect("autonomous tier has a default llm_model");
let clobbered = cfg.resolve_llm(None, Some(tier_override.as_str()), None);
assert_eq!(
clobbered.model, tier_override,
"tier-default override wins over configured model — the #1440 daemon defect"
);
assert_ne!(
clobbered.model, configured_model,
"the override must differ from the configured model for this regression to be meaningful"
);
scrub_llm_env();
}
#[test]
fn resolve_llm_1146_alias_fallback_key_for_xai() {
let _g = env_var_lock();
scrub_llm_env();
unsafe {
std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
std::env::set_var("XAI_API_KEY", "alias-fallback-key");
}
let cfg = empty_app_config();
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(resolved.backend, "xai");
assert_eq!(resolved.api_key(), Some("alias-fallback-key"));
match &resolved.api_key_source {
KeySource::AliasFallback(name) => assert_eq!(name, "XAI_API_KEY"),
other => panic!("expected AliasFallback(XAI_API_KEY), got {other:?}"),
}
scrub_llm_env();
}
#[test]
fn resolve_llm_1146_legacy_llm_model_feeds_resolver() {
let _g = env_var_lock();
scrub_llm_env();
let mut cfg = AppConfig::default();
cfg.llm_model = Some("gemma4:e4b".into());
cfg.ollama_url = Some("http://localhost:11434".into());
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(resolved.backend, "ollama");
assert_eq!(resolved.model, "gemma4:e4b");
assert_eq!(resolved.source, ConfigSource::Legacy);
}
#[test]
fn validate_secret_handling_1146_rejects_inline_api_key() {
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some("xai".into()),
api_key: Some("xai-INLINE-SECRET".into()),
..LlmSection::default()
});
let err = cfg
.validate_secret_handling()
.expect_err("inline api_key must be rejected");
assert!(
err.contains("api_key") && err.contains("forbidden"),
"error must name the field and the policy: {err}"
);
}
#[test]
fn validate_secret_handling_1146_rejects_env_and_file_both_set() {
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some("xai".into()),
api_key_env: Some("XAI_API_KEY".into()),
api_key_file: Some("/etc/key".into()),
..LlmSection::default()
});
let err = cfg
.validate_secret_handling()
.expect_err("env+file mutex must be enforced");
assert!(
err.contains("api_key_env") && err.contains("api_key_file"),
"error must call out the mutex: {err}"
);
}
#[test]
fn resolve_llm_1146_api_key_env_reads_named_env_var() {
let _g = env_var_lock();
scrub_llm_env();
unsafe {
std::env::set_var("MY_CUSTOM_LLM_KEY", "via-config-env-var");
}
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some("xai".into()),
model: Some("grok-4.3".into()),
api_key_env: Some("MY_CUSTOM_LLM_KEY".into()),
..LlmSection::default()
});
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(resolved.api_key(), Some("via-config-env-var"));
match &resolved.api_key_source {
KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_LLM_KEY"),
other => panic!("expected ConfigEnvVar(MY_CUSTOM_LLM_KEY), got {other:?}"),
}
unsafe {
std::env::remove_var("MY_CUSTOM_LLM_KEY");
}
}
#[test]
#[cfg(unix)]
fn resolve_llm_1146_api_key_file_rejects_lax_perms() {
use std::os::unix::fs::PermissionsExt;
let _g = env_var_lock();
scrub_llm_env();
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(".local-runs")
.join(format!("test-1146-perms-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let key_path = base.join("xai.key");
std::fs::write(&key_path, "shhh").unwrap();
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some("xai".into()),
api_key_file: Some(key_path.display().to_string()),
..LlmSection::default()
});
let resolved = cfg.resolve_llm(None, None, None);
match &resolved.api_key_source {
KeySource::Error(reason) => {
assert!(
reason.contains("lax permissions") && reason.contains("0400"),
"error must name the perm policy: {reason}"
);
}
other => panic!("expected KeySource::Error(lax perms), got {other:?}"),
}
let _ = std::fs::remove_file(&key_path);
let _ = std::fs::remove_dir(&base);
}
#[test]
#[cfg(unix)]
fn resolve_llm_1146_api_key_file_accepts_0400() {
use std::os::unix::fs::PermissionsExt;
let _g = env_var_lock();
scrub_llm_env();
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(".local-runs")
.join(format!("test-1146-perms-ok-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let key_path = base.join("xai.key");
std::fs::write(&key_path, "the-actual-key\n").unwrap();
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o400)).unwrap();
let mut cfg = empty_app_config();
cfg.llm = Some(LlmSection {
backend: Some("xai".into()),
api_key_file: Some(key_path.display().to_string()),
..LlmSection::default()
});
let resolved = cfg.resolve_llm(None, None, None);
assert_eq!(
resolved.api_key(),
Some("the-actual-key"),
"first line is the key"
);
assert!(matches!(resolved.api_key_source, KeySource::ConfigFile(_)));
let _ = std::fs::remove_file(&key_path);
let _ = std::fs::remove_dir(&base);
}
#[test]
fn resolve_embeddings_1146_legacy_alias_canonicalised() {
let _g = env_var_lock();
scrub_llm_env();
let mut cfg = AppConfig::default();
cfg.embedding_model = Some("nomic_embed_v15".into());
let resolved = cfg.resolve_embeddings();
assert_eq!(
resolved.model, "nomic-embed-text-v1.5",
"legacy alias must be canonicalised"
);
assert_eq!(resolved.source, ConfigSource::Legacy);
assert_eq!(resolved.backfill_batch, 100, "compiled default applied");
}
#[test]
fn resolve_embeddings_1146_backfill_batch_env_overrides_config() {
let _g = env_var_lock();
scrub_llm_env();
unsafe {
std::env::set_var("AI_MEMORY_EMBED_BACKFILL_BATCH", "500");
}
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backfill_batch: Some(50),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.backfill_batch, 500, "env must beat config");
scrub_llm_env();
}
#[test]
fn resolve_embeddings_1598_compiled_defaults() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
let cfg = empty_app_config();
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.backend, crate::llm::BACKEND_OLLAMA);
assert_eq!(resolved.url, crate::llm::DEFAULT_OLLAMA_URL);
assert_eq!(resolved.model, DEFAULT_EMBED_MODEL);
assert_eq!(resolved.source, ConfigSource::CompiledDefault);
assert_eq!(resolved.api_key(), None);
assert_eq!(resolved.key_source, KeySource::None);
}
#[test]
fn resolve_embeddings_1598_env_beats_section() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
unsafe {
std::env::set_var(ENV_EMBED_BACKEND, "openai-compatible");
std::env::set_var(ENV_EMBED_BASE_URL, "http://tei.internal:8080/v1");
std::env::set_var(
ENV_EMBED_MODEL,
"ibm-granite/granite-embedding-125m-english",
);
}
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("ollama".into()),
url: Some("http://section-url:11434".into()),
model: Some("nomic-embed-text-v1.5".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.backend, "openai-compatible");
assert_eq!(resolved.url, "http://tei.internal:8080/v1");
assert_eq!(resolved.model, "ibm-granite/granite-embedding-125m-english");
assert_eq!(resolved.source, ConfigSource::Env);
assert_eq!(
resolved.embedding_dim,
Some(768),
"granite dim comes from the known-dims table"
);
scrub_embed_env();
}
#[test]
fn resolve_embeddings_1598_section_beats_legacy() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
let mut cfg = empty_app_config();
cfg.embed_url = Some("http://legacy-embed:11434".into());
cfg.embedding_model = Some("mini_lm_l6_v2".into());
cfg.embeddings = Some(EmbeddingsSection {
url: Some("http://section:11434".into()),
model: Some("nomic-embed-text-v1.5".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.url, "http://section:11434");
assert_eq!(resolved.model, "nomic-embed-text-v1.5");
assert_eq!(resolved.source, ConfigSource::Config);
}
#[test]
fn resolve_embeddings_1598_base_url_wins_over_url_synonym() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
base_url: Some("http://base-url-wins:8080/v1".into()),
url: Some("http://url-loses:11434".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.url, "http://base-url-wins:8080/v1");
}
#[test]
fn resolve_embeddings_1598_api_alias_default_base_url() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("openrouter".into()),
model: Some("google/gemini-embedding-2".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(
resolved.url, "https://openrouter.ai/api/v1",
"API alias with no URL configured must fall back to the \
vendor default from llm.rs"
);
assert_eq!(resolved.embedding_dim, Some(3072), "gemini-embedding-2 dim");
}
#[test]
fn resolve_embeddings_1598_dim_override_beats_table() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
model: Some("nomic-embed-text-v1.5".into()),
dim: Some(512),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(
resolved.embedding_dim,
Some(512),
"[embeddings].dim override must beat the known-dims table"
);
cfg.embeddings = Some(EmbeddingsSection {
model: Some("nomic-embed-text-v1.5".into()),
dim: Some(0),
..EmbeddingsSection::default()
});
assert_eq!(cfg.resolve_embeddings().embedding_dim, Some(768));
}
#[test]
fn resolve_embeddings_1598_requested_dim_explicit_only() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
model: Some("nomic-embed-text-v1.5".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.embedding_dim, Some(768), "table dim resolves");
assert_eq!(
resolved.requested_dim, None,
"table-derived dim must not become a wire dimensions request"
);
cfg.embeddings = Some(EmbeddingsSection {
model: Some("google/gemini-embedding-2".into()),
dim: Some(768),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.embedding_dim, Some(768));
assert_eq!(resolved.requested_dim, Some(768));
cfg.embeddings = Some(EmbeddingsSection {
model: Some("google/gemini-embedding-2".into()),
dim: Some(0),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.embedding_dim, Some(3072), "table dim again");
assert_eq!(resolved.requested_dim, None);
}
#[test]
fn resolve_embed_api_key_1598_process_env_wins() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
unsafe {
std::env::set_var(ENV_EMBED_API_KEY, "embed-process-env-key");
std::env::set_var("OPENROUTER_API_KEY", "alias-key-loses");
}
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("openrouter".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.api_key(), Some("embed-process-env-key"));
assert_eq!(resolved.key_source, KeySource::ProcessEnv);
scrub_embed_env();
}
#[test]
fn resolve_embed_api_key_1598_alias_fallback() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
unsafe {
std::env::set_var("OPENROUTER_API_KEY", "alias-fallback-embed-key");
}
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("openrouter".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.api_key(), Some("alias-fallback-embed-key"));
match &resolved.key_source {
KeySource::AliasFallback(name) => assert_eq!(name, "OPENROUTER_API_KEY"),
other => panic!("expected AliasFallback(OPENROUTER_API_KEY), got {other:?}"),
}
scrub_embed_env();
}
#[test]
fn resolve_embed_api_key_1598_config_env_var() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
unsafe {
std::env::set_var("MY_CUSTOM_EMBED_KEY", "via-embed-config-env-var");
}
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("openai-compatible".into()),
api_key_env: Some("MY_CUSTOM_EMBED_KEY".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.api_key(), Some("via-embed-config-env-var"));
match &resolved.key_source {
KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_EMBED_KEY"),
other => panic!("expected ConfigEnvVar(MY_CUSTOM_EMBED_KEY), got {other:?}"),
}
unsafe {
std::env::remove_var("MY_CUSTOM_EMBED_KEY");
}
}
#[test]
#[cfg(unix)]
fn resolve_embed_api_key_1598_api_key_file_rejects_lax_perms() {
use std::os::unix::fs::PermissionsExt;
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(".local-runs")
.join(format!("test-1598-perms-lax-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let key_path = base.join("embed.key");
std::fs::write(&key_path, "leaky-embed-key\n").unwrap();
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("openai-compatible".into()),
api_key_file: Some(key_path.display().to_string()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
assert_eq!(resolved.api_key(), None, "lax-perm file must be refused");
match &resolved.key_source {
KeySource::Error(reason) => {
assert!(
reason.contains("[embeddings].api_key_file") && reason.contains("lax"),
"error must attribute the embeddings field: {reason}"
);
}
other => panic!("expected KeySource::Error, got {other:?}"),
}
let _ = std::fs::remove_file(&key_path);
let _ = std::fs::remove_dir(&base);
}
#[test]
fn resolved_embeddings_1598_debug_redacts_api_key() {
let _g = env_var_lock();
scrub_llm_env();
scrub_embed_env();
unsafe {
std::env::set_var(ENV_EMBED_API_KEY, "super-secret-embed-key");
}
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("openrouter".into()),
..EmbeddingsSection::default()
});
let resolved = cfg.resolve_embeddings();
let debugged = format!("{resolved:?}");
assert!(
!debugged.contains("super-secret-embed-key"),
"Debug must never leak the key: {debugged}"
);
assert!(
debugged.contains(crate::REDACTED_PLACEHOLDER),
"Debug must show the redaction placeholder: {debugged}"
);
scrub_embed_env();
}
#[test]
fn validate_secret_handling_1598_rejects_inline_embeddings_api_key() {
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
backend: Some("openrouter".into()),
api_key: Some("embed-INLINE-SECRET".into()),
..EmbeddingsSection::default()
});
let err = cfg
.validate_secret_handling()
.expect_err("inline [embeddings].api_key must be rejected");
assert!(
err.contains("api_key") && err.contains("forbidden") && err.contains("[embeddings]"),
"error must name the field, section, and policy: {err}"
);
}
#[test]
fn validate_secret_handling_1598_rejects_embeddings_env_and_file_both_set() {
let mut cfg = empty_app_config();
cfg.embeddings = Some(EmbeddingsSection {
api_key_env: Some("EMBED_KEY".into()),
api_key_file: Some("/etc/embed.key".into()),
..EmbeddingsSection::default()
});
let err = cfg
.validate_secret_handling()
.expect_err("[embeddings] env+file mutex must be enforced");
assert!(
err.contains("[embeddings].api_key_env") && err.contains("[embeddings].api_key_file"),
"error must call out the mutex: {err}"
);
}
#[test]
fn is_api_embed_backend_1598_classification() {
assert!(!is_api_embed_backend(crate::llm::BACKEND_OLLAMA));
assert!(!is_api_embed_backend(" Ollama "));
for api in ["openrouter", "openai", "gemini", "openai-compatible"] {
assert!(is_api_embed_backend(api), "{api} must classify as API");
}
}
#[test]
fn known_embedding_dims_1598_gemini_and_granite_entries() {
assert_eq!(
canonical_embedding_dim("google/gemini-embedding-2"),
Some(3072)
);
assert_eq!(canonical_embedding_dim("gemini-embedding-2"), Some(3072));
assert_eq!(
canonical_embedding_dim("ibm-granite/granite-embedding-125m-english"),
Some(768)
);
assert_eq!(canonical_embedding_dim("granite-embedding"), Some(768));
}
#[test]
fn resolve_storage_1579_mmap_compiled_default() {
let _g = env_var_lock();
unsafe {
std::env::remove_var(ENV_DB_MMAP_SIZE);
}
let cfg = empty_app_config();
let resolved = cfg.resolve_storage();
assert_eq!(
resolved.db_mmap_size_bytes,
crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
"no env + no section must bottom out on the compiled 256 MiB default"
);
}
#[test]
fn resolve_storage_1579_mmap_env_overrides_config() {
let _g = env_var_lock();
unsafe {
std::env::set_var(ENV_DB_MMAP_SIZE, "1048576");
}
let mut cfg = empty_app_config();
cfg.storage = Some(StorageSection {
db_mmap_size_bytes: Some(2_097_152),
..StorageSection::default()
});
let resolved = cfg.resolve_storage();
assert_eq!(
resolved.db_mmap_size_bytes, 1_048_576,
"env must beat the [storage] section"
);
unsafe {
std::env::remove_var(ENV_DB_MMAP_SIZE);
}
}
#[test]
fn resolve_storage_1579_mmap_config_zero_disables() {
let _g = env_var_lock();
unsafe {
std::env::remove_var(ENV_DB_MMAP_SIZE);
}
let mut cfg = empty_app_config();
cfg.storage = Some(StorageSection {
db_mmap_size_bytes: Some(0),
..StorageSection::default()
});
let resolved = cfg.resolve_storage();
assert_eq!(
resolved.db_mmap_size_bytes, 0,
"explicit 0 (mmap disabled) is a deliberate operator choice and must be honoured"
);
}
#[test]
fn resolve_storage_1579_mmap_garbage_falls_through() {
let _g = env_var_lock();
unsafe {
std::env::set_var(ENV_DB_MMAP_SIZE, "not-a-number");
}
let mut cfg = empty_app_config();
cfg.storage = Some(StorageSection {
db_mmap_size_bytes: Some(-5),
..StorageSection::default()
});
let resolved = cfg.resolve_storage();
assert_eq!(
resolved.db_mmap_size_bytes,
crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
"unparseable env + negative section value must both fall through to the compiled default"
);
unsafe {
std::env::remove_var(ENV_DB_MMAP_SIZE);
}
}
#[test]
fn resolve_storage_default_namespace_provenance_1590() {
let _g = env_var_lock();
let cfg = empty_app_config();
let resolved = cfg.resolve_storage();
assert_eq!(resolved.default_namespace, crate::DEFAULT_NAMESPACE);
assert_eq!(
resolved.default_namespace_source,
ConfigSource::CompiledDefault
);
assert_eq!(resolved.explicit_default_namespace(), None);
let mut cfg = empty_app_config();
cfg.storage = Some(StorageSection {
archive_on_gc: Some(true),
..StorageSection::default()
});
let resolved = cfg.resolve_storage();
assert_eq!(resolved.explicit_default_namespace(), None);
assert_eq!(
resolved.default_namespace_source,
ConfigSource::CompiledDefault
);
let mut cfg = empty_app_config();
cfg.storage = Some(StorageSection {
default_namespace: Some("alphaone".to_string()),
..StorageSection::default()
});
let resolved = cfg.resolve_storage();
assert_eq!(resolved.default_namespace, "alphaone");
assert_eq!(resolved.default_namespace_source, ConfigSource::Config);
assert_eq!(resolved.explicit_default_namespace(), Some("alphaone"));
#[allow(deprecated)]
let resolved = {
let mut cfg = empty_app_config();
cfg.default_namespace = Some("legacy-ns".to_string());
cfg.resolve_storage()
};
assert_eq!(resolved.default_namespace, "legacy-ns");
assert_eq!(resolved.default_namespace_source, ConfigSource::Legacy);
assert_eq!(resolved.explicit_default_namespace(), Some("legacy-ns"));
let mut cfg = empty_app_config();
cfg.storage = Some(StorageSection {
default_namespace: Some(" ".to_string()),
..StorageSection::default()
});
let resolved = cfg.resolve_storage();
assert_eq!(resolved.explicit_default_namespace(), None);
}
#[test]
fn configured_default_namespace_seed_and_clear_1590() {
let _gate = lock_configured_default_namespace_for_test();
set_configured_default_namespace(Some("alphaone".to_string()));
assert_eq!(
configured_default_namespace().as_deref(),
Some("alphaone"),
"seeded value must be readable process-wide"
);
set_configured_default_namespace(Some(" ".to_string()));
assert_eq!(
configured_default_namespace(),
None,
"blank seeds are filtered to the unconfigured state"
);
set_configured_default_namespace(Some("ns2".to_string()));
set_configured_default_namespace(None);
assert_eq!(configured_default_namespace(), None, "clear resets");
}
#[test]
fn resolve_reranker_1146_folds_legacy_cross_encoder() {
let _g = env_var_lock();
let mut cfg = AppConfig::default();
cfg.cross_encoder = Some(true);
let resolved = cfg.resolve_reranker();
assert!(resolved.enabled);
assert_eq!(resolved.model, "ms-marco-MiniLM-L-6-v2");
assert_eq!(resolved.source, ConfigSource::Legacy);
}
#[test]
fn curator_reflection_namespace_enabled_1671() {
use std::collections::HashMap;
let bare = AppConfig::default();
assert!(!bare.reflection_namespace_enabled("team/eng"));
let mut ns_map = HashMap::new();
ns_map.insert(
"team/eng".to_string(),
crate::curator::reflection_pass::ReflectionPassConfig {
enabled: true,
max_depth: None,
},
);
ns_map.insert(
"team/ops".to_string(),
crate::curator::reflection_pass::ReflectionPassConfig {
enabled: false,
max_depth: None,
},
);
let cfg = AppConfig {
curator: Some(CuratorSection {
reflection_namespaces: Some(ns_map),
confidence_decay_half_life_days: None,
}),
..AppConfig::default()
};
assert!(
cfg.reflection_namespace_enabled("team/eng"),
"#1671: enabled=true namespace participates"
);
assert!(
!cfg.reflection_namespace_enabled("team/ops"),
"#1671: enabled=false namespace is skipped"
);
assert!(
!cfg.reflection_namespace_enabled("team/unlisted"),
"#1671: namespace with no entry is skipped"
);
}
#[test]
fn curator_confidence_decay_half_life_resolver_n15() {
use std::collections::HashMap;
let bare = AppConfig::default();
assert!(
(bare.confidence_decay_half_life_for("team/eng")
- crate::confidence::DEFAULT_HALF_LIFE_DAYS)
.abs()
< f64::EPSILON
);
let mut hl = HashMap::new();
hl.insert("team/eng".to_string(), 14.0_f64);
hl.insert("team/bad".to_string(), -5.0_f64); hl.insert("team/nan".to_string(), f64::NAN); let cfg = AppConfig {
curator: Some(CuratorSection {
reflection_namespaces: None,
confidence_decay_half_life_days: Some(hl),
}),
..AppConfig::default()
};
assert!((cfg.confidence_decay_half_life_for("team/eng") - 14.0).abs() < f64::EPSILON);
assert!(
(cfg.confidence_decay_half_life_for("team/bad")
- crate::confidence::DEFAULT_HALF_LIFE_DAYS)
.abs()
< f64::EPSILON,
"n15: non-positive override falls through to the default"
);
assert!(
(cfg.confidence_decay_half_life_for("team/nan")
- crate::confidence::DEFAULT_HALF_LIFE_DAYS)
.abs()
< f64::EPSILON,
"n15: non-finite override falls through to the default"
);
let snap = cfg.confidence_decay_half_life_overrides();
assert_eq!(snap.len(), 1, "only the finite positive entry survives");
assert!((snap["team/eng"] - 14.0).abs() < f64::EPSILON);
}
#[test]
fn resolve_reranker_1604_max_seq_ladder() {
let _g = env_var_lock();
unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
let cfg = AppConfig::default();
assert_eq!(
cfg.resolve_reranker().max_seq_tokens,
crate::reranker::RERANK_MAX_SEQ_DEFAULT
);
let mut cfg = AppConfig::default();
cfg.reranker = Some(RerankerSection {
max_seq_tokens: Some(128),
..RerankerSection::default()
});
assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "192") };
assert_eq!(cfg.resolve_reranker().max_seq_tokens, 192);
unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "not-a-number") };
assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "0") };
assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
unsafe {
std::env::set_var(
ENV_RERANK_MAX_SEQ,
(crate::reranker::CROSS_ENCODER_MAX_SEQ + 1).to_string(),
);
}
assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
let mut cfg = AppConfig::default();
cfg.reranker = Some(RerankerSection {
max_seq_tokens: Some(crate::reranker::CROSS_ENCODER_MAX_SEQ + 1),
..RerankerSection::default()
});
assert_eq!(
cfg.resolve_reranker().max_seq_tokens,
crate::reranker::RERANK_MAX_SEQ_DEFAULT
);
unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
}
#[test]
fn resolved_llm_1146_debug_redacts_api_key() {
let resolved = ResolvedLlm {
backend: "xai".into(),
model: "grok-4.3".into(),
base_url: "https://api.x.ai/v1".into(),
api_key: Some("SUPER-SECRET-DONT-LEAK".into()),
api_key_source: KeySource::ProcessEnv,
source: ConfigSource::Env,
};
let dbg = format!("{resolved:?}");
assert!(
!dbg.contains("SUPER-SECRET-DONT-LEAK"),
"Debug impl must redact the api_key: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"Debug impl must show <redacted> placeholder: {dbg}"
);
}
#[test]
fn app_config_1454_debug_redacts_api_key() {
let cfg = AppConfig {
tier: Some("autonomous".into()),
api_key: Some("HTTP-BEARER-SUPER-SECRET".into()),
..AppConfig::default()
};
let dbg = format!("{cfg:?}");
assert!(
!dbg.contains("HTTP-BEARER-SUPER-SECRET"),
"AppConfig Debug must redact api_key: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"AppConfig Debug must show <redacted> placeholder: {dbg}"
);
assert!(
dbg.contains("autonomous"),
"AppConfig Debug must still render non-secret fields: {dbg}"
);
}
#[test]
fn llm_section_1454_debug_redacts_api_key() {
let section = LlmSection {
backend: Some("xai".into()),
api_key: Some("LLM-INLINE-SUPER-SECRET".into()),
api_key_env: Some("XAI_API_KEY".into()),
..LlmSection::default()
};
let dbg = format!("{section:?}");
assert!(
!dbg.contains("LLM-INLINE-SUPER-SECRET"),
"LlmSection Debug must redact api_key: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"LlmSection Debug must show <redacted> placeholder: {dbg}"
);
assert!(
dbg.contains("XAI_API_KEY"),
"api_key_env (a name, not a secret) must stay verbatim: {dbg}"
);
}
}