Skip to main content

lean_ctx/core/knowledge/
persist.rs

1use 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    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
63    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
64    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}