Skip to main content

lean_ctx/core/
knowledge.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_FACTS: usize = 200;
6const MAX_PATTERNS: usize = 50;
7const MAX_HISTORY: usize = 100;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ProjectKnowledge {
11    pub project_root: String,
12    pub project_hash: String,
13    pub facts: Vec<KnowledgeFact>,
14    pub patterns: Vec<ProjectPattern>,
15    pub history: Vec<ConsolidatedInsight>,
16    pub updated_at: DateTime<Utc>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct KnowledgeFact {
21    pub category: String,
22    pub key: String,
23    pub value: String,
24    pub source_session: String,
25    pub confidence: f32,
26    pub created_at: DateTime<Utc>,
27    pub last_confirmed: DateTime<Utc>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ProjectPattern {
32    pub pattern_type: String,
33    pub description: String,
34    pub examples: Vec<String>,
35    pub source_session: String,
36    pub created_at: DateTime<Utc>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ConsolidatedInsight {
41    pub summary: String,
42    pub from_sessions: Vec<String>,
43    pub timestamp: DateTime<Utc>,
44}
45
46impl ProjectKnowledge {
47    pub fn new(project_root: &str) -> Self {
48        Self {
49            project_root: project_root.to_string(),
50            project_hash: hash_project_root(project_root),
51            facts: Vec::new(),
52            patterns: Vec::new(),
53            history: Vec::new(),
54            updated_at: Utc::now(),
55        }
56    }
57
58    pub fn remember(
59        &mut self,
60        category: &str,
61        key: &str,
62        value: &str,
63        session_id: &str,
64        confidence: f32,
65    ) {
66        if let Some(existing) = self
67            .facts
68            .iter_mut()
69            .find(|f| f.category == category && f.key == key)
70        {
71            existing.value = value.to_string();
72            existing.confidence = confidence;
73            existing.last_confirmed = Utc::now();
74            existing.source_session = session_id.to_string();
75        } else {
76            let now = Utc::now();
77            self.facts.push(KnowledgeFact {
78                category: category.to_string(),
79                key: key.to_string(),
80                value: value.to_string(),
81                source_session: session_id.to_string(),
82                confidence,
83                created_at: now,
84                last_confirmed: now,
85            });
86            if self.facts.len() > MAX_FACTS {
87                self.facts
88                    .sort_by(|a, b| b.last_confirmed.cmp(&a.last_confirmed));
89                self.facts.truncate(MAX_FACTS);
90            }
91        }
92        self.updated_at = Utc::now();
93    }
94
95    pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
96        let q = query.to_lowercase();
97        let terms: Vec<&str> = q.split_whitespace().collect();
98
99        let mut results: Vec<(&KnowledgeFact, f32)> = self
100            .facts
101            .iter()
102            .filter_map(|f| {
103                let searchable = format!(
104                    "{} {} {} {}",
105                    f.category.to_lowercase(),
106                    f.key.to_lowercase(),
107                    f.value.to_lowercase(),
108                    f.source_session
109                );
110                let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
111                if match_count > 0 {
112                    let relevance = (match_count as f32 / terms.len() as f32) * f.confidence;
113                    Some((f, relevance))
114                } else {
115                    None
116                }
117            })
118            .collect();
119
120        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
121        results.into_iter().map(|(f, _)| f).collect()
122    }
123
124    pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
125        self.facts
126            .iter()
127            .filter(|f| f.category == category)
128            .collect()
129    }
130
131    pub fn add_pattern(
132        &mut self,
133        pattern_type: &str,
134        description: &str,
135        examples: Vec<String>,
136        session_id: &str,
137    ) {
138        if let Some(existing) = self
139            .patterns
140            .iter_mut()
141            .find(|p| p.pattern_type == pattern_type && p.description == description)
142        {
143            for ex in &examples {
144                if !existing.examples.contains(ex) {
145                    existing.examples.push(ex.clone());
146                }
147            }
148            return;
149        }
150
151        self.patterns.push(ProjectPattern {
152            pattern_type: pattern_type.to_string(),
153            description: description.to_string(),
154            examples,
155            source_session: session_id.to_string(),
156            created_at: Utc::now(),
157        });
158
159        if self.patterns.len() > MAX_PATTERNS {
160            self.patterns.truncate(MAX_PATTERNS);
161        }
162        self.updated_at = Utc::now();
163    }
164
165    pub fn consolidate(&mut self, summary: &str, session_ids: Vec<String>) {
166        self.history.push(ConsolidatedInsight {
167            summary: summary.to_string(),
168            from_sessions: session_ids,
169            timestamp: Utc::now(),
170        });
171
172        if self.history.len() > MAX_HISTORY {
173            self.history.drain(0..self.history.len() - MAX_HISTORY);
174        }
175        self.updated_at = Utc::now();
176    }
177
178    pub fn remove_fact(&mut self, category: &str, key: &str) -> bool {
179        let before = self.facts.len();
180        self.facts
181            .retain(|f| !(f.category == category && f.key == key));
182        let removed = self.facts.len() < before;
183        if removed {
184            self.updated_at = Utc::now();
185        }
186        removed
187    }
188
189    pub fn format_summary(&self) -> String {
190        let mut out = String::new();
191
192        if !self.facts.is_empty() {
193            out.push_str("PROJECT KNOWLEDGE:\n");
194            let mut categories: Vec<&str> =
195                self.facts.iter().map(|f| f.category.as_str()).collect();
196            categories.sort();
197            categories.dedup();
198
199            for cat in categories {
200                out.push_str(&format!("  [{cat}]\n"));
201                for f in self.facts.iter().filter(|f| f.category == cat) {
202                    out.push_str(&format!(
203                        "    {}: {} (confidence: {:.0}%)\n",
204                        f.key,
205                        f.value,
206                        f.confidence * 100.0
207                    ));
208                }
209            }
210        }
211
212        if !self.patterns.is_empty() {
213            out.push_str("PROJECT PATTERNS:\n");
214            for p in &self.patterns {
215                out.push_str(&format!("  [{}] {}\n", p.pattern_type, p.description));
216            }
217        }
218
219        out
220    }
221
222    pub fn save(&self) -> Result<(), String> {
223        let dir = knowledge_dir(&self.project_hash)?;
224        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
225
226        let path = dir.join("knowledge.json");
227        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
228        std::fs::write(&path, json).map_err(|e| e.to_string())
229    }
230
231    pub fn load(project_root: &str) -> Option<Self> {
232        let hash = hash_project_root(project_root);
233        let dir = knowledge_dir(&hash).ok()?;
234        let path = dir.join("knowledge.json");
235
236        let content = std::fs::read_to_string(&path).ok()?;
237        serde_json::from_str(&content).ok()
238    }
239
240    pub fn load_or_create(project_root: &str) -> Self {
241        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
242    }
243}
244
245fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
246    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
247    Ok(home.join(".lean-ctx").join("knowledge").join(project_hash))
248}
249
250fn hash_project_root(root: &str) -> String {
251    use std::collections::hash_map::DefaultHasher;
252    use std::hash::{Hash, Hasher};
253
254    let mut hasher = DefaultHasher::new();
255    root.hash(&mut hasher);
256    format!("{:016x}", hasher.finish())
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn remember_and_recall() {
265        let mut k = ProjectKnowledge::new("/tmp/test-project");
266        k.remember("architecture", "auth", "JWT RS256", "session-1", 0.9);
267        k.remember("api", "rate-limit", "100/min", "session-1", 0.8);
268
269        let results = k.recall("auth");
270        assert_eq!(results.len(), 1);
271        assert_eq!(results[0].value, "JWT RS256");
272
273        let results = k.recall("api rate");
274        assert_eq!(results.len(), 1);
275        assert_eq!(results[0].key, "rate-limit");
276    }
277
278    #[test]
279    fn upsert_existing_fact() {
280        let mut k = ProjectKnowledge::new("/tmp/test");
281        k.remember("arch", "db", "PostgreSQL", "s1", 0.7);
282        k.remember("arch", "db", "PostgreSQL 16 with pgvector", "s2", 0.95);
283
284        assert_eq!(k.facts.len(), 1);
285        assert_eq!(k.facts[0].value, "PostgreSQL 16 with pgvector");
286        assert_eq!(k.facts[0].confidence, 0.95);
287    }
288
289    #[test]
290    fn remove_fact() {
291        let mut k = ProjectKnowledge::new("/tmp/test");
292        k.remember("arch", "db", "PostgreSQL", "s1", 0.9);
293        assert!(k.remove_fact("arch", "db"));
294        assert!(k.facts.is_empty());
295        assert!(!k.remove_fact("arch", "db"));
296    }
297
298    #[test]
299    fn consolidate_history() {
300        let mut k = ProjectKnowledge::new("/tmp/test");
301        k.consolidate(
302            "Migrated from REST to GraphQL",
303            vec!["s1".into(), "s2".into()],
304        );
305        assert_eq!(k.history.len(), 1);
306        assert_eq!(k.history[0].from_sessions.len(), 2);
307    }
308
309    #[test]
310    fn format_summary_output() {
311        let mut k = ProjectKnowledge::new("/tmp/test");
312        k.remember("architecture", "auth", "JWT RS256", "s1", 0.9);
313        k.add_pattern(
314            "naming",
315            "snake_case for functions",
316            vec!["get_user()".into()],
317            "s1",
318        );
319        let summary = k.format_summary();
320        assert!(summary.contains("PROJECT KNOWLEDGE:"));
321        assert!(summary.contains("auth: JWT RS256"));
322        assert!(summary.contains("PROJECT PATTERNS:"));
323    }
324}