lean_ctx/core/knowledge/
persist.rs1use chrono::Utc;
2use std::path::PathBuf;
3
4use super::ranking::hash_project_root;
5use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern};
6use crate::core::memory_policy::MemoryPolicy;
7
8fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
9 Ok(crate::core::data_dir::lean_ctx_data_dir()?
10 .join("knowledge")
11 .join(project_hash))
12}
13
14impl ProjectKnowledge {
15 pub fn save(&self) -> Result<(), String> {
16 let dir = knowledge_dir(&self.project_hash)?;
17 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
18
19 let path = dir.join("knowledge.json");
20 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
21 std::fs::write(&path, json).map_err(|e| e.to_string())
22 }
23
24 pub fn load(project_root: &str) -> Option<Self> {
25 let hash = hash_project_root(project_root);
26 let dir = knowledge_dir(&hash).ok()?;
27 let path = dir.join("knowledge.json");
28
29 if let Ok(content) = std::fs::read_to_string(&path) {
30 let size = content.len();
31 if size > 1_000_000 {
32 tracing::warn!(
33 "knowledge.json is large ({:.1} MB) — recall may be slow. \
34 Consider running ctx_knowledge(action=\"consolidate\") to compact it.",
35 size as f64 / 1_048_576.0,
36 );
37 }
38 if let Ok(k) = serde_json::from_str::<Self>(&content) {
39 return Some(k);
40 }
41 }
42
43 let old_hash = crate::core::project_hash::hash_path_only(project_root);
44 if old_hash != hash {
45 crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
46 if let Ok(content) = std::fs::read_to_string(&path) {
47 if let Ok(mut k) = serde_json::from_str::<Self>(&content) {
48 k.project_hash = hash;
49 let _ = k.save();
50 return Some(k);
51 }
52 }
53 }
54
55 None
56 }
57
58 pub fn load_or_create(project_root: &str) -> Self {
59 Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
60 }
61
62 pub fn migrate_legacy_empty_root(
65 target_root: &str,
66 policy: &MemoryPolicy,
67 ) -> Result<bool, String> {
68 if target_root.trim().is_empty() {
69 return Ok(false);
70 }
71
72 let Some(legacy) = Self::load("") else {
73 return Ok(false);
74 };
75
76 if !legacy.project_root.trim().is_empty() {
77 return Ok(false);
78 }
79 if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
80 return Ok(false);
81 }
82
83 let mut target = Self::load_or_create(target_root);
84
85 fn fact_key(f: &KnowledgeFact) -> String {
86 format!(
87 "{}|{}|{}|{}|{}",
88 f.category, f.key, f.value, f.source_session, f.created_at
89 )
90 }
91 fn pattern_key(p: &ProjectPattern) -> String {
92 format!(
93 "{}|{}|{}|{}",
94 p.pattern_type, p.description, p.source_session, p.created_at
95 )
96 }
97 fn history_key(h: &ConsolidatedInsight) -> String {
98 format!(
99 "{}|{}|{}",
100 h.summary,
101 h.from_sessions.join(","),
102 h.timestamp
103 )
104 }
105
106 let mut seen_facts: std::collections::HashSet<String> =
107 target.facts.iter().map(fact_key).collect();
108 for f in legacy.facts {
109 if seen_facts.insert(fact_key(&f)) {
110 target.facts.push(f);
111 }
112 }
113
114 let mut seen_patterns: std::collections::HashSet<String> =
115 target.patterns.iter().map(pattern_key).collect();
116 for p in legacy.patterns {
117 if seen_patterns.insert(pattern_key(&p)) {
118 target.patterns.push(p);
119 }
120 }
121
122 let mut seen_history: std::collections::HashSet<String> =
123 target.history.iter().map(history_key).collect();
124 for h in legacy.history {
125 if seen_history.insert(history_key(&h)) {
126 target.history.push(h);
127 }
128 }
129
130 target.facts.sort_by(|a, b| {
131 b.created_at
132 .cmp(&a.created_at)
133 .then_with(|| b.confidence.total_cmp(&a.confidence))
134 });
135 if target.facts.len() > policy.knowledge.max_facts {
136 target.facts.truncate(policy.knowledge.max_facts);
137 }
138 target
139 .patterns
140 .sort_by_key(|x| std::cmp::Reverse(x.created_at));
141 if target.patterns.len() > policy.knowledge.max_patterns {
142 target.patterns.truncate(policy.knowledge.max_patterns);
143 }
144 target
145 .history
146 .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
147 if target.history.len() > policy.knowledge.max_history {
148 target.history.truncate(policy.knowledge.max_history);
149 }
150
151 target.updated_at = Utc::now();
152 target.save()?;
153
154 let legacy_hash = crate::core::project_hash::hash_path_only("");
155 let legacy_dir = knowledge_dir(&legacy_hash)?;
156 let legacy_path = legacy_dir.join("knowledge.json");
157 if legacy_path.exists() {
158 let ts = Utc::now().format("%Y%m%d-%H%M%S");
159 let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
160 std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
161 }
162
163 Ok(true)
164 }
165}