1use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7use crate::models::Tier;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum EmbeddingModel {
17 MiniLmL6V2,
19 NomicEmbedV15,
21}
22
23impl EmbeddingModel {
24 pub fn dim(self) -> usize {
26 match self {
27 Self::MiniLmL6V2 => 384,
28 Self::NomicEmbedV15 => 768,
29 }
30 }
31
32 pub fn hf_model_id(&self) -> &str {
34 match self {
35 Self::MiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
36 Self::NomicEmbedV15 => "nomic-ai/nomic-embed-text-v1.5",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LlmModel {
49 Gemma4E2B,
51 Gemma4E4B,
53}
54
55impl LlmModel {
56 pub fn ollama_model_id(&self) -> &str {
58 match self {
59 Self::Gemma4E2B => "gemma4:e2b",
60 Self::Gemma4E4B => "gemma4:e4b",
61 }
62 }
63
64 pub fn display_name(&self) -> &str {
66 match self {
67 Self::Gemma4E2B => "Gemma 4 Effective 2B (Q4)",
68 Self::Gemma4E4B => "Gemma 4 Effective 4B (Q4)",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum FeatureTier {
82 Keyword,
84 Semantic,
86 Smart,
88 Autonomous,
90}
91
92impl FeatureTier {
93 pub fn from_str(s: &str) -> Option<Self> {
95 match s.to_ascii_lowercase().as_str() {
96 "keyword" => Some(Self::Keyword),
97 "semantic" => Some(Self::Semantic),
98 "smart" => Some(Self::Smart),
99 "autonomous" => Some(Self::Autonomous),
100 _ => None,
101 }
102 }
103
104 pub fn as_str(&self) -> &str {
106 match self {
107 Self::Keyword => "keyword",
108 Self::Semantic => "semantic",
109 Self::Smart => "smart",
110 Self::Autonomous => "autonomous",
111 }
112 }
113
114 pub fn config(self) -> TierConfig {
116 match self {
117 Self::Keyword => TierConfig {
118 tier: self,
119 embedding_model: None,
120 llm_model: None,
121 cross_encoder: false,
122 max_memory_mb: 0,
123 },
124 Self::Semantic => TierConfig {
125 tier: self,
126 embedding_model: Some(EmbeddingModel::MiniLmL6V2),
127 llm_model: None,
128 cross_encoder: false,
129 max_memory_mb: 256,
130 },
131 Self::Smart => TierConfig {
132 tier: self,
133 embedding_model: Some(EmbeddingModel::NomicEmbedV15),
134 llm_model: Some(LlmModel::Gemma4E2B),
135 cross_encoder: false,
136 max_memory_mb: 1024,
137 },
138 Self::Autonomous => TierConfig {
139 tier: self,
140 embedding_model: Some(EmbeddingModel::NomicEmbedV15),
141 llm_model: Some(LlmModel::Gemma4E4B),
142 cross_encoder: true,
143 max_memory_mb: 4096,
144 },
145 }
146 }
147
148 #[allow(dead_code)]
150 pub fn from_memory_budget(mb: usize) -> Self {
151 if mb >= 4096 {
152 Self::Autonomous
153 } else if mb >= 1024 {
154 Self::Smart
155 } else if mb >= 256 {
156 Self::Semantic
157 } else {
158 Self::Keyword
159 }
160 }
161}
162
163impl std::fmt::Display for FeatureTier {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 f.write_str(self.as_str())
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct TierConfig {
176 pub tier: FeatureTier,
177 pub embedding_model: Option<EmbeddingModel>,
178 pub llm_model: Option<LlmModel>,
179 pub cross_encoder: bool,
180 pub max_memory_mb: usize,
181}
182
183impl TierConfig {
184 pub fn capabilities(&self) -> Capabilities {
196 let has_embeddings = self.embedding_model.is_some();
197 let has_llm = self.llm_model.is_some();
198
199 Capabilities {
200 schema_version: "2".to_string(),
202 tier: self.tier.as_str().to_string(),
203 version: env!("CARGO_PKG_VERSION").to_string(),
204 features: CapabilityFeatures {
205 keyword_search: true,
206 semantic_search: has_embeddings,
207 hybrid_recall: has_embeddings,
208 query_expansion: has_llm,
209 auto_consolidation: has_llm,
210 auto_tagging: has_llm,
211 contradiction_analysis: has_llm,
212 cross_encoder_reranking: self.cross_encoder,
213 memory_reflection: PlannedFeature::planned("v0.7+"),
217 embedder_loaded: false,
221 recall_mode_active: RecallMode::Disabled,
226 reranker_active: RerankerMode::Off,
232 },
233 models: CapabilityModels {
234 embedding: self
235 .embedding_model
236 .map_or_else(|| "none".to_string(), |m| m.hf_model_id().to_string()),
237 embedding_dim: self.embedding_model.map_or(0, EmbeddingModel::dim),
238 llm: self
239 .llm_model
240 .map_or_else(|| "none".to_string(), |m| m.ollama_model_id().to_string()),
241 cross_encoder: if self.cross_encoder {
242 "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string()
243 } else {
244 "none".to_string()
245 },
246 },
247 permissions: CapabilityPermissions {
258 mode: "advisory".to_string(),
259 active_rules: 0,
260 inheritance: Some("enforced".to_string()),
265 },
266 hooks: CapabilityHooks::default(),
267 compaction: CapabilityCompaction::planned(),
268 approval: CapabilityApproval {
269 pending_requests: 0,
270 },
271 transcripts: CapabilityTranscripts::planned(),
272 hnsw: CapabilityHnsw::default(),
273 }
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct Capabilities {
309 pub schema_version: String,
311 pub tier: String,
312 pub version: String,
313 pub features: CapabilityFeatures,
314 pub models: CapabilityModels,
315
316 pub permissions: CapabilityPermissions,
321
322 pub hooks: CapabilityHooks,
325
326 pub compaction: CapabilityCompaction,
329
330 pub approval: CapabilityApproval,
333
334 pub transcripts: CapabilityTranscripts,
337
338 #[serde(default)]
343 pub hnsw: CapabilityHnsw,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "snake_case")]
356pub enum RecallMode {
357 Hybrid,
358 KeywordOnly,
359 Degraded,
360 Disabled,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "snake_case")]
372pub enum RerankerMode {
373 Neural,
374 LexicalFallback,
375 Off,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
383pub struct PlannedFeature {
384 pub planned: bool,
386 pub version: String,
390 pub enabled: bool,
393}
394
395impl PlannedFeature {
396 #[must_use]
398 pub fn planned(version: &str) -> Self {
399 Self {
400 planned: true,
401 version: version.to_string(),
402 enabled: false,
403 }
404 }
405}
406
407#[allow(clippy::struct_excessive_bools)]
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct CapabilityFeatures {
411 pub keyword_search: bool,
412 pub semantic_search: bool,
413 pub hybrid_recall: bool,
414 pub query_expansion: bool,
415 pub auto_consolidation: bool,
416 pub auto_tagging: bool,
417 pub contradiction_analysis: bool,
418 pub cross_encoder_reranking: bool,
419 pub memory_reflection: PlannedFeature,
424 #[serde(default)]
437 pub embedder_loaded: bool,
438 #[serde(default = "default_recall_mode")]
442 pub recall_mode_active: RecallMode,
443 #[serde(default = "default_reranker_mode")]
446 pub reranker_active: RerankerMode,
447}
448
449fn default_recall_mode() -> RecallMode {
450 RecallMode::Disabled
451}
452
453fn default_reranker_mode() -> RerankerMode {
454 RerankerMode::Off
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct CapabilityModels {
460 pub embedding: String,
461 pub embedding_dim: usize,
462 pub llm: String,
463 pub cross_encoder: String,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, Default)]
473pub struct CapabilityPermissions {
474 pub mode: String,
476 pub active_rules: usize,
479 #[serde(default)]
491 pub inheritance: Option<String>,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct CapabilityHooks {
499 pub registered_count: usize,
501 #[serde(default = "default_webhook_events")]
508 pub webhook_events: Vec<String>,
509}
510
511impl Default for CapabilityHooks {
512 fn default() -> Self {
513 Self {
514 registered_count: 0,
515 webhook_events: default_webhook_events(),
516 }
517 }
518}
519
520fn default_webhook_events() -> Vec<String> {
526 vec![
527 "memory_store".to_string(),
528 "memory_promote".to_string(),
529 "memory_delete".to_string(),
530 "memory_link_created".to_string(),
531 "memory_consolidated".to_string(),
532 ]
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct CapabilityCompaction {
542 #[serde(flatten)]
546 pub status: PlannedFeature,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub interval_minutes: Option<u64>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub last_run_at: Option<String>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub last_run_stats: Option<serde_json::Value>,
556}
557
558impl CapabilityCompaction {
559 #[must_use]
561 pub fn planned() -> Self {
562 Self {
563 status: PlannedFeature::planned("v0.8+"),
564 interval_minutes: None,
565 last_run_at: None,
566 last_run_stats: None,
567 }
568 }
569}
570
571impl Default for CapabilityCompaction {
572 fn default() -> Self {
573 Self::planned()
574 }
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, Default)]
580pub struct CapabilityApproval {
581 pub pending_requests: usize,
583 }
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct CapabilityTranscripts {
594 #[serde(flatten)]
597 pub status: PlannedFeature,
598 #[serde(default, skip_serializing_if = "is_zero_usize")]
600 pub total_count: usize,
601 #[serde(default, skip_serializing_if = "is_zero_u64")]
603 pub total_size_mb: u64,
604}
605
606impl CapabilityTranscripts {
607 #[must_use]
609 pub fn planned() -> Self {
610 Self {
611 status: PlannedFeature::planned("v0.7+"),
612 total_count: 0,
613 total_size_mb: 0,
614 }
615 }
616}
617
618impl Default for CapabilityTranscripts {
619 fn default() -> Self {
620 Self::planned()
621 }
622}
623
624#[allow(clippy::trivially_copy_pass_by_ref)]
625fn is_zero_usize(n: &usize) -> bool {
626 *n == 0
627}
628
629#[allow(clippy::trivially_copy_pass_by_ref)]
630fn is_zero_u64(n: &u64) -> bool {
631 *n == 0
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize, Default)]
643pub struct CapabilityHnsw {
644 pub evictions_total: u64,
647 pub evicted_recently: bool,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct CapabilitiesV1 {
666 pub tier: String,
667 pub version: String,
668 pub features: CapabilityFeaturesV1,
669 pub models: CapabilityModels,
670}
671
672#[allow(clippy::struct_excessive_bools)]
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct CapabilityFeaturesV1 {
677 pub keyword_search: bool,
678 pub semantic_search: bool,
679 pub hybrid_recall: bool,
680 pub query_expansion: bool,
681 pub auto_consolidation: bool,
682 pub auto_tagging: bool,
683 pub contradiction_analysis: bool,
684 pub cross_encoder_reranking: bool,
685 pub memory_reflection: bool,
686 #[serde(default)]
687 pub embedder_loaded: bool,
688}
689
690impl Capabilities {
691 #[must_use]
699 pub fn to_v1(&self) -> CapabilitiesV1 {
700 CapabilitiesV1 {
701 tier: self.tier.clone(),
702 version: self.version.clone(),
703 features: CapabilityFeaturesV1 {
704 keyword_search: self.features.keyword_search,
705 semantic_search: self.features.semantic_search,
706 hybrid_recall: self.features.hybrid_recall,
707 query_expansion: self.features.query_expansion,
708 auto_consolidation: self.features.auto_consolidation,
709 auto_tagging: self.features.auto_tagging,
710 contradiction_analysis: self.features.contradiction_analysis,
711 cross_encoder_reranking: self.features.cross_encoder_reranking,
712 memory_reflection: self.features.memory_reflection.enabled,
713 embedder_loaded: self.features.embedder_loaded,
714 },
715 models: self.models.clone(),
716 }
717 }
718}
719
720#[allow(clippy::struct_field_names)]
726#[derive(Debug, Clone, Default, Serialize, Deserialize)]
727pub struct TtlConfig {
728 pub short_ttl_secs: Option<i64>,
730 pub mid_ttl_secs: Option<i64>,
732 pub long_ttl_secs: Option<i64>,
734 pub short_extend_secs: Option<i64>,
736 pub mid_extend_secs: Option<i64>,
738}
739
740#[derive(Debug, Clone)]
742#[allow(clippy::struct_field_names)]
743pub struct ResolvedTtl {
744 pub short_ttl_secs: Option<i64>,
745 pub mid_ttl_secs: Option<i64>,
746 pub long_ttl_secs: Option<i64>,
747 pub short_extend_secs: i64,
748 pub mid_extend_secs: i64,
749}
750
751impl Default for ResolvedTtl {
752 fn default() -> Self {
753 Self {
754 short_ttl_secs: Tier::Short.default_ttl_secs(),
755 mid_ttl_secs: Tier::Mid.default_ttl_secs(),
756 long_ttl_secs: Tier::Long.default_ttl_secs(),
757 short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
758 mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
759 }
760 }
761}
762
763const MAX_TTL_SECS: i64 = 315_360_000;
766
767#[allow(dead_code)]
768impl ResolvedTtl {
769 pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
773 let defaults = Self::default();
774 let Some(c) = cfg else {
775 return defaults;
776 };
777 let clamp_ttl = |v: i64| -> Option<i64> {
778 if v <= 0 {
779 None
780 } else {
781 Some(v.min(MAX_TTL_SECS))
782 }
783 };
784 Self {
785 short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
786 mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
787 long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
788 short_extend_secs: c
789 .short_extend_secs
790 .unwrap_or(defaults.short_extend_secs)
791 .max(0),
792 mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
793 }
794 }
795
796 pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
798 match tier {
799 Tier::Short => self.short_ttl_secs,
800 Tier::Mid => self.mid_ttl_secs,
801 Tier::Long => self.long_ttl_secs,
802 }
803 }
804
805 pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
807 match tier {
808 Tier::Short => Some(self.short_extend_secs),
809 Tier::Mid => Some(self.mid_extend_secs),
810 Tier::Long => None,
811 }
812 }
813}
814
815#[derive(Debug, Clone, Default, Serialize, Deserialize)]
833pub struct RecallScoringConfig {
834 pub half_life_days_short: Option<f64>,
836 pub half_life_days_mid: Option<f64>,
838 pub half_life_days_long: Option<f64>,
840 #[serde(default)]
842 pub legacy_scoring: bool,
843}
844
845#[derive(Debug, Clone, Copy)]
849pub struct ResolvedScoring {
850 pub half_life_days_short: f64,
851 pub half_life_days_mid: f64,
852 pub half_life_days_long: f64,
853 pub legacy_scoring: bool,
854}
855
856impl Default for ResolvedScoring {
857 fn default() -> Self {
858 Self {
859 half_life_days_short: 7.0,
860 half_life_days_mid: 30.0,
861 half_life_days_long: 365.0,
862 legacy_scoring: false,
863 }
864 }
865}
866
867impl ResolvedScoring {
868 const MIN_HALF_LIFE: f64 = 0.1;
869 const MAX_HALF_LIFE: f64 = 36_500.0;
870
871 pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
874 let defaults = Self::default();
875 let Some(c) = cfg else {
876 return defaults;
877 };
878 let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
879 Self {
880 half_life_days_short: c
881 .half_life_days_short
882 .map_or(defaults.half_life_days_short, clamp),
883 half_life_days_mid: c
884 .half_life_days_mid
885 .map_or(defaults.half_life_days_mid, clamp),
886 half_life_days_long: c
887 .half_life_days_long
888 .map_or(defaults.half_life_days_long, clamp),
889 legacy_scoring: c.legacy_scoring,
890 }
891 }
892
893 pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
895 match tier {
896 Tier::Short => self.half_life_days_short,
897 Tier::Mid => self.half_life_days_mid,
898 Tier::Long => self.half_life_days_long,
899 }
900 }
901
902 #[must_use]
907 pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
908 if self.legacy_scoring || age_days <= 0.0 {
909 return 1.0;
910 }
911 let half_life = self.half_life_for_tier(tier);
912 (-std::f64::consts::LN_2 * age_days / half_life).exp()
913 }
914}
915
916const CONFIG_DIR: &str = ".config/ai-memory";
921const CONFIG_FILE: &str = "config.toml";
922
923#[derive(Debug, Clone, Default, Serialize, Deserialize)]
928pub struct AppConfig {
929 pub tier: Option<String>,
931 pub db: Option<String>,
933 pub ollama_url: Option<String>,
935 pub embed_url: Option<String>,
937 pub embedding_model: Option<String>,
939 pub llm_model: Option<String>,
941 pub cross_encoder: Option<bool>,
943 pub default_namespace: Option<String>,
945 pub max_memory_mb: Option<usize>,
947 pub ttl: Option<TtlConfig>,
949 pub archive_on_gc: Option<bool>,
951 pub api_key: Option<String>,
953 pub archive_max_days: Option<i64>,
955 pub identity: Option<IdentityConfig>,
957 pub scoring: Option<RecallScoringConfig>,
960 pub autonomous_hooks: Option<bool>,
966 pub logging: Option<LoggingConfig>,
970 pub audit: Option<AuditConfig>,
975 pub boot: Option<BootConfig>,
984 pub mcp: Option<McpConfig>,
989}
990
991#[derive(Debug, Clone, Default, Serialize, Deserialize)]
998pub struct LoggingConfig {
999 pub enabled: Option<bool>,
1001 pub path: Option<String>,
1003 pub max_size_mb: Option<u64>,
1007 pub max_files: Option<usize>,
1009 pub retention_days: Option<u32>,
1012 pub structured: Option<bool>,
1014 pub level: Option<String>,
1016 pub rotation: Option<String>,
1018 pub filename_prefix: Option<String>,
1020}
1021
1022#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1029pub struct AuditConfig {
1030 pub enabled: Option<bool>,
1032 pub path: Option<String>,
1036 pub schema_version: Option<u32>,
1041 pub redact_content: Option<bool>,
1046 pub hash_chain: Option<bool>,
1048 pub attestation_cadence_minutes: Option<u32>,
1053 pub append_only: Option<bool>,
1057 pub retention_days: Option<u32>,
1062 pub compliance: Option<AuditComplianceConfig>,
1066}
1067
1068impl AuditConfig {
1069 #[must_use]
1075 pub fn effective_retention_days(&self) -> u32 {
1076 let mut chosen = self.retention_days.unwrap_or(90);
1077 if let Some(comp) = &self.compliance {
1078 for preset in comp.applied_presets() {
1079 if let Some(d) = preset.retention_days
1080 && d > chosen
1081 {
1082 chosen = d;
1083 }
1084 }
1085 }
1086 chosen
1087 }
1088
1089 #[must_use]
1093 pub fn effective_attestation_cadence_minutes(&self) -> u32 {
1094 let base = self.attestation_cadence_minutes.unwrap_or(60);
1095 let mut chosen = base;
1096 if let Some(comp) = &self.compliance {
1097 for preset in comp.applied_presets() {
1098 if let Some(m) = preset.attestation_cadence_minutes
1099 && m > 0
1100 && (chosen == 0 || m < chosen)
1101 {
1102 chosen = m;
1103 }
1104 }
1105 }
1106 chosen
1107 }
1108}
1109
1110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1123pub struct BootConfig {
1124 pub enabled: Option<bool>,
1129 pub redact_titles: Option<bool>,
1135}
1136
1137impl BootConfig {
1138 #[must_use]
1143 pub fn effective_enabled(&self) -> bool {
1144 if let Ok(v) = std::env::var("AI_MEMORY_BOOT_ENABLED") {
1145 let v = v.trim().to_ascii_lowercase();
1146 if matches!(v.as_str(), "0" | "false" | "no" | "off") {
1147 return false;
1148 }
1149 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
1150 return true;
1151 }
1152 }
1153 self.enabled.unwrap_or(true)
1154 }
1155
1156 #[must_use]
1158 pub fn effective_redact_titles(&self) -> bool {
1159 self.redact_titles.unwrap_or(false)
1160 }
1161}
1162
1163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1174pub struct McpConfig {
1175 pub profile: Option<String>,
1179
1180 pub allowlist: Option<std::collections::HashMap<String, Vec<String>>>,
1197}
1198
1199impl McpConfig {
1200 #[must_use]
1219 pub fn allowlist_decision(&self, agent_id: Option<&str>, family: &str) -> AllowlistDecision {
1220 let table = match self.allowlist.as_ref() {
1221 Some(t) if !t.is_empty() => t,
1222 _ => return AllowlistDecision::Disabled,
1223 };
1224 let aid = agent_id.unwrap_or("");
1227 if let Some(families) = table.get(aid) {
1229 return decide(families, family);
1230 }
1231 let mut keys: Vec<&String> = table
1233 .keys()
1234 .filter(|k| k.as_str() != "*" && aid.starts_with(k.as_str()))
1235 .collect();
1236 keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
1237 if let Some(k) = keys.first() {
1238 if let Some(families) = table.get(*k) {
1239 return decide(families, family);
1240 }
1241 }
1242 if let Some(families) = table.get("*") {
1244 return decide(families, family);
1245 }
1246 AllowlistDecision::Deny
1247 }
1248}
1249
1250fn decide(families: &[String], requested: &str) -> AllowlistDecision {
1251 if families.iter().any(|f| f == "full" || f == requested) {
1252 AllowlistDecision::Allow
1253 } else {
1254 AllowlistDecision::Deny
1255 }
1256}
1257
1258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1260pub enum AllowlistDecision {
1261 Disabled,
1263 Allow,
1265 Deny,
1267}
1268
1269#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1270pub struct AuditComplianceConfig {
1271 pub soc2: Option<CompliancePreset>,
1272 pub hipaa: Option<CompliancePreset>,
1273 pub gdpr: Option<CompliancePreset>,
1274 pub fedramp: Option<CompliancePreset>,
1275}
1276
1277impl AuditComplianceConfig {
1278 pub fn applied_presets(&self) -> impl Iterator<Item = &CompliancePreset> {
1280 [
1281 self.soc2.as_ref(),
1282 self.hipaa.as_ref(),
1283 self.gdpr.as_ref(),
1284 self.fedramp.as_ref(),
1285 ]
1286 .into_iter()
1287 .flatten()
1288 .filter(|p| p.applied.unwrap_or(false))
1289 }
1290}
1291
1292#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1293pub struct CompliancePreset {
1294 pub applied: Option<bool>,
1295 pub retention_days: Option<u32>,
1296 pub redact_content: Option<bool>,
1297 pub attestation_cadence_minutes: Option<u32>,
1298 pub encrypt_at_rest: Option<bool>,
1302 pub pseudonymize_actors: Option<bool>,
1304}
1305
1306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1313pub struct IdentityConfig {
1314 #[serde(default)]
1318 pub anonymize_default: bool,
1319}
1320
1321impl AppConfig {
1322 pub fn config_path() -> Option<PathBuf> {
1324 let home = std::env::var("HOME").ok()?;
1325 Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
1326 }
1327
1328 pub fn load() -> Self {
1331 if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
1332 return Self::default();
1333 }
1334 let Some(path) = Self::config_path() else {
1335 return Self::default();
1336 };
1337 Self::load_from(&path)
1338 }
1339
1340 pub fn load_from(path: &Path) -> Self {
1342 match std::fs::read_to_string(path) {
1343 Ok(contents) => match toml::from_str(&contents) {
1344 Ok(cfg) => {
1345 eprintln!("ai-memory: loaded config from {}", path.display());
1346 cfg
1347 }
1348 Err(e) => {
1349 eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
1350 Self::default()
1351 }
1352 },
1353 Err(_) => Self::default(),
1354 }
1355 }
1356
1357 pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
1359 let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
1360 FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
1361 }
1362
1363 pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
1365 let default_db = PathBuf::from("ai-memory.db");
1367 if cli_db != default_db {
1368 return cli_db.to_path_buf();
1369 }
1370 self.db
1372 .as_ref()
1373 .map_or_else(|| cli_db.to_path_buf(), PathBuf::from)
1374 }
1375
1376 pub fn effective_ollama_url(&self) -> &str {
1378 self.ollama_url
1379 .as_deref()
1380 .unwrap_or("http://localhost:11434")
1381 }
1382
1383 pub fn effective_ttl(&self) -> ResolvedTtl {
1385 ResolvedTtl::from_config(self.ttl.as_ref())
1386 }
1387
1388 pub fn effective_scoring(&self) -> ResolvedScoring {
1391 ResolvedScoring::from_config(self.scoring.as_ref())
1392 }
1393
1394 pub fn effective_archive_on_gc(&self) -> bool {
1396 self.archive_on_gc.unwrap_or(true)
1397 }
1398
1399 pub fn effective_profile(
1411 &self,
1412 cli_or_env: Option<&str>,
1413 ) -> Result<crate::profile::Profile, crate::profile::ProfileParseError> {
1414 let raw = cli_or_env
1415 .or_else(|| self.mcp.as_ref().and_then(|m| m.profile.as_deref()))
1416 .unwrap_or("core");
1417 crate::profile::Profile::parse(raw)
1418 }
1419
1420 pub fn effective_autonomous_hooks(&self) -> bool {
1426 if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
1427 let v = v.trim().to_ascii_lowercase();
1428 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
1429 return true;
1430 }
1431 if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
1432 return false;
1433 }
1434 }
1435 self.autonomous_hooks.unwrap_or(false)
1436 }
1437
1438 pub fn effective_anonymize_default(&self) -> bool {
1441 if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
1442 let v = v.trim().to_ascii_lowercase();
1443 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
1444 return true;
1445 }
1446 if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
1447 return false;
1448 }
1449 }
1450 self.identity.as_ref().is_some_and(|i| i.anonymize_default)
1451 }
1452
1453 pub fn effective_logging(&self) -> LoggingConfig {
1456 self.logging.clone().unwrap_or_default()
1457 }
1458
1459 pub fn effective_audit(&self) -> AuditConfig {
1462 self.audit.clone().unwrap_or_default()
1463 }
1464
1465 pub fn effective_boot(&self) -> BootConfig {
1469 self.boot.clone().unwrap_or_default()
1470 }
1471
1472 pub fn effective_embed_url(&self) -> &str {
1474 self.embed_url
1475 .as_deref()
1476 .or(self.ollama_url.as_deref())
1477 .unwrap_or("http://localhost:11434")
1478 }
1479
1480 pub fn write_default_if_missing() {
1482 let Some(path) = Self::config_path() else {
1483 return;
1484 };
1485 if path.exists() {
1486 return;
1487 }
1488 if let Some(parent) = path.parent() {
1489 let _ = std::fs::create_dir_all(parent);
1490 }
1491 let default_toml = r#"# ai-memory configuration
1492# See: https://github.com/alphaonedev/ai-memory-mcp
1493
1494# Feature tier: keyword, semantic, smart, autonomous
1495# tier = "semantic"
1496
1497# Path to SQLite database
1498# db = "~/.claude/ai-memory.db"
1499
1500# Ollama base URL (for smart/autonomous tiers)
1501# ollama_url = "http://localhost:11434"
1502
1503# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
1504# embedding_model = "mini_lm_l6_v2"
1505
1506# LLM model tag for Ollama
1507# llm_model = "gemma4:e2b"
1508
1509# Enable neural cross-encoder reranking (autonomous tier)
1510# cross_encoder = true
1511
1512# Default namespace for new memories
1513# default_namespace = "global"
1514
1515# Memory budget in MB (for auto tier selection)
1516# max_memory_mb = 4096
1517
1518# Archive expired memories before GC deletion (default: true)
1519# archive_on_gc = true
1520
1521# Per-tier TTL overrides (uncomment to customize)
1522# [ttl]
1523# short_ttl_secs = 21600 # 6 hours (default)
1524# mid_ttl_secs = 604800 # 7 days (default)
1525# long_ttl_secs = 0 # 0 = never expires (default)
1526# short_extend_secs = 3600 # +1h on access (default)
1527# mid_extend_secs = 86400 # +1d on access (default)
1528
1529# v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
1530# Default-OFF. Uncomment + set enabled = true to capture every
1531# `tracing::*` call site to a rotating on-disk log file. See
1532# `docs/security/audit-trail.md` §SIEM ingestion guide for Splunk /
1533# Datadog / Elastic / Loki recipes.
1534# [logging]
1535# enabled = false
1536# path = "~/.local/state/ai-memory/logs/"
1537# max_size_mb = 100
1538# max_files = 30
1539# retention_days = 90
1540# structured = false # true = emit JSON lines for SIEM ingest
1541# level = "info" # tracing EnvFilter directive
1542# rotation = "daily" # minutely | hourly | daily | never
1543
1544# v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF.
1545# When enabled, every memory mutation emits one hash-chained JSON
1546# line per event suitable for SOC2 / HIPAA / GDPR / FedRAMP evidence.
1547# `ai-memory audit verify` walks the chain; `ai-memory logs tail`
1548# streams events.
1549# [audit]
1550# enabled = false
1551# path = "~/.local/state/ai-memory/audit/"
1552# schema_version = 1
1553# redact_content = true # v1 schema never emits content; reserved
1554# hash_chain = true
1555# attestation_cadence_minutes = 60
1556# append_only = true # best-effort chflags(2) / FS_IOC_SETFLAGS
1557
1558# Compliance presets. Set `applied = true` and the documented retention
1559# / cadence values override the defaults above. See
1560# `docs/security/audit-trail.md` §Compliance.
1561# [audit.compliance.soc2]
1562# applied = false
1563# retention_days = 730
1564# redact_content = true
1565# attestation_cadence_minutes = 60
1566#
1567# [audit.compliance.hipaa]
1568# applied = false
1569# retention_days = 2190
1570# redact_content = true
1571# encrypt_at_rest = true # pair with --features sqlcipher
1572#
1573# [audit.compliance.gdpr]
1574# applied = false
1575# retention_days = 1095
1576# redact_content = true
1577# pseudonymize_actors = true # reserved for v0.7+
1578#
1579# [audit.compliance.fedramp]
1580# applied = false
1581# retention_days = 1095
1582# redact_content = true
1583# attestation_cadence_minutes = 30
1584
1585# v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy controls.
1586# Default-ON (omit the section entirely for the historical pre-v0.6.3.1
1587# behavior). Two knobs:
1588#
1589# - `enabled = false` silences `ai-memory boot` entirely: empty stdout,
1590# empty stderr, exit 0. The SessionStart hook injects nothing. Use on
1591# privacy-sensitive hosts where memory titles must never enter CI
1592# logs. The env var `AI_MEMORY_BOOT_ENABLED=0` takes precedence over
1593# this config (same precedence pattern as PR-5's log-dir resolution).
1594#
1595# - `redact_titles = true` keeps the manifest header but replaces row
1596# `title` fields with `<redacted>` — useful for compliance contexts
1597# that need the audit-trail signal of "boot ran with N memories"
1598# without exposing memory subjects.
1599# [boot]
1600# enabled = true
1601# redact_titles = false
1602"#;
1603 let _ = std::fs::write(&path, default_toml);
1604 }
1605}
1606
1607#[cfg(test)]
1612mod tests {
1613 use super::*;
1614
1615 #[test]
1616 fn tier_roundtrip() {
1617 for tier in [
1618 FeatureTier::Keyword,
1619 FeatureTier::Semantic,
1620 FeatureTier::Smart,
1621 FeatureTier::Autonomous,
1622 ] {
1623 assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
1624 }
1625 }
1626
1627 #[test]
1628 fn budget_selection() {
1629 assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
1630 assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
1631 assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
1632 assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
1633 assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
1634 assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
1635 assert_eq!(
1636 FeatureTier::from_memory_budget(4096),
1637 FeatureTier::Autonomous
1638 );
1639 assert_eq!(
1640 FeatureTier::from_memory_budget(8192),
1641 FeatureTier::Autonomous
1642 );
1643 }
1644
1645 #[test]
1646 fn embedding_dimensions() {
1647 assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
1648 assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
1649 }
1650
1651 #[test]
1652 fn autonomous_has_cross_encoder() {
1653 let cfg = FeatureTier::Autonomous.config();
1654 assert!(cfg.cross_encoder);
1655 let caps = cfg.capabilities();
1656 assert!(caps.features.cross_encoder_reranking);
1657 assert!(caps.features.memory_reflection.planned);
1662 assert!(!caps.features.memory_reflection.enabled);
1663 assert_eq!(caps.features.memory_reflection.version, "v0.7+");
1664 }
1665
1666 #[test]
1667 fn keyword_has_no_models() {
1668 let cfg = FeatureTier::Keyword.config();
1669 assert!(cfg.embedding_model.is_none());
1670 assert!(cfg.llm_model.is_none());
1671 assert!(!cfg.cross_encoder);
1672 assert_eq!(cfg.max_memory_mb, 0);
1673 }
1674
1675 #[test]
1676 fn capabilities_serialize() {
1677 let caps = FeatureTier::Smart.config().capabilities();
1678 let json = serde_json::to_string_pretty(&caps).unwrap();
1679 assert!(json.contains("\"tier\": \"smart\""));
1680 assert!(json.contains("nomic"));
1681 assert!(json.contains("gemma4:e2b"));
1682 }
1683
1684 #[test]
1689 fn capabilities_v2_zero_state_round_trip() {
1690 let caps = FeatureTier::Keyword.config().capabilities();
1691 let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
1692
1693 assert_eq!(val["schema_version"], "2");
1694
1695 assert_eq!(val["permissions"]["mode"], "advisory");
1698 assert_eq!(val["permissions"]["active_rules"], 0);
1699 assert!(
1700 val["permissions"].get("rule_summary").is_none(),
1701 "v2 honesty patch drops `permissions.rule_summary` (no per-rule serializer)"
1702 );
1703 assert_eq!(val["permissions"]["inheritance"], "enforced");
1705
1706 assert_eq!(val["hooks"]["registered_count"], 0);
1708 assert!(
1709 val["hooks"].get("by_event").is_none(),
1710 "v2 honesty patch drops `hooks.by_event` (no event registry)"
1711 );
1712
1713 assert_eq!(val["hooks"]["registered_count"], 0);
1715 assert!(
1716 val["hooks"].get("by_event").is_none(),
1717 "v2 drops hooks.by_event (no event registry)"
1718 );
1719 let events = val["hooks"]["webhook_events"].as_array().unwrap();
1723 assert_eq!(events.len(), 5);
1724 for expected in [
1725 "memory_store",
1726 "memory_promote",
1727 "memory_delete",
1728 "memory_link_created",
1729 "memory_consolidated",
1730 ] {
1731 assert!(
1732 events.iter().any(|v| v.as_str() == Some(expected)),
1733 "webhook_events missing {expected}"
1734 );
1735 }
1736
1737 assert_eq!(val["compaction"]["planned"], true);
1739 assert_eq!(val["compaction"]["enabled"], false);
1740 assert_eq!(val["compaction"]["version"], "v0.8+");
1741 assert!(
1742 val["compaction"].get("interval_minutes").is_none(),
1743 "Option::None values must be skipped in serialization"
1744 );
1745 assert!(val["compaction"].get("last_run_at").is_none());
1746 assert!(val["compaction"].get("last_run_stats").is_none());
1747
1748 assert_eq!(val["approval"]["pending_requests"], 0);
1751 assert!(
1752 val["approval"].get("subscribers").is_none(),
1753 "v2 honesty patch drops `approval.subscribers` (no subscription API)"
1754 );
1755 assert!(
1756 val["approval"].get("default_timeout_seconds").is_none(),
1757 "v2 honesty patch drops `approval.default_timeout_seconds` (no sweeper)"
1758 );
1759
1760 assert_eq!(val["transcripts"]["planned"], true);
1762 assert_eq!(val["transcripts"]["enabled"], false);
1763 assert_eq!(val["transcripts"]["version"], "v0.7+");
1764
1765 assert_eq!(val["features"]["memory_reflection"]["planned"], true);
1767 assert_eq!(val["features"]["memory_reflection"]["enabled"], false);
1768 assert_eq!(val["features"]["memory_reflection"]["version"], "v0.7+");
1769
1770 assert_eq!(val["features"]["recall_mode_active"], "disabled");
1775 assert_eq!(val["features"]["reranker_active"], "off");
1776
1777 let restored: Capabilities = serde_json::from_value(val).unwrap();
1780 assert_eq!(restored.schema_version, "2");
1781 assert_eq!(restored.permissions.mode, "advisory");
1782 assert!(restored.compaction.status.planned);
1783 assert!(restored.transcripts.status.planned);
1784 assert_eq!(restored.features.recall_mode_active, RecallMode::Disabled);
1785 assert_eq!(restored.features.reranker_active, RerankerMode::Off);
1786 }
1787
1788 #[test]
1791 fn capabilities_v1_projection_preserves_legacy_shape() {
1792 let caps = FeatureTier::Autonomous.config().capabilities();
1793 let v1 = caps.to_v1();
1794 let val: serde_json::Value = serde_json::to_value(&v1).unwrap();
1795
1796 assert!(
1798 val.get("schema_version").is_none(),
1799 "v1 has no schema_version"
1800 );
1801 assert!(
1802 val.get("permissions").is_none(),
1803 "v1 has no permissions block"
1804 );
1805 assert!(val.get("hooks").is_none());
1806 assert!(val.get("compaction").is_none());
1807 assert!(val.get("approval").is_none());
1808 assert!(val.get("transcripts").is_none());
1809
1810 assert!(val["tier"].is_string());
1812 assert!(val["version"].is_string());
1813 assert!(val["features"].is_object());
1814 assert!(val["models"].is_object());
1815
1816 assert!(val["features"]["memory_reflection"].is_boolean());
1820 assert_eq!(val["features"]["memory_reflection"], false);
1821
1822 assert!(val["features"].get("recall_mode_active").is_none());
1824 assert!(val["features"].get("reranker_active").is_none());
1825 }
1826
1827 #[test]
1828 fn config_default_is_empty() {
1829 let cfg = AppConfig::default();
1830 assert!(cfg.tier.is_none());
1831 assert!(cfg.db.is_none());
1832 assert!(cfg.ollama_url.is_none());
1833 }
1834
1835 #[test]
1836 fn config_parse_toml() {
1837 let toml_str = r#"
1838 tier = "smart"
1839 db = "/tmp/test.db"
1840 ollama_url = "http://localhost:11434"
1841 cross_encoder = true
1842 "#;
1843 let cfg: AppConfig = toml::from_str(toml_str).unwrap();
1844 assert_eq!(cfg.tier.as_deref(), Some("smart"));
1845 assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
1846 assert!(cfg.cross_encoder.unwrap());
1847 }
1848
1849 #[test]
1850 fn resolved_ttl_defaults_match_hardcoded() {
1851 let resolved = ResolvedTtl::default();
1852 assert_eq!(resolved.short_ttl_secs, Some(6 * 3600));
1853 assert_eq!(resolved.mid_ttl_secs, Some(7 * 24 * 3600));
1854 assert_eq!(resolved.long_ttl_secs, None);
1855 assert_eq!(resolved.short_extend_secs, 3600);
1856 assert_eq!(resolved.mid_extend_secs, 86400);
1857 }
1858
1859 #[test]
1860 fn resolved_ttl_from_partial_config() {
1861 let cfg = TtlConfig {
1862 mid_ttl_secs: Some(90 * 24 * 3600), ..Default::default()
1864 };
1865 let resolved = ResolvedTtl::from_config(Some(&cfg));
1866 assert_eq!(resolved.short_ttl_secs, Some(6 * 3600)); assert_eq!(resolved.mid_ttl_secs, Some(90 * 24 * 3600)); assert_eq!(resolved.long_ttl_secs, None); }
1870
1871 #[test]
1872 fn resolved_ttl_zero_means_no_expiry() {
1873 let cfg = TtlConfig {
1874 short_ttl_secs: Some(0),
1875 mid_ttl_secs: Some(0),
1876 ..Default::default()
1877 };
1878 let resolved = ResolvedTtl::from_config(Some(&cfg));
1879 assert_eq!(resolved.short_ttl_secs, None); assert_eq!(resolved.mid_ttl_secs, None);
1881 }
1882
1883 #[test]
1884 fn resolved_ttl_clamps_overflow() {
1885 let cfg = TtlConfig {
1886 mid_ttl_secs: Some(i64::MAX),
1887 short_extend_secs: Some(-3600),
1888 ..Default::default()
1889 };
1890 let resolved = ResolvedTtl::from_config(Some(&cfg));
1891 assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
1893 assert_eq!(resolved.short_extend_secs, 0);
1895 }
1896
1897 #[test]
1898 fn ttl_config_parse_toml() {
1899 let toml_str = r#"
1900 tier = "semantic"
1901 archive_on_gc = false
1902 [ttl]
1903 mid_ttl_secs = 7776000
1904 short_extend_secs = 7200
1905 "#;
1906 let cfg: AppConfig = toml::from_str(toml_str).unwrap();
1907 assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
1908 assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
1909 assert!(!cfg.effective_archive_on_gc());
1910 }
1911
1912 #[test]
1913 fn resolved_ttl_tier_methods() {
1914 let resolved = ResolvedTtl::default();
1915 assert_eq!(resolved.ttl_for_tier(&Tier::Short), Some(6 * 3600));
1916 assert_eq!(resolved.ttl_for_tier(&Tier::Mid), Some(7 * 24 * 3600));
1917 assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
1918 assert_eq!(resolved.extend_for_tier(&Tier::Short), Some(3600));
1919 assert_eq!(resolved.extend_for_tier(&Tier::Mid), Some(86400));
1920 assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
1921 }
1922
1923 #[test]
1924 fn config_effective_tier() {
1925 let cfg = AppConfig {
1926 tier: Some("smart".to_string()),
1927 ..Default::default()
1928 };
1929 assert_eq!(
1931 cfg.effective_tier(Some("autonomous")),
1932 FeatureTier::Autonomous
1933 );
1934 assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
1936 }
1937
1938 #[test]
1941 fn scoring_defaults_match_spec() {
1942 let s = ResolvedScoring::default();
1943 assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
1944 assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
1945 assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
1946 assert!(!s.legacy_scoring);
1947 }
1948
1949 #[test]
1950 fn scoring_from_config_overrides() {
1951 let cfg = RecallScoringConfig {
1952 half_life_days_short: Some(3.5),
1953 half_life_days_mid: Some(14.0),
1954 half_life_days_long: Some(730.0),
1955 legacy_scoring: false,
1956 };
1957 let s = ResolvedScoring::from_config(Some(&cfg));
1958 assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
1959 assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
1960 assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
1961 }
1962
1963 #[test]
1964 fn scoring_clamps_out_of_range() {
1965 let cfg = RecallScoringConfig {
1966 half_life_days_short: Some(-10.0),
1967 half_life_days_mid: Some(0.0),
1968 half_life_days_long: Some(1_000_000.0),
1969 legacy_scoring: false,
1970 };
1971 let s = ResolvedScoring::from_config(Some(&cfg));
1972 assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
1973 assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
1974 assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
1975 }
1976
1977 #[test]
1978 fn scoring_decay_at_half_life_is_half() {
1979 let s = ResolvedScoring::default();
1980 let d = s.decay_multiplier(&Tier::Short, 7.0);
1982 assert!((d - 0.5).abs() < 1e-9);
1983 let d = s.decay_multiplier(&Tier::Mid, 30.0);
1984 assert!((d - 0.5).abs() < 1e-9);
1985 let d = s.decay_multiplier(&Tier::Long, 365.0);
1986 assert!((d - 0.5).abs() < 1e-9);
1987 }
1988
1989 #[test]
1990 fn scoring_decay_monotonic() {
1991 let s = ResolvedScoring::default();
1992 let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
1993 let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
1994 assert!(d_new > d_old);
1996 assert!(d_new < 1.0);
1997 assert!(d_old > 0.0);
1998 }
1999
2000 #[test]
2001 fn scoring_decay_zero_age_is_one() {
2002 let s = ResolvedScoring::default();
2003 assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
2004 assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
2006 }
2007
2008 #[test]
2009 fn scoring_legacy_disables_decay() {
2010 let cfg = RecallScoringConfig {
2011 legacy_scoring: true,
2012 ..Default::default()
2013 };
2014 let s = ResolvedScoring::from_config(Some(&cfg));
2015 assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
2017 assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
2018 assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
2019 }
2020
2021 #[test]
2022 fn effective_scoring_on_empty_config() {
2023 let cfg = AppConfig::default();
2024 let s = cfg.effective_scoring();
2025 assert_eq!(s.half_life_days_short, 7.0);
2026 assert!(!s.legacy_scoring);
2027 }
2028
2029 #[test]
2030 fn scoring_roundtrip_through_toml() {
2031 let toml_src = r"
2032[scoring]
2033half_life_days_short = 5.0
2034half_life_days_mid = 25.0
2035legacy_scoring = false
2036";
2037 let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
2038 let s = cfg.effective_scoring();
2039 assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
2040 assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
2041 assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
2043 }
2044
2045 #[test]
2049 fn effective_tier_cli_overrides_config() {
2050 let cfg = AppConfig {
2051 tier: Some("smart".to_string()),
2052 ..AppConfig::default()
2053 };
2054 assert_eq!(
2056 cfg.effective_tier(Some("autonomous")),
2057 FeatureTier::Autonomous
2058 );
2059 assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
2061 }
2062
2063 #[test]
2064 fn effective_tier_unknown_falls_back_to_semantic() {
2065 let cfg = AppConfig::default();
2066 assert_eq!(
2067 cfg.effective_tier(Some("invalid-tier")),
2068 FeatureTier::Semantic
2069 );
2070 assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
2072 }
2073
2074 #[test]
2084 fn effective_profile_cli_or_env_overrides_config() {
2085 let cfg = AppConfig {
2086 mcp: Some(McpConfig {
2087 profile: Some("graph".to_string()),
2088 allowlist: None,
2089 }),
2090 ..AppConfig::default()
2091 };
2092 assert_eq!(
2094 cfg.effective_profile(Some("admin")).unwrap(),
2095 crate::profile::Profile::admin()
2096 );
2097 assert_eq!(
2099 cfg.effective_profile(None).unwrap(),
2100 crate::profile::Profile::graph()
2101 );
2102 }
2103
2104 #[test]
2105 fn effective_profile_falls_back_to_core_default() {
2106 let cfg = AppConfig::default();
2107 assert_eq!(
2109 cfg.effective_profile(None).unwrap(),
2110 crate::profile::Profile::core()
2111 );
2112 }
2113
2114 #[test]
2115 fn effective_profile_surfaces_parse_error_for_unknown_family() {
2116 let cfg = AppConfig::default();
2117 assert!(matches!(
2118 cfg.effective_profile(Some("xyz")),
2119 Err(crate::profile::ProfileParseError::UnknownFamily(_))
2120 ));
2121 }
2122
2123 #[test]
2124 fn effective_profile_surfaces_parse_error_for_mixed_case() {
2125 let cfg = AppConfig::default();
2126 assert!(matches!(
2127 cfg.effective_profile(Some("Core")),
2128 Err(crate::profile::ProfileParseError::CaseMismatch(_))
2129 ));
2130 }
2131
2132 fn allowlist_table(rows: &[(&str, &[&str])]) -> McpConfig {
2135 let mut map = std::collections::HashMap::new();
2136 for (k, v) in rows {
2137 map.insert(
2138 (*k).to_string(),
2139 v.iter().map(|s| (*s).to_string()).collect(),
2140 );
2141 }
2142 McpConfig {
2143 profile: None,
2144 allowlist: Some(map),
2145 }
2146 }
2147
2148 #[test]
2149 fn allowlist_disabled_when_table_absent() {
2150 let cfg = McpConfig::default();
2151 assert_eq!(
2152 cfg.allowlist_decision(Some("alice"), "graph"),
2153 AllowlistDecision::Disabled
2154 );
2155 }
2156
2157 #[test]
2158 fn allowlist_disabled_when_table_empty() {
2159 let cfg = McpConfig {
2160 profile: None,
2161 allowlist: Some(std::collections::HashMap::new()),
2162 };
2163 assert_eq!(
2164 cfg.allowlist_decision(Some("alice"), "graph"),
2165 AllowlistDecision::Disabled
2166 );
2167 }
2168
2169 #[test]
2170 fn allowlist_exact_match_grants_or_denies_per_family_set() {
2171 let cfg = allowlist_table(&[("alice", &["core", "graph"]), ("*", &["core"])]);
2172 assert_eq!(
2173 cfg.allowlist_decision(Some("alice"), "graph"),
2174 AllowlistDecision::Allow
2175 );
2176 assert_eq!(
2177 cfg.allowlist_decision(Some("alice"), "power"),
2178 AllowlistDecision::Deny
2179 );
2180 }
2181
2182 #[test]
2183 fn allowlist_full_grants_every_family() {
2184 let cfg = allowlist_table(&[("bob", &["full"])]);
2185 assert_eq!(
2186 cfg.allowlist_decision(Some("bob"), "graph"),
2187 AllowlistDecision::Allow
2188 );
2189 assert_eq!(
2190 cfg.allowlist_decision(Some("bob"), "archive"),
2191 AllowlistDecision::Allow
2192 );
2193 }
2194
2195 #[test]
2196 fn allowlist_wildcard_default_for_unknown_agents() {
2197 let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
2198 assert_eq!(
2199 cfg.allowlist_decision(Some("eve"), "core"),
2200 AllowlistDecision::Allow
2201 );
2202 assert_eq!(
2203 cfg.allowlist_decision(Some("eve"), "graph"),
2204 AllowlistDecision::Deny
2205 );
2206 }
2207
2208 #[test]
2209 fn allowlist_default_deny_when_no_wildcard() {
2210 let cfg = allowlist_table(&[("alice", &["full"])]);
2211 assert_eq!(
2212 cfg.allowlist_decision(Some("eve"), "core"),
2213 AllowlistDecision::Deny
2214 );
2215 }
2216
2217 #[test]
2218 fn allowlist_longest_prefix_match_wins() {
2219 let cfg = allowlist_table(&[
2220 ("ai:", &["core"]),
2221 ("ai:claude-code", &["full"]),
2222 ("*", &["core"]),
2223 ]);
2224 assert_eq!(
2226 cfg.allowlist_decision(Some("ai:claude-code@host"), "graph"),
2227 AllowlistDecision::Allow
2228 );
2229 assert_eq!(
2231 cfg.allowlist_decision(Some("ai:codex@host"), "graph"),
2232 AllowlistDecision::Deny
2233 );
2234 }
2235
2236 #[test]
2237 fn allowlist_no_agent_id_uses_wildcard() {
2238 let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
2241 assert_eq!(
2242 cfg.allowlist_decision(None, "core"),
2243 AllowlistDecision::Allow
2244 );
2245 assert_eq!(
2246 cfg.allowlist_decision(None, "graph"),
2247 AllowlistDecision::Deny
2248 );
2249 }
2250
2251 #[test]
2252 fn effective_db_cli_path_wins_when_non_default() {
2253 let cfg = AppConfig {
2254 db: Some("/from/config.db".to_string()),
2255 ..AppConfig::default()
2256 };
2257 let cli_path = Path::new("/from/cli.db");
2258 assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
2259 }
2260
2261 #[test]
2262 fn effective_db_falls_back_to_config_when_cli_default() {
2263 let cfg = AppConfig {
2264 db: Some("/from/config.db".to_string()),
2265 ..AppConfig::default()
2266 };
2267 assert_eq!(
2269 cfg.effective_db(Path::new("ai-memory.db")),
2270 PathBuf::from("/from/config.db")
2271 );
2272 }
2273
2274 #[test]
2275 fn effective_db_falls_back_to_cli_when_no_config() {
2276 let cfg = AppConfig::default();
2277 let cli_path = Path::new("ai-memory.db");
2278 assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
2279 }
2280
2281 #[test]
2282 fn effective_ollama_url_default_when_unset() {
2283 let cfg = AppConfig::default();
2284 assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
2285 }
2286
2287 #[test]
2288 fn effective_ollama_url_uses_configured_value() {
2289 let cfg = AppConfig {
2290 ollama_url: Some("http://my-host:9999".to_string()),
2291 ..AppConfig::default()
2292 };
2293 assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
2294 }
2295
2296 #[test]
2297 fn effective_embed_url_falls_back_to_ollama_url() {
2298 let cfg = AppConfig {
2299 ollama_url: Some("http://ollama:11434".to_string()),
2300 ..AppConfig::default()
2301 };
2302 assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
2304 }
2305
2306 #[test]
2307 fn effective_embed_url_uses_dedicated_value_when_set() {
2308 let cfg = AppConfig {
2309 ollama_url: Some("http://ollama:11434".to_string()),
2310 embed_url: Some("http://embed:8080".to_string()),
2311 ..AppConfig::default()
2312 };
2313 assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
2315 }
2316
2317 #[test]
2318 fn effective_embed_url_uses_default_when_neither_set() {
2319 let cfg = AppConfig::default();
2320 assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
2321 }
2322
2323 #[test]
2324 fn effective_archive_on_gc_default_is_true() {
2325 let cfg = AppConfig::default();
2326 assert!(cfg.effective_archive_on_gc());
2327 }
2328
2329 #[test]
2330 fn effective_archive_on_gc_respects_explicit_false() {
2331 let cfg = AppConfig {
2332 archive_on_gc: Some(false),
2333 ..AppConfig::default()
2334 };
2335 assert!(!cfg.effective_archive_on_gc());
2336 }
2337
2338 #[test]
2339 fn effective_autonomous_hooks_default_is_false() {
2340 unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
2345 let cfg = AppConfig::default();
2346 assert!(!cfg.effective_autonomous_hooks());
2347 }
2348
2349 #[test]
2350 fn effective_autonomous_hooks_config_value_used_when_env_unset() {
2351 unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
2352 let cfg = AppConfig {
2353 autonomous_hooks: Some(true),
2354 ..AppConfig::default()
2355 };
2356 assert!(cfg.effective_autonomous_hooks());
2357 }
2358
2359 #[test]
2360 fn effective_anonymize_default_falls_back_to_config() {
2361 unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
2362 let cfg = AppConfig::default();
2363 assert!(!cfg.effective_anonymize_default());
2364 }
2365
2366 #[test]
2367 fn write_default_if_missing_creates_file_then_noops() {
2368 let tmp = tempfile::tempdir().unwrap();
2370 unsafe { std::env::set_var("HOME", tmp.path()) };
2372 AppConfig::write_default_if_missing();
2374 let expected = AppConfig::config_path().unwrap();
2375 assert!(expected.exists(), "config not written at {expected:?}");
2376 let original = std::fs::read_to_string(&expected).unwrap();
2377 assert!(original.contains("ai-memory configuration"));
2378 std::fs::write(&expected, "# user-edited\n").unwrap();
2380 AppConfig::write_default_if_missing();
2381 let after = std::fs::read_to_string(&expected).unwrap();
2382 assert_eq!(after, "# user-edited\n");
2383 }
2384
2385 #[test]
2386 fn config_path_returns_some_when_home_set() {
2387 unsafe { std::env::set_var("HOME", "/some/home") };
2389 let path = AppConfig::config_path().unwrap();
2390 assert!(path.starts_with("/some/home"));
2391 }
2392
2393 #[test]
2394 fn load_from_returns_default_for_missing_file() {
2395 let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
2397 assert!(cfg.tier.is_none());
2398 assert!(cfg.db.is_none());
2399 }
2400
2401 #[test]
2402 fn load_from_returns_default_for_unparseable_toml() {
2403 let tmp = tempfile::NamedTempFile::new().unwrap();
2405 std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
2406 let cfg = AppConfig::load_from(tmp.path());
2407 assert!(cfg.tier.is_none());
2408 }
2409
2410 #[test]
2411 fn load_from_parses_valid_toml() {
2412 let tmp = tempfile::NamedTempFile::new().unwrap();
2413 std::fs::write(
2414 tmp.path(),
2415 r#"
2416 tier = "smart"
2417 db = "/disk.db"
2418 "#,
2419 )
2420 .unwrap();
2421 let cfg = AppConfig::load_from(tmp.path());
2422 assert_eq!(cfg.tier.as_deref(), Some("smart"));
2423 assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
2424 }
2425}