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}