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 {
186 let has_embeddings = self.embedding_model.is_some();
187 let has_llm = self.llm_model.is_some();
188
189 Capabilities {
190 schema_version: "2".to_string(),
192 tier: self.tier.as_str().to_string(),
193 version: env!("CARGO_PKG_VERSION").to_string(),
194 features: CapabilityFeatures {
195 keyword_search: true,
196 semantic_search: has_embeddings,
197 hybrid_recall: has_embeddings,
198 query_expansion: has_llm,
199 auto_consolidation: has_llm,
200 auto_tagging: has_llm,
201 contradiction_analysis: has_llm,
202 cross_encoder_reranking: self.cross_encoder,
203 memory_reflection: self.cross_encoder && has_llm,
204 embedder_loaded: false,
208 },
209 models: CapabilityModels {
210 embedding: self
211 .embedding_model
212 .map_or_else(|| "none".to_string(), |m| m.hf_model_id().to_string()),
213 embedding_dim: self.embedding_model.map_or(0, EmbeddingModel::dim),
214 llm: self
215 .llm_model
216 .map_or_else(|| "none".to_string(), |m| m.ollama_model_id().to_string()),
217 cross_encoder: if self.cross_encoder {
218 "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string()
219 } else {
220 "none".to_string()
221 },
222 },
223 permissions: CapabilityPermissions {
227 mode: "ask".to_string(),
228 active_rules: 0,
229 rule_summary: Vec::new(),
230 },
231 hooks: CapabilityHooks::default(),
232 compaction: CapabilityCompaction::default(),
233 approval: CapabilityApproval {
234 subscribers: 0,
235 pending_requests: 0,
236 default_timeout_seconds: 30,
237 },
238 transcripts: CapabilityTranscripts::default(),
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Capabilities {
257 pub schema_version: String,
259 pub tier: String,
260 pub version: String,
261 pub features: CapabilityFeatures,
262 pub models: CapabilityModels,
263
264 pub permissions: CapabilityPermissions,
269
270 pub hooks: CapabilityHooks,
273
274 pub compaction: CapabilityCompaction,
276
277 pub approval: CapabilityApproval,
281
282 pub transcripts: CapabilityTranscripts,
285}
286
287#[allow(clippy::struct_excessive_bools)]
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct CapabilityFeatures {
291 pub keyword_search: bool,
292 pub semantic_search: bool,
293 pub hybrid_recall: bool,
294 pub query_expansion: bool,
295 pub auto_consolidation: bool,
296 pub auto_tagging: bool,
297 pub contradiction_analysis: bool,
298 pub cross_encoder_reranking: bool,
299 pub memory_reflection: bool,
300 #[serde(default)]
313 pub embedder_loaded: bool,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct CapabilityModels {
319 pub embedding: String,
320 pub embedding_dim: usize,
321 pub llm: String,
322 pub cross_encoder: String,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329pub struct CapabilityPermissions {
330 pub mode: String,
334 pub active_rules: usize,
337 #[serde(default)]
339 pub rule_summary: Vec<String>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, Default)]
346pub struct CapabilityHooks {
347 pub registered_count: usize,
349 #[serde(default)]
351 pub by_event: std::collections::BTreeMap<String, usize>,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, Default)]
357pub struct CapabilityCompaction {
358 pub enabled: bool,
359 #[serde(default)]
360 pub interval_minutes: Option<u64>,
361 #[serde(default)]
362 pub last_run_at: Option<String>,
363 #[serde(default)]
364 pub last_run_stats: Option<serde_json::Value>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, Default)]
371pub struct CapabilityApproval {
372 pub subscribers: usize,
375 pub pending_requests: usize,
377 pub default_timeout_seconds: u64,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, Default)]
384pub struct CapabilityTranscripts {
385 pub enabled: bool,
386 pub total_count: usize,
387 pub total_size_mb: u64,
388}
389
390#[allow(clippy::struct_field_names)]
396#[derive(Debug, Clone, Default, Serialize, Deserialize)]
397pub struct TtlConfig {
398 pub short_ttl_secs: Option<i64>,
400 pub mid_ttl_secs: Option<i64>,
402 pub long_ttl_secs: Option<i64>,
404 pub short_extend_secs: Option<i64>,
406 pub mid_extend_secs: Option<i64>,
408}
409
410#[derive(Debug, Clone)]
412#[allow(clippy::struct_field_names)]
413pub struct ResolvedTtl {
414 pub short_ttl_secs: Option<i64>,
415 pub mid_ttl_secs: Option<i64>,
416 pub long_ttl_secs: Option<i64>,
417 pub short_extend_secs: i64,
418 pub mid_extend_secs: i64,
419}
420
421impl Default for ResolvedTtl {
422 fn default() -> Self {
423 Self {
424 short_ttl_secs: Tier::Short.default_ttl_secs(),
425 mid_ttl_secs: Tier::Mid.default_ttl_secs(),
426 long_ttl_secs: Tier::Long.default_ttl_secs(),
427 short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
428 mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
429 }
430 }
431}
432
433const MAX_TTL_SECS: i64 = 315_360_000;
436
437#[allow(dead_code)]
438impl ResolvedTtl {
439 pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
443 let defaults = Self::default();
444 let Some(c) = cfg else {
445 return defaults;
446 };
447 let clamp_ttl = |v: i64| -> Option<i64> {
448 if v <= 0 {
449 None
450 } else {
451 Some(v.min(MAX_TTL_SECS))
452 }
453 };
454 Self {
455 short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
456 mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
457 long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
458 short_extend_secs: c
459 .short_extend_secs
460 .unwrap_or(defaults.short_extend_secs)
461 .max(0),
462 mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
463 }
464 }
465
466 pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
468 match tier {
469 Tier::Short => self.short_ttl_secs,
470 Tier::Mid => self.mid_ttl_secs,
471 Tier::Long => self.long_ttl_secs,
472 }
473 }
474
475 pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
477 match tier {
478 Tier::Short => Some(self.short_extend_secs),
479 Tier::Mid => Some(self.mid_extend_secs),
480 Tier::Long => None,
481 }
482 }
483}
484
485#[derive(Debug, Clone, Default, Serialize, Deserialize)]
503pub struct RecallScoringConfig {
504 pub half_life_days_short: Option<f64>,
506 pub half_life_days_mid: Option<f64>,
508 pub half_life_days_long: Option<f64>,
510 #[serde(default)]
512 pub legacy_scoring: bool,
513}
514
515#[derive(Debug, Clone, Copy)]
519pub struct ResolvedScoring {
520 pub half_life_days_short: f64,
521 pub half_life_days_mid: f64,
522 pub half_life_days_long: f64,
523 pub legacy_scoring: bool,
524}
525
526impl Default for ResolvedScoring {
527 fn default() -> Self {
528 Self {
529 half_life_days_short: 7.0,
530 half_life_days_mid: 30.0,
531 half_life_days_long: 365.0,
532 legacy_scoring: false,
533 }
534 }
535}
536
537impl ResolvedScoring {
538 const MIN_HALF_LIFE: f64 = 0.1;
539 const MAX_HALF_LIFE: f64 = 36_500.0;
540
541 pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
544 let defaults = Self::default();
545 let Some(c) = cfg else {
546 return defaults;
547 };
548 let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
549 Self {
550 half_life_days_short: c
551 .half_life_days_short
552 .map_or(defaults.half_life_days_short, clamp),
553 half_life_days_mid: c
554 .half_life_days_mid
555 .map_or(defaults.half_life_days_mid, clamp),
556 half_life_days_long: c
557 .half_life_days_long
558 .map_or(defaults.half_life_days_long, clamp),
559 legacy_scoring: c.legacy_scoring,
560 }
561 }
562
563 pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
565 match tier {
566 Tier::Short => self.half_life_days_short,
567 Tier::Mid => self.half_life_days_mid,
568 Tier::Long => self.half_life_days_long,
569 }
570 }
571
572 #[must_use]
577 pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
578 if self.legacy_scoring || age_days <= 0.0 {
579 return 1.0;
580 }
581 let half_life = self.half_life_for_tier(tier);
582 (-std::f64::consts::LN_2 * age_days / half_life).exp()
583 }
584}
585
586const CONFIG_DIR: &str = ".config/ai-memory";
591const CONFIG_FILE: &str = "config.toml";
592
593#[derive(Debug, Clone, Default, Serialize, Deserialize)]
598pub struct AppConfig {
599 pub tier: Option<String>,
601 pub db: Option<String>,
603 pub ollama_url: Option<String>,
605 pub embed_url: Option<String>,
607 pub embedding_model: Option<String>,
609 pub llm_model: Option<String>,
611 pub cross_encoder: Option<bool>,
613 pub default_namespace: Option<String>,
615 pub max_memory_mb: Option<usize>,
617 pub ttl: Option<TtlConfig>,
619 pub archive_on_gc: Option<bool>,
621 pub api_key: Option<String>,
623 pub archive_max_days: Option<i64>,
625 pub identity: Option<IdentityConfig>,
627 pub scoring: Option<RecallScoringConfig>,
630 pub autonomous_hooks: Option<bool>,
636}
637
638#[derive(Debug, Clone, Default, Serialize, Deserialize)]
645pub struct IdentityConfig {
646 #[serde(default)]
650 pub anonymize_default: bool,
651}
652
653impl AppConfig {
654 pub fn config_path() -> Option<PathBuf> {
656 let home = std::env::var("HOME").ok()?;
657 Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
658 }
659
660 pub fn load() -> Self {
663 if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
664 return Self::default();
665 }
666 let Some(path) = Self::config_path() else {
667 return Self::default();
668 };
669 Self::load_from(&path)
670 }
671
672 pub fn load_from(path: &Path) -> Self {
674 match std::fs::read_to_string(path) {
675 Ok(contents) => match toml::from_str(&contents) {
676 Ok(cfg) => {
677 eprintln!("ai-memory: loaded config from {}", path.display());
678 cfg
679 }
680 Err(e) => {
681 eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
682 Self::default()
683 }
684 },
685 Err(_) => Self::default(),
686 }
687 }
688
689 pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
691 let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
692 FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
693 }
694
695 pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
697 let default_db = PathBuf::from("ai-memory.db");
699 if cli_db != default_db {
700 return cli_db.to_path_buf();
701 }
702 self.db
704 .as_ref()
705 .map_or_else(|| cli_db.to_path_buf(), PathBuf::from)
706 }
707
708 pub fn effective_ollama_url(&self) -> &str {
710 self.ollama_url
711 .as_deref()
712 .unwrap_or("http://localhost:11434")
713 }
714
715 pub fn effective_ttl(&self) -> ResolvedTtl {
717 ResolvedTtl::from_config(self.ttl.as_ref())
718 }
719
720 pub fn effective_scoring(&self) -> ResolvedScoring {
723 ResolvedScoring::from_config(self.scoring.as_ref())
724 }
725
726 pub fn effective_archive_on_gc(&self) -> bool {
728 self.archive_on_gc.unwrap_or(true)
729 }
730
731 pub fn effective_autonomous_hooks(&self) -> bool {
737 if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
738 let v = v.trim().to_ascii_lowercase();
739 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
740 return true;
741 }
742 if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
743 return false;
744 }
745 }
746 self.autonomous_hooks.unwrap_or(false)
747 }
748
749 pub fn effective_anonymize_default(&self) -> bool {
752 if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
753 let v = v.trim().to_ascii_lowercase();
754 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
755 return true;
756 }
757 if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
758 return false;
759 }
760 }
761 self.identity.as_ref().is_some_and(|i| i.anonymize_default)
762 }
763
764 pub fn effective_embed_url(&self) -> &str {
766 self.embed_url
767 .as_deref()
768 .or(self.ollama_url.as_deref())
769 .unwrap_or("http://localhost:11434")
770 }
771
772 pub fn write_default_if_missing() {
774 let Some(path) = Self::config_path() else {
775 return;
776 };
777 if path.exists() {
778 return;
779 }
780 if let Some(parent) = path.parent() {
781 let _ = std::fs::create_dir_all(parent);
782 }
783 let default_toml = r#"# ai-memory configuration
784# See: https://github.com/alphaonedev/ai-memory-mcp
785
786# Feature tier: keyword, semantic, smart, autonomous
787# tier = "semantic"
788
789# Path to SQLite database
790# db = "~/.claude/ai-memory.db"
791
792# Ollama base URL (for smart/autonomous tiers)
793# ollama_url = "http://localhost:11434"
794
795# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
796# embedding_model = "mini_lm_l6_v2"
797
798# LLM model tag for Ollama
799# llm_model = "gemma4:e2b"
800
801# Enable neural cross-encoder reranking (autonomous tier)
802# cross_encoder = true
803
804# Default namespace for new memories
805# default_namespace = "global"
806
807# Memory budget in MB (for auto tier selection)
808# max_memory_mb = 4096
809
810# Archive expired memories before GC deletion (default: true)
811# archive_on_gc = true
812
813# Per-tier TTL overrides (uncomment to customize)
814# [ttl]
815# short_ttl_secs = 21600 # 6 hours (default)
816# mid_ttl_secs = 604800 # 7 days (default)
817# long_ttl_secs = 0 # 0 = never expires (default)
818# short_extend_secs = 3600 # +1h on access (default)
819# mid_extend_secs = 86400 # +1d on access (default)
820"#;
821 let _ = std::fs::write(&path, default_toml);
822 }
823}
824
825#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn tier_roundtrip() {
835 for tier in [
836 FeatureTier::Keyword,
837 FeatureTier::Semantic,
838 FeatureTier::Smart,
839 FeatureTier::Autonomous,
840 ] {
841 assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
842 }
843 }
844
845 #[test]
846 fn budget_selection() {
847 assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
848 assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
849 assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
850 assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
851 assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
852 assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
853 assert_eq!(
854 FeatureTier::from_memory_budget(4096),
855 FeatureTier::Autonomous
856 );
857 assert_eq!(
858 FeatureTier::from_memory_budget(8192),
859 FeatureTier::Autonomous
860 );
861 }
862
863 #[test]
864 fn embedding_dimensions() {
865 assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
866 assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
867 }
868
869 #[test]
870 fn autonomous_has_cross_encoder() {
871 let cfg = FeatureTier::Autonomous.config();
872 assert!(cfg.cross_encoder);
873 assert!(cfg.capabilities().features.cross_encoder_reranking);
874 assert!(cfg.capabilities().features.memory_reflection);
875 }
876
877 #[test]
878 fn keyword_has_no_models() {
879 let cfg = FeatureTier::Keyword.config();
880 assert!(cfg.embedding_model.is_none());
881 assert!(cfg.llm_model.is_none());
882 assert!(!cfg.cross_encoder);
883 assert_eq!(cfg.max_memory_mb, 0);
884 }
885
886 #[test]
887 fn capabilities_serialize() {
888 let caps = FeatureTier::Smart.config().capabilities();
889 let json = serde_json::to_string_pretty(&caps).unwrap();
890 assert!(json.contains("\"tier\": \"smart\""));
891 assert!(json.contains("nomic"));
892 assert!(json.contains("gemma4:e2b"));
893 }
894
895 #[test]
899 fn capabilities_v2_zero_state_round_trip() {
900 let caps = FeatureTier::Keyword.config().capabilities();
901 let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
902
903 assert_eq!(val["schema_version"], "2");
904
905 assert_eq!(val["permissions"]["mode"], "ask");
907 assert_eq!(val["permissions"]["active_rules"], 0);
908 assert!(
909 val["permissions"]["rule_summary"]
910 .as_array()
911 .unwrap()
912 .is_empty()
913 );
914
915 assert_eq!(val["hooks"]["registered_count"], 0);
917 assert!(val["hooks"]["by_event"].as_object().unwrap().is_empty());
918
919 assert_eq!(val["compaction"]["enabled"], false);
921 assert!(val["compaction"]["interval_minutes"].is_null());
922 assert!(val["compaction"]["last_run_at"].is_null());
923 assert!(val["compaction"]["last_run_stats"].is_null());
924
925 assert_eq!(val["approval"]["subscribers"], 0);
927 assert_eq!(val["approval"]["pending_requests"], 0);
928 assert_eq!(val["approval"]["default_timeout_seconds"], 30);
929
930 assert_eq!(val["transcripts"]["enabled"], false);
932 assert_eq!(val["transcripts"]["total_count"], 0);
933 assert_eq!(val["transcripts"]["total_size_mb"], 0);
934
935 let restored: Capabilities = serde_json::from_value(val).unwrap();
938 assert_eq!(restored.schema_version, "2");
939 assert_eq!(restored.permissions.mode, "ask");
940 assert_eq!(restored.approval.default_timeout_seconds, 30);
941 }
942
943 #[test]
944 fn config_default_is_empty() {
945 let cfg = AppConfig::default();
946 assert!(cfg.tier.is_none());
947 assert!(cfg.db.is_none());
948 assert!(cfg.ollama_url.is_none());
949 }
950
951 #[test]
952 fn config_parse_toml() {
953 let toml_str = r#"
954 tier = "smart"
955 db = "/tmp/test.db"
956 ollama_url = "http://localhost:11434"
957 cross_encoder = true
958 "#;
959 let cfg: AppConfig = toml::from_str(toml_str).unwrap();
960 assert_eq!(cfg.tier.as_deref(), Some("smart"));
961 assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
962 assert!(cfg.cross_encoder.unwrap());
963 }
964
965 #[test]
966 fn resolved_ttl_defaults_match_hardcoded() {
967 let resolved = ResolvedTtl::default();
968 assert_eq!(resolved.short_ttl_secs, Some(6 * 3600));
969 assert_eq!(resolved.mid_ttl_secs, Some(7 * 24 * 3600));
970 assert_eq!(resolved.long_ttl_secs, None);
971 assert_eq!(resolved.short_extend_secs, 3600);
972 assert_eq!(resolved.mid_extend_secs, 86400);
973 }
974
975 #[test]
976 fn resolved_ttl_from_partial_config() {
977 let cfg = TtlConfig {
978 mid_ttl_secs: Some(90 * 24 * 3600), ..Default::default()
980 };
981 let resolved = ResolvedTtl::from_config(Some(&cfg));
982 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); }
986
987 #[test]
988 fn resolved_ttl_zero_means_no_expiry() {
989 let cfg = TtlConfig {
990 short_ttl_secs: Some(0),
991 mid_ttl_secs: Some(0),
992 ..Default::default()
993 };
994 let resolved = ResolvedTtl::from_config(Some(&cfg));
995 assert_eq!(resolved.short_ttl_secs, None); assert_eq!(resolved.mid_ttl_secs, None);
997 }
998
999 #[test]
1000 fn resolved_ttl_clamps_overflow() {
1001 let cfg = TtlConfig {
1002 mid_ttl_secs: Some(i64::MAX),
1003 short_extend_secs: Some(-3600),
1004 ..Default::default()
1005 };
1006 let resolved = ResolvedTtl::from_config(Some(&cfg));
1007 assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
1009 assert_eq!(resolved.short_extend_secs, 0);
1011 }
1012
1013 #[test]
1014 fn ttl_config_parse_toml() {
1015 let toml_str = r#"
1016 tier = "semantic"
1017 archive_on_gc = false
1018 [ttl]
1019 mid_ttl_secs = 7776000
1020 short_extend_secs = 7200
1021 "#;
1022 let cfg: AppConfig = toml::from_str(toml_str).unwrap();
1023 assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
1024 assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
1025 assert!(!cfg.effective_archive_on_gc());
1026 }
1027
1028 #[test]
1029 fn resolved_ttl_tier_methods() {
1030 let resolved = ResolvedTtl::default();
1031 assert_eq!(resolved.ttl_for_tier(&Tier::Short), Some(6 * 3600));
1032 assert_eq!(resolved.ttl_for_tier(&Tier::Mid), Some(7 * 24 * 3600));
1033 assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
1034 assert_eq!(resolved.extend_for_tier(&Tier::Short), Some(3600));
1035 assert_eq!(resolved.extend_for_tier(&Tier::Mid), Some(86400));
1036 assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
1037 }
1038
1039 #[test]
1040 fn config_effective_tier() {
1041 let cfg = AppConfig {
1042 tier: Some("smart".to_string()),
1043 ..Default::default()
1044 };
1045 assert_eq!(
1047 cfg.effective_tier(Some("autonomous")),
1048 FeatureTier::Autonomous
1049 );
1050 assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
1052 }
1053
1054 #[test]
1057 fn scoring_defaults_match_spec() {
1058 let s = ResolvedScoring::default();
1059 assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
1060 assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
1061 assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
1062 assert!(!s.legacy_scoring);
1063 }
1064
1065 #[test]
1066 fn scoring_from_config_overrides() {
1067 let cfg = RecallScoringConfig {
1068 half_life_days_short: Some(3.5),
1069 half_life_days_mid: Some(14.0),
1070 half_life_days_long: Some(730.0),
1071 legacy_scoring: false,
1072 };
1073 let s = ResolvedScoring::from_config(Some(&cfg));
1074 assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
1075 assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
1076 assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
1077 }
1078
1079 #[test]
1080 fn scoring_clamps_out_of_range() {
1081 let cfg = RecallScoringConfig {
1082 half_life_days_short: Some(-10.0),
1083 half_life_days_mid: Some(0.0),
1084 half_life_days_long: Some(1_000_000.0),
1085 legacy_scoring: false,
1086 };
1087 let s = ResolvedScoring::from_config(Some(&cfg));
1088 assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
1089 assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
1090 assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
1091 }
1092
1093 #[test]
1094 fn scoring_decay_at_half_life_is_half() {
1095 let s = ResolvedScoring::default();
1096 let d = s.decay_multiplier(&Tier::Short, 7.0);
1098 assert!((d - 0.5).abs() < 1e-9);
1099 let d = s.decay_multiplier(&Tier::Mid, 30.0);
1100 assert!((d - 0.5).abs() < 1e-9);
1101 let d = s.decay_multiplier(&Tier::Long, 365.0);
1102 assert!((d - 0.5).abs() < 1e-9);
1103 }
1104
1105 #[test]
1106 fn scoring_decay_monotonic() {
1107 let s = ResolvedScoring::default();
1108 let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
1109 let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
1110 assert!(d_new > d_old);
1112 assert!(d_new < 1.0);
1113 assert!(d_old > 0.0);
1114 }
1115
1116 #[test]
1117 fn scoring_decay_zero_age_is_one() {
1118 let s = ResolvedScoring::default();
1119 assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
1120 assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
1122 }
1123
1124 #[test]
1125 fn scoring_legacy_disables_decay() {
1126 let cfg = RecallScoringConfig {
1127 legacy_scoring: true,
1128 ..Default::default()
1129 };
1130 let s = ResolvedScoring::from_config(Some(&cfg));
1131 assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
1133 assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
1134 assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
1135 }
1136
1137 #[test]
1138 fn effective_scoring_on_empty_config() {
1139 let cfg = AppConfig::default();
1140 let s = cfg.effective_scoring();
1141 assert_eq!(s.half_life_days_short, 7.0);
1142 assert!(!s.legacy_scoring);
1143 }
1144
1145 #[test]
1146 fn scoring_roundtrip_through_toml() {
1147 let toml_src = r"
1148[scoring]
1149half_life_days_short = 5.0
1150half_life_days_mid = 25.0
1151legacy_scoring = false
1152";
1153 let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
1154 let s = cfg.effective_scoring();
1155 assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
1156 assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
1157 assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
1159 }
1160
1161 #[test]
1165 fn effective_tier_cli_overrides_config() {
1166 let cfg = AppConfig {
1167 tier: Some("smart".to_string()),
1168 ..AppConfig::default()
1169 };
1170 assert_eq!(
1172 cfg.effective_tier(Some("autonomous")),
1173 FeatureTier::Autonomous
1174 );
1175 assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
1177 }
1178
1179 #[test]
1180 fn effective_tier_unknown_falls_back_to_semantic() {
1181 let cfg = AppConfig::default();
1182 assert_eq!(
1183 cfg.effective_tier(Some("invalid-tier")),
1184 FeatureTier::Semantic
1185 );
1186 assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
1188 }
1189
1190 #[test]
1191 fn effective_db_cli_path_wins_when_non_default() {
1192 let cfg = AppConfig {
1193 db: Some("/from/config.db".to_string()),
1194 ..AppConfig::default()
1195 };
1196 let cli_path = Path::new("/from/cli.db");
1197 assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
1198 }
1199
1200 #[test]
1201 fn effective_db_falls_back_to_config_when_cli_default() {
1202 let cfg = AppConfig {
1203 db: Some("/from/config.db".to_string()),
1204 ..AppConfig::default()
1205 };
1206 assert_eq!(
1208 cfg.effective_db(Path::new("ai-memory.db")),
1209 PathBuf::from("/from/config.db")
1210 );
1211 }
1212
1213 #[test]
1214 fn effective_db_falls_back_to_cli_when_no_config() {
1215 let cfg = AppConfig::default();
1216 let cli_path = Path::new("ai-memory.db");
1217 assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
1218 }
1219
1220 #[test]
1221 fn effective_ollama_url_default_when_unset() {
1222 let cfg = AppConfig::default();
1223 assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
1224 }
1225
1226 #[test]
1227 fn effective_ollama_url_uses_configured_value() {
1228 let cfg = AppConfig {
1229 ollama_url: Some("http://my-host:9999".to_string()),
1230 ..AppConfig::default()
1231 };
1232 assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
1233 }
1234
1235 #[test]
1236 fn effective_embed_url_falls_back_to_ollama_url() {
1237 let cfg = AppConfig {
1238 ollama_url: Some("http://ollama:11434".to_string()),
1239 ..AppConfig::default()
1240 };
1241 assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
1243 }
1244
1245 #[test]
1246 fn effective_embed_url_uses_dedicated_value_when_set() {
1247 let cfg = AppConfig {
1248 ollama_url: Some("http://ollama:11434".to_string()),
1249 embed_url: Some("http://embed:8080".to_string()),
1250 ..AppConfig::default()
1251 };
1252 assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
1254 }
1255
1256 #[test]
1257 fn effective_embed_url_uses_default_when_neither_set() {
1258 let cfg = AppConfig::default();
1259 assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
1260 }
1261
1262 #[test]
1263 fn effective_archive_on_gc_default_is_true() {
1264 let cfg = AppConfig::default();
1265 assert!(cfg.effective_archive_on_gc());
1266 }
1267
1268 #[test]
1269 fn effective_archive_on_gc_respects_explicit_false() {
1270 let cfg = AppConfig {
1271 archive_on_gc: Some(false),
1272 ..AppConfig::default()
1273 };
1274 assert!(!cfg.effective_archive_on_gc());
1275 }
1276
1277 #[test]
1278 fn effective_autonomous_hooks_default_is_false() {
1279 unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
1284 let cfg = AppConfig::default();
1285 assert!(!cfg.effective_autonomous_hooks());
1286 }
1287
1288 #[test]
1289 fn effective_autonomous_hooks_config_value_used_when_env_unset() {
1290 unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
1291 let cfg = AppConfig {
1292 autonomous_hooks: Some(true),
1293 ..AppConfig::default()
1294 };
1295 assert!(cfg.effective_autonomous_hooks());
1296 }
1297
1298 #[test]
1299 fn effective_anonymize_default_falls_back_to_config() {
1300 unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
1301 let cfg = AppConfig::default();
1302 assert!(!cfg.effective_anonymize_default());
1303 }
1304
1305 #[test]
1306 fn write_default_if_missing_creates_file_then_noops() {
1307 let tmp = tempfile::tempdir().unwrap();
1309 unsafe { std::env::set_var("HOME", tmp.path()) };
1311 AppConfig::write_default_if_missing();
1313 let expected = AppConfig::config_path().unwrap();
1314 assert!(expected.exists(), "config not written at {expected:?}");
1315 let original = std::fs::read_to_string(&expected).unwrap();
1316 assert!(original.contains("ai-memory configuration"));
1317 std::fs::write(&expected, "# user-edited\n").unwrap();
1319 AppConfig::write_default_if_missing();
1320 let after = std::fs::read_to_string(&expected).unwrap();
1321 assert_eq!(after, "# user-edited\n");
1322 }
1323
1324 #[test]
1325 fn config_path_returns_some_when_home_set() {
1326 unsafe { std::env::set_var("HOME", "/some/home") };
1328 let path = AppConfig::config_path().unwrap();
1329 assert!(path.starts_with("/some/home"));
1330 }
1331
1332 #[test]
1333 fn load_from_returns_default_for_missing_file() {
1334 let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
1336 assert!(cfg.tier.is_none());
1337 assert!(cfg.db.is_none());
1338 }
1339
1340 #[test]
1341 fn load_from_returns_default_for_unparseable_toml() {
1342 let tmp = tempfile::NamedTempFile::new().unwrap();
1344 std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
1345 let cfg = AppConfig::load_from(tmp.path());
1346 assert!(cfg.tier.is_none());
1347 }
1348
1349 #[test]
1350 fn load_from_parses_valid_toml() {
1351 let tmp = tempfile::NamedTempFile::new().unwrap();
1352 std::fs::write(
1353 tmp.path(),
1354 r#"
1355 tier = "smart"
1356 db = "/disk.db"
1357 "#,
1358 )
1359 .unwrap();
1360 let cfg = AppConfig::load_from(tmp.path());
1361 assert_eq!(cfg.tier.as_deref(), Some("smart"));
1362 assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
1363 }
1364}