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;
8const CONTRADICTION_THRESHOLD: f32 = 0.5;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ProjectKnowledge {
12 pub project_root: String,
13 pub project_hash: String,
14 pub facts: Vec<KnowledgeFact>,
15 pub patterns: Vec<ProjectPattern>,
16 pub history: Vec<ConsolidatedInsight>,
17 pub updated_at: DateTime<Utc>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct KnowledgeFact {
22 pub category: String,
23 pub key: String,
24 pub value: String,
25 pub source_session: String,
26 pub confidence: f32,
27 pub created_at: DateTime<Utc>,
28 pub last_confirmed: DateTime<Utc>,
29 #[serde(default)]
30 pub retrieval_count: u32,
31 #[serde(default)]
32 pub last_retrieved: Option<DateTime<Utc>>,
33 #[serde(default)]
34 pub valid_from: Option<DateTime<Utc>>,
35 #[serde(default)]
36 pub valid_until: Option<DateTime<Utc>>,
37 #[serde(default)]
38 pub supersedes: Option<String>,
39 #[serde(default)]
40 pub confirmation_count: u32,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Contradiction {
45 pub existing_key: String,
46 pub existing_value: String,
47 pub new_value: String,
48 pub category: String,
49 pub severity: ContradictionSeverity,
50 pub resolution: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub enum ContradictionSeverity {
55 Low,
56 Medium,
57 High,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProjectPattern {
62 pub pattern_type: String,
63 pub description: String,
64 pub examples: Vec<String>,
65 pub source_session: String,
66 pub created_at: DateTime<Utc>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ConsolidatedInsight {
71 pub summary: String,
72 pub from_sessions: Vec<String>,
73 pub timestamp: DateTime<Utc>,
74}
75
76impl ProjectKnowledge {
77 pub fn run_memory_lifecycle(&mut self) -> crate::core::memory_lifecycle::LifecycleReport {
78 let cfg = crate::core::memory_lifecycle::LifecycleConfig {
79 max_facts: MAX_FACTS,
80 ..Default::default()
81 };
82 crate::core::memory_lifecycle::run_lifecycle(&mut self.facts, &cfg)
83 }
84
85 pub fn new(project_root: &str) -> Self {
86 Self {
87 project_root: project_root.to_string(),
88 project_hash: hash_project_root(project_root),
89 facts: Vec::new(),
90 patterns: Vec::new(),
91 history: Vec::new(),
92 updated_at: Utc::now(),
93 }
94 }
95
96 pub fn check_contradiction(
97 &self,
98 category: &str,
99 key: &str,
100 new_value: &str,
101 ) -> Option<Contradiction> {
102 let existing = self
103 .facts
104 .iter()
105 .find(|f| f.category == category && f.key == key && f.is_current())?;
106
107 if existing.value.to_lowercase() == new_value.to_lowercase() {
108 return None;
109 }
110
111 let similarity = string_similarity(&existing.value, new_value);
112 if similarity > 0.8 {
113 return None;
114 }
115
116 let severity = if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
117 ContradictionSeverity::High
118 } else if existing.confidence >= CONTRADICTION_THRESHOLD {
119 ContradictionSeverity::Medium
120 } else {
121 ContradictionSeverity::Low
122 };
123
124 let resolution = match severity {
125 ContradictionSeverity::High => format!(
126 "High-confidence fact [{category}/{key}] changed: '{}' -> '{new_value}' (was confirmed {}x). Previous value archived.",
127 existing.value, existing.confirmation_count
128 ),
129 ContradictionSeverity::Medium => format!(
130 "Fact [{category}/{key}] updated: '{}' -> '{new_value}'",
131 existing.value
132 ),
133 ContradictionSeverity::Low => format!(
134 "Low-confidence fact [{category}/{key}] replaced: '{}' -> '{new_value}'",
135 existing.value
136 ),
137 };
138
139 Some(Contradiction {
140 existing_key: key.to_string(),
141 existing_value: existing.value.clone(),
142 new_value: new_value.to_string(),
143 category: category.to_string(),
144 severity,
145 resolution,
146 })
147 }
148
149 pub fn remember(
150 &mut self,
151 category: &str,
152 key: &str,
153 value: &str,
154 session_id: &str,
155 confidence: f32,
156 ) -> Option<Contradiction> {
157 let contradiction = self.check_contradiction(category, key, value);
158
159 if let Some(existing) = self
160 .facts
161 .iter_mut()
162 .find(|f| f.category == category && f.key == key && f.is_current())
163 {
164 if existing.value != value {
165 if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
166 existing.valid_until = Some(Utc::now());
167 let superseded_id = format!("{}/{}", existing.category, existing.key);
168 let now = Utc::now();
169 self.facts.push(KnowledgeFact {
170 category: category.to_string(),
171 key: key.to_string(),
172 value: value.to_string(),
173 source_session: session_id.to_string(),
174 confidence,
175 created_at: now,
176 last_confirmed: now,
177 retrieval_count: 0,
178 last_retrieved: None,
179 valid_from: Some(now),
180 valid_until: None,
181 supersedes: Some(superseded_id),
182 confirmation_count: 1,
183 });
184 } else {
185 existing.value = value.to_string();
186 existing.confidence = confidence;
187 existing.last_confirmed = Utc::now();
188 existing.source_session = session_id.to_string();
189 existing.valid_from = existing.valid_from.or(Some(existing.created_at));
190 existing.confirmation_count = 1;
191 }
192 } else {
193 existing.last_confirmed = Utc::now();
194 existing.source_session = session_id.to_string();
195 existing.confidence = (existing.confidence + confidence) / 2.0;
196 existing.confirmation_count += 1;
197 }
198 } else {
199 let now = Utc::now();
200 self.facts.push(KnowledgeFact {
201 category: category.to_string(),
202 key: key.to_string(),
203 value: value.to_string(),
204 source_session: session_id.to_string(),
205 confidence,
206 created_at: now,
207 last_confirmed: now,
208 retrieval_count: 0,
209 last_retrieved: None,
210 valid_from: Some(now),
211 valid_until: None,
212 supersedes: None,
213 confirmation_count: 1,
214 });
215 }
216
217 if self.facts.len() > MAX_FACTS * 2 {
219 let _ = self.run_memory_lifecycle();
220 }
221
222 self.updated_at = Utc::now();
223
224 let action = if contradiction.is_some() {
225 "contradict"
226 } else {
227 "remember"
228 };
229 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
230 category: category.to_string(),
231 key: key.to_string(),
232 action: action.to_string(),
233 });
234
235 contradiction
236 }
237
238 pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
239 let q = query.to_lowercase();
240 let terms: Vec<&str> = q.split_whitespace().collect();
241
242 let mut results: Vec<(&KnowledgeFact, f32)> = self
243 .facts
244 .iter()
245 .filter(|f| f.is_current())
246 .filter_map(|f| {
247 let searchable = format!(
248 "{} {} {} {}",
249 f.category.to_lowercase(),
250 f.key.to_lowercase(),
251 f.value.to_lowercase(),
252 f.source_session
253 );
254 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
255 if match_count > 0 {
256 let relevance = (match_count as f32 / terms.len() as f32) * f.confidence;
257 Some((f, relevance))
258 } else {
259 None
260 }
261 })
262 .collect();
263
264 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
265 results.into_iter().map(|(f, _)| f).collect()
266 }
267
268 pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
269 self.facts
270 .iter()
271 .filter(|f| f.category == category && f.is_current())
272 .collect()
273 }
274
275 pub fn recall_at_time(&self, query: &str, at: DateTime<Utc>) -> Vec<&KnowledgeFact> {
276 let q = query.to_lowercase();
277 let terms: Vec<&str> = q.split_whitespace().collect();
278
279 let mut results: Vec<(&KnowledgeFact, f32)> = self
280 .facts
281 .iter()
282 .filter(|f| f.was_valid_at(at))
283 .filter_map(|f| {
284 let searchable = format!(
285 "{} {} {}",
286 f.category.to_lowercase(),
287 f.key.to_lowercase(),
288 f.value.to_lowercase(),
289 );
290 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
291 if match_count > 0 {
292 Some((f, match_count as f32 / terms.len() as f32))
293 } else {
294 None
295 }
296 })
297 .collect();
298
299 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
300 results.into_iter().map(|(f, _)| f).collect()
301 }
302
303 pub fn timeline(&self, category: &str) -> Vec<&KnowledgeFact> {
304 let mut facts: Vec<&KnowledgeFact> = self
305 .facts
306 .iter()
307 .filter(|f| f.category == category)
308 .collect();
309 facts.sort_by_key(|x| x.created_at);
310 facts
311 }
312
313 pub fn list_rooms(&self) -> Vec<(String, usize)> {
314 let mut categories: std::collections::BTreeMap<String, usize> =
315 std::collections::BTreeMap::new();
316 for f in &self.facts {
317 if f.is_current() {
318 *categories.entry(f.category.clone()).or_insert(0) += 1;
319 }
320 }
321 categories.into_iter().collect()
322 }
323
324 pub fn add_pattern(
325 &mut self,
326 pattern_type: &str,
327 description: &str,
328 examples: Vec<String>,
329 session_id: &str,
330 ) {
331 if let Some(existing) = self
332 .patterns
333 .iter_mut()
334 .find(|p| p.pattern_type == pattern_type && p.description == description)
335 {
336 for ex in &examples {
337 if !existing.examples.contains(ex) {
338 existing.examples.push(ex.clone());
339 }
340 }
341 return;
342 }
343
344 self.patterns.push(ProjectPattern {
345 pattern_type: pattern_type.to_string(),
346 description: description.to_string(),
347 examples,
348 source_session: session_id.to_string(),
349 created_at: Utc::now(),
350 });
351
352 if self.patterns.len() > MAX_PATTERNS {
353 self.patterns.truncate(MAX_PATTERNS);
354 }
355 self.updated_at = Utc::now();
356 }
357
358 pub fn consolidate(&mut self, summary: &str, session_ids: Vec<String>) {
359 self.history.push(ConsolidatedInsight {
360 summary: summary.to_string(),
361 from_sessions: session_ids,
362 timestamp: Utc::now(),
363 });
364
365 if self.history.len() > MAX_HISTORY {
366 self.history.drain(0..self.history.len() - MAX_HISTORY);
367 }
368 self.updated_at = Utc::now();
369 }
370
371 pub fn remove_fact(&mut self, category: &str, key: &str) -> bool {
372 let before = self.facts.len();
373 self.facts
374 .retain(|f| !(f.category == category && f.key == key));
375 let removed = self.facts.len() < before;
376 if removed {
377 self.updated_at = Utc::now();
378 }
379 removed
380 }
381
382 pub fn format_summary(&self) -> String {
383 let mut out = String::new();
384 let current_facts: Vec<&KnowledgeFact> =
385 self.facts.iter().filter(|f| f.is_current()).collect();
386
387 if !current_facts.is_empty() {
388 out.push_str("PROJECT KNOWLEDGE:\n");
389 let mut rooms: Vec<(String, usize)> = self.list_rooms();
390 rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
391
392 let total_rooms = rooms.len();
393 rooms.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT);
394
395 for (cat, _count) in rooms {
396 out.push_str(&format!(" [{cat}]\n"));
397
398 let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
399 .iter()
400 .copied()
401 .filter(|f| f.category == cat)
402 .collect();
403 facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
404
405 let total_in_cat = facts_in_cat.len();
406 facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT);
407
408 for f in facts_in_cat {
409 let key = crate::core::sanitize::neutralize_metadata(&f.key);
410 let val = crate::core::sanitize::neutralize_metadata(&f.value);
411 out.push_str(&format!(
412 " {}: {} (confidence: {:.0}%)\n",
413 key,
414 val,
415 f.confidence * 100.0
416 ));
417 }
418 if total_in_cat > crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT {
419 out.push_str(&format!(
420 " … +{} more\n",
421 total_in_cat - crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT
422 ));
423 }
424 }
425
426 if total_rooms > crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT {
427 out.push_str(&format!(
428 " … +{} more rooms\n",
429 total_rooms - crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT
430 ));
431 }
432 }
433
434 if !self.patterns.is_empty() {
435 out.push_str("PROJECT PATTERNS:\n");
436 let mut patterns = self.patterns.clone();
437 patterns.sort_by(|a, b| {
438 b.created_at
439 .cmp(&a.created_at)
440 .then_with(|| a.pattern_type.cmp(&b.pattern_type))
441 .then_with(|| a.description.cmp(&b.description))
442 });
443 let total = patterns.len();
444 patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
445 for p in &patterns {
446 let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
447 let desc = crate::core::sanitize::neutralize_metadata(&p.description);
448 out.push_str(&format!(" [{ty}] {desc}\n"));
449 }
450 if total > crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT {
451 out.push_str(&format!(
452 " … +{} more\n",
453 total - crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT
454 ));
455 }
456 }
457
458 if out.is_empty() {
459 out
460 } else {
461 crate::core::sanitize::fence_content("project_knowledge", out.trim_end())
462 }
463 }
464
465 pub fn format_aaak(&self) -> String {
466 let current_facts: Vec<&KnowledgeFact> =
467 self.facts.iter().filter(|f| f.is_current()).collect();
468
469 if current_facts.is_empty() && self.patterns.is_empty() {
470 return String::new();
471 }
472
473 let mut out = String::new();
474
475 let mut rooms: Vec<(String, usize)> = self.list_rooms();
476 rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
477 rooms.truncate(crate::core::budgets::KNOWLEDGE_AAAK_ROOMS_LIMIT);
478
479 for (cat, _count) in rooms {
480 let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
481 .iter()
482 .copied()
483 .filter(|f| f.category == cat)
484 .collect();
485 facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
486 facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_AAAK_FACTS_PER_ROOM_LIMIT);
487
488 let items: Vec<String> = facts_in_cat
489 .iter()
490 .map(|f| {
491 let stars = confidence_stars(f.confidence);
492 let key = crate::core::sanitize::neutralize_metadata(&f.key);
493 let val = crate::core::sanitize::neutralize_metadata(&f.value);
494 format!("{key}={val}{stars}")
495 })
496 .collect();
497 out.push_str(&format!(
498 "{}:{}\n",
499 crate::core::sanitize::neutralize_metadata(&cat.to_uppercase()),
500 items.join("|")
501 ));
502 }
503
504 if !self.patterns.is_empty() {
505 let mut patterns = self.patterns.clone();
506 patterns.sort_by(|a, b| {
507 b.created_at
508 .cmp(&a.created_at)
509 .then_with(|| a.pattern_type.cmp(&b.pattern_type))
510 .then_with(|| a.description.cmp(&b.description))
511 });
512 patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
513 let pat_items: Vec<String> = patterns
514 .iter()
515 .map(|p| {
516 let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
517 let desc = crate::core::sanitize::neutralize_metadata(&p.description);
518 format!("{ty}.{desc}")
519 })
520 .collect();
521 out.push_str(&format!("PAT:{}\n", pat_items.join("|")));
522 }
523
524 if out.is_empty() {
525 out
526 } else {
527 crate::core::sanitize::fence_content("project_memory_aaak", out.trim_end())
528 }
529 }
530
531 pub fn format_wakeup(&self) -> String {
532 let current_facts: Vec<&KnowledgeFact> = self
533 .facts
534 .iter()
535 .filter(|f| f.is_current() && f.confidence >= 0.7)
536 .collect();
537
538 if current_facts.is_empty() {
539 return String::new();
540 }
541
542 let mut top_facts: Vec<&KnowledgeFact> = current_facts;
543 top_facts.sort_by(|a, b| sort_fact_for_output(a, b));
544 top_facts.truncate(10);
545
546 let items: Vec<String> = top_facts
547 .iter()
548 .map(|f| {
549 let cat = crate::core::sanitize::neutralize_metadata(&f.category);
550 let key = crate::core::sanitize::neutralize_metadata(&f.key);
551 let val = crate::core::sanitize::neutralize_metadata(&f.value);
552 format!("{cat}/{key}={val}")
553 })
554 .collect();
555
556 crate::core::sanitize::fence_content(
557 "project_facts_wakeup",
558 &format!("FACTS:{}", items.join("|")),
559 )
560 }
561
562 pub fn save(&self) -> Result<(), String> {
563 let dir = knowledge_dir(&self.project_hash)?;
564 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
565
566 let path = dir.join("knowledge.json");
567 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
568 std::fs::write(&path, json).map_err(|e| e.to_string())
569 }
570
571 pub fn load(project_root: &str) -> Option<Self> {
572 let hash = hash_project_root(project_root);
573 let dir = knowledge_dir(&hash).ok()?;
574 let path = dir.join("knowledge.json");
575
576 if let Ok(content) = std::fs::read_to_string(&path) {
577 if let Ok(k) = serde_json::from_str::<Self>(&content) {
578 return Some(k);
579 }
580 }
581
582 let old_hash = crate::core::project_hash::hash_path_only(project_root);
583 if old_hash != hash {
584 crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
585 if let Ok(content) = std::fs::read_to_string(&path) {
586 if let Ok(mut k) = serde_json::from_str::<Self>(&content) {
587 k.project_hash = hash;
588 let _ = k.save();
589 return Some(k);
590 }
591 }
592 }
593
594 None
595 }
596
597 pub fn load_or_create(project_root: &str) -> Self {
598 Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
599 }
600
601 pub fn migrate_legacy_empty_root(target_root: &str) -> Result<bool, String> {
604 if target_root.trim().is_empty() {
605 return Ok(false);
606 }
607
608 let legacy = match Self::load("") {
609 Some(k) => k,
610 None => return Ok(false),
611 };
612
613 if !legacy.project_root.trim().is_empty() {
614 return Ok(false);
615 }
616 if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
617 return Ok(false);
618 }
619
620 let mut target = Self::load_or_create(target_root);
621
622 fn fact_key(f: &KnowledgeFact) -> String {
623 format!(
624 "{}|{}|{}|{}|{}",
625 f.category, f.key, f.value, f.source_session, f.created_at
626 )
627 }
628 fn pattern_key(p: &ProjectPattern) -> String {
629 format!(
630 "{}|{}|{}|{}",
631 p.pattern_type, p.description, p.source_session, p.created_at
632 )
633 }
634 fn history_key(h: &ConsolidatedInsight) -> String {
635 format!(
636 "{}|{}|{}",
637 h.summary,
638 h.from_sessions.join(","),
639 h.timestamp
640 )
641 }
642
643 let mut seen_facts: std::collections::HashSet<String> =
644 target.facts.iter().map(fact_key).collect();
645 for f in legacy.facts {
646 if seen_facts.insert(fact_key(&f)) {
647 target.facts.push(f);
648 }
649 }
650
651 let mut seen_patterns: std::collections::HashSet<String> =
652 target.patterns.iter().map(pattern_key).collect();
653 for p in legacy.patterns {
654 if seen_patterns.insert(pattern_key(&p)) {
655 target.patterns.push(p);
656 }
657 }
658
659 let mut seen_history: std::collections::HashSet<String> =
660 target.history.iter().map(history_key).collect();
661 for h in legacy.history {
662 if seen_history.insert(history_key(&h)) {
663 target.history.push(h);
664 }
665 }
666
667 target.facts.sort_by(|a, b| {
669 b.created_at
670 .cmp(&a.created_at)
671 .then_with(|| b.confidence.total_cmp(&a.confidence))
672 });
673 if target.facts.len() > MAX_FACTS {
674 target.facts.truncate(MAX_FACTS);
675 }
676 target
677 .patterns
678 .sort_by_key(|x| std::cmp::Reverse(x.created_at));
679 if target.patterns.len() > MAX_PATTERNS {
680 target.patterns.truncate(MAX_PATTERNS);
681 }
682 target
683 .history
684 .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
685 if target.history.len() > MAX_HISTORY {
686 target.history.truncate(MAX_HISTORY);
687 }
688
689 target.updated_at = Utc::now();
690 target.save()?;
691
692 let legacy_hash = crate::core::project_hash::hash_path_only("");
693 let legacy_dir = knowledge_dir(&legacy_hash)?;
694 let legacy_path = legacy_dir.join("knowledge.json");
695 if legacy_path.exists() {
696 let ts = Utc::now().format("%Y%m%d-%H%M%S");
697 let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
698 std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
699 }
700
701 Ok(true)
702 }
703
704 pub fn recall_for_output(&mut self, query: &str, limit: usize) -> (Vec<KnowledgeFact>, usize) {
705 let q = query.to_lowercase();
706 let terms: Vec<&str> = q.split_whitespace().filter(|t| !t.is_empty()).collect();
707 if terms.is_empty() {
708 return (Vec::new(), 0);
709 }
710
711 struct Scored {
712 idx: usize,
713 relevance: f32,
714 }
715
716 let mut scored: Vec<Scored> = self
717 .facts
718 .iter()
719 .enumerate()
720 .filter(|(_, f)| f.is_current())
721 .filter_map(|(idx, f)| {
722 let searchable = format!(
723 "{} {} {} {}",
724 f.category.to_lowercase(),
725 f.key.to_lowercase(),
726 f.value.to_lowercase(),
727 f.source_session
728 );
729 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
730 if match_count > 0 {
731 let relevance = (match_count as f32 / terms.len() as f32) * f.confidence;
732 Some(Scored { idx, relevance })
733 } else {
734 None
735 }
736 })
737 .collect();
738
739 scored.sort_by(|a, b| {
740 b.relevance
741 .partial_cmp(&a.relevance)
742 .unwrap_or(std::cmp::Ordering::Equal)
743 .then_with(|| sort_fact_for_output(&self.facts[a.idx], &self.facts[b.idx]))
744 });
745
746 let total = scored.len();
747 scored.truncate(limit);
748
749 let now = Utc::now();
750 let mut out: Vec<KnowledgeFact> = Vec::new();
751 for s in scored {
752 if let Some(f) = self.facts.get_mut(s.idx) {
753 f.retrieval_count = f.retrieval_count.saturating_add(1);
754 f.last_retrieved = Some(now);
755 out.push(f.clone());
756 }
757 }
758
759 (out, total)
760 }
761
762 pub fn recall_by_category_for_output(
763 &mut self,
764 category: &str,
765 limit: usize,
766 ) -> (Vec<KnowledgeFact>, usize) {
767 let mut idxs: Vec<usize> = self
768 .facts
769 .iter()
770 .enumerate()
771 .filter(|(_, f)| f.is_current() && f.category == category)
772 .map(|(i, _)| i)
773 .collect();
774
775 idxs.sort_by(|a, b| sort_fact_for_output(&self.facts[*a], &self.facts[*b]));
776
777 let total = idxs.len();
778 idxs.truncate(limit);
779
780 let now = Utc::now();
781 let mut out = Vec::new();
782 for idx in idxs {
783 if let Some(f) = self.facts.get_mut(idx) {
784 f.retrieval_count = f.retrieval_count.saturating_add(1);
785 f.last_retrieved = Some(now);
786 out.push(f.clone());
787 }
788 }
789
790 (out, total)
791 }
792}
793
794impl KnowledgeFact {
795 pub fn is_current(&self) -> bool {
796 self.valid_until.is_none()
797 }
798
799 pub fn was_valid_at(&self, at: DateTime<Utc>) -> bool {
800 let after_start = self.valid_from.is_none_or(|from| at >= from);
801 let before_end = self.valid_until.is_none_or(|until| at <= until);
802 after_start && before_end
803 }
804}
805
806fn confidence_stars(confidence: f32) -> &'static str {
807 if confidence >= 0.95 {
808 "★★★★★"
809 } else if confidence >= 0.85 {
810 "★★★★"
811 } else if confidence >= 0.7 {
812 "★★★"
813 } else if confidence >= 0.5 {
814 "★★"
815 } else {
816 "★"
817 }
818}
819
820fn string_similarity(a: &str, b: &str) -> f32 {
821 let a_lower = a.to_lowercase();
822 let b_lower = b.to_lowercase();
823 let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
824 let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
825
826 if a_words.is_empty() && b_words.is_empty() {
827 return 1.0;
828 }
829
830 let intersection = a_words.intersection(&b_words).count();
831 let union = a_words.union(&b_words).count();
832
833 if union == 0 {
834 return 0.0;
835 }
836
837 intersection as f32 / union as f32
838}
839
840fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
841 Ok(crate::core::data_dir::lean_ctx_data_dir()?
842 .join("knowledge")
843 .join(project_hash))
844}
845
846fn sort_fact_for_output(a: &KnowledgeFact, b: &KnowledgeFact) -> std::cmp::Ordering {
847 salience_score(b)
848 .cmp(&salience_score(a))
849 .then_with(|| {
850 b.confidence
851 .partial_cmp(&a.confidence)
852 .unwrap_or(std::cmp::Ordering::Equal)
853 })
854 .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
855 .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
856 .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
857 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
858 .then_with(|| a.category.cmp(&b.category))
859 .then_with(|| a.key.cmp(&b.key))
860 .then_with(|| a.value.cmp(&b.value))
861}
862
863fn salience_score(f: &KnowledgeFact) -> u32 {
864 let cat = f.category.to_lowercase();
865 let base: u32 = match cat.as_str() {
866 "decision" => 70,
867 "gotcha" => 75,
868 "architecture" | "arch" => 60,
869 "security" => 65,
870 "testing" | "tests" => 55,
871 "deployment" | "deploy" => 55,
872 "conventions" | "convention" => 45,
873 "finding" => 40,
874 _ => 30,
875 };
876
877 let confidence_bonus = (f.confidence.clamp(0.0, 1.0) * 30.0) as u32;
878 let confirmation_bonus = f.confirmation_count.min(15);
879 let retrieval_bonus = ((f.retrieval_count as f32).ln_1p() * 8.0).min(20.0) as u32;
880
881 let recency_bonus = f
882 .last_retrieved
883 .map(|t| {
884 let days = Utc::now().signed_duration_since(t).num_days();
885 if days <= 7 {
886 10u32
887 } else if days <= 30 {
888 5u32
889 } else {
890 0u32
891 }
892 })
893 .unwrap_or(0u32);
894
895 base + confidence_bonus + confirmation_bonus + retrieval_bonus + recency_bonus
896}
897
898fn hash_project_root(root: &str) -> String {
899 crate::core::project_hash::hash_project_root(root)
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905
906 #[test]
907 fn remember_and_recall() {
908 let mut k = ProjectKnowledge::new("/tmp/test-project");
909 k.remember("architecture", "auth", "JWT RS256", "session-1", 0.9);
910 k.remember("api", "rate-limit", "100/min", "session-1", 0.8);
911
912 let results = k.recall("auth");
913 assert_eq!(results.len(), 1);
914 assert_eq!(results[0].value, "JWT RS256");
915
916 let results = k.recall("api rate");
917 assert_eq!(results.len(), 1);
918 assert_eq!(results[0].key, "rate-limit");
919 }
920
921 #[test]
922 fn upsert_existing_fact() {
923 let mut k = ProjectKnowledge::new("/tmp/test");
924 k.remember("arch", "db", "PostgreSQL", "s1", 0.7);
925 k.remember("arch", "db", "PostgreSQL 16 with pgvector", "s2", 0.95);
926
927 let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
928 assert_eq!(current.len(), 1);
929 assert_eq!(current[0].value, "PostgreSQL 16 with pgvector");
930 }
931
932 #[test]
933 fn contradiction_detection() {
934 let mut k = ProjectKnowledge::new("/tmp/test");
935 k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
936 k.facts[0].confirmation_count = 3;
937
938 let contradiction = k.check_contradiction("arch", "db", "MySQL");
939 assert!(contradiction.is_some());
940 let c = contradiction.unwrap();
941 assert_eq!(c.severity, ContradictionSeverity::High);
942 }
943
944 #[test]
945 fn temporal_validity() {
946 let mut k = ProjectKnowledge::new("/tmp/test");
947 k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
948 k.facts[0].confirmation_count = 3;
949
950 k.remember("arch", "db", "MySQL", "s2", 0.9);
951
952 let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
953 assert_eq!(current.len(), 1);
954 assert_eq!(current[0].value, "MySQL");
955
956 let all_db: Vec<_> = k.facts.iter().filter(|f| f.key == "db").collect();
957 assert_eq!(all_db.len(), 2);
958 }
959
960 #[test]
961 fn confirmation_count() {
962 let mut k = ProjectKnowledge::new("/tmp/test");
963 k.remember("arch", "db", "PostgreSQL", "s1", 0.9);
964 assert_eq!(k.facts[0].confirmation_count, 1);
965
966 k.remember("arch", "db", "PostgreSQL", "s2", 0.9);
967 assert_eq!(k.facts[0].confirmation_count, 2);
968 }
969
970 #[test]
971 fn remove_fact() {
972 let mut k = ProjectKnowledge::new("/tmp/test");
973 k.remember("arch", "db", "PostgreSQL", "s1", 0.9);
974 assert!(k.remove_fact("arch", "db"));
975 assert!(k.facts.is_empty());
976 assert!(!k.remove_fact("arch", "db"));
977 }
978
979 #[test]
980 fn list_rooms() {
981 let mut k = ProjectKnowledge::new("/tmp/test");
982 k.remember("architecture", "auth", "JWT", "s1", 0.9);
983 k.remember("architecture", "db", "PG", "s1", 0.9);
984 k.remember("deploy", "host", "AWS", "s1", 0.8);
985
986 let rooms = k.list_rooms();
987 assert_eq!(rooms.len(), 2);
988 }
989
990 #[test]
991 fn aaak_format() {
992 let mut k = ProjectKnowledge::new("/tmp/test");
993 k.remember("architecture", "auth", "JWT RS256", "s1", 0.95);
994 k.remember("architecture", "db", "PostgreSQL", "s1", 0.7);
995
996 let aaak = k.format_aaak();
997 assert!(aaak.contains("ARCHITECTURE:"));
998 assert!(aaak.contains("auth=JWT RS256"));
999 }
1000
1001 #[test]
1002 fn consolidate_history() {
1003 let mut k = ProjectKnowledge::new("/tmp/test");
1004 k.consolidate(
1005 "Migrated from REST to GraphQL",
1006 vec!["s1".into(), "s2".into()],
1007 );
1008 assert_eq!(k.history.len(), 1);
1009 assert_eq!(k.history[0].from_sessions.len(), 2);
1010 }
1011
1012 #[test]
1013 fn format_summary_output() {
1014 let mut k = ProjectKnowledge::new("/tmp/test");
1015 k.remember("architecture", "auth", "JWT RS256", "s1", 0.9);
1016 k.add_pattern(
1017 "naming",
1018 "snake_case for functions",
1019 vec!["get_user()".into()],
1020 "s1",
1021 );
1022 let summary = k.format_summary();
1023 assert!(summary.contains("PROJECT KNOWLEDGE:"));
1024 assert!(summary.contains("auth: JWT RS256"));
1025 assert!(summary.contains("PROJECT PATTERNS:"));
1026 }
1027
1028 #[test]
1029 fn temporal_recall_at_time() {
1030 let mut k = ProjectKnowledge::new("/tmp/test");
1031 k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
1032 k.facts[0].confirmation_count = 3;
1033
1034 let before_change = Utc::now();
1035 std::thread::sleep(std::time::Duration::from_millis(10));
1036
1037 k.remember("arch", "db", "MySQL", "s2", 0.9);
1038
1039 let results = k.recall_at_time("db", before_change);
1040 assert_eq!(results.len(), 1);
1041 assert_eq!(results[0].value, "PostgreSQL");
1042
1043 let results_now = k.recall_at_time("db", Utc::now());
1044 assert_eq!(results_now.len(), 1);
1045 assert_eq!(results_now[0].value, "MySQL");
1046 }
1047
1048 #[test]
1049 fn timeline_shows_history() {
1050 let mut k = ProjectKnowledge::new("/tmp/test");
1051 k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
1052 k.facts[0].confirmation_count = 3;
1053 k.remember("arch", "db", "MySQL", "s2", 0.9);
1054
1055 let timeline = k.timeline("arch");
1056 assert_eq!(timeline.len(), 2);
1057 assert!(!timeline[0].is_current());
1058 assert!(timeline[1].is_current());
1059 }
1060
1061 #[test]
1062 fn wakeup_format() {
1063 let mut k = ProjectKnowledge::new("/tmp/test");
1064 k.remember("arch", "auth", "JWT", "s1", 0.95);
1065 k.remember("arch", "db", "PG", "s1", 0.8);
1066
1067 let wakeup = k.format_wakeup();
1068 assert!(wakeup.contains("FACTS:"));
1069 assert!(wakeup.contains("arch/auth=JWT"));
1070 assert!(wakeup.contains("arch/db=PG"));
1071 }
1072
1073 #[test]
1074 fn salience_prioritizes_decisions_over_findings_at_similar_confidence() {
1075 let mut k = ProjectKnowledge::new("/tmp/test");
1076 k.remember("finding", "f1", "some thing", "s1", 0.9);
1077 k.remember("decision", "d1", "important", "s1", 0.85);
1078
1079 let wakeup = k.format_wakeup();
1080 let items = wakeup
1081 .strip_prefix("FACTS:")
1082 .unwrap_or(&wakeup)
1083 .split('|')
1084 .collect::<Vec<_>>();
1085 assert!(
1086 items
1087 .first()
1088 .is_some_and(|s| s.contains("decision/d1=important")),
1089 "expected decision first in wakeup: {wakeup}"
1090 );
1091 }
1092
1093 #[test]
1094 fn low_confidence_contradiction() {
1095 let mut k = ProjectKnowledge::new("/tmp/test");
1096 k.remember("arch", "db", "PostgreSQL", "s1", 0.4);
1097
1098 let c = k.check_contradiction("arch", "db", "MySQL");
1099 assert!(c.is_some());
1100 assert_eq!(c.unwrap().severity, ContradictionSeverity::Low);
1101 }
1102
1103 #[test]
1104 fn no_contradiction_for_same_value() {
1105 let mut k = ProjectKnowledge::new("/tmp/test");
1106 k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
1107
1108 let c = k.check_contradiction("arch", "db", "PostgreSQL");
1109 assert!(c.is_none());
1110 }
1111
1112 #[test]
1113 fn no_contradiction_for_similar_values() {
1114 let mut k = ProjectKnowledge::new("/tmp/test");
1115 k.remember(
1116 "arch",
1117 "db",
1118 "PostgreSQL 16 production database server",
1119 "s1",
1120 0.95,
1121 );
1122
1123 let c = k.check_contradiction(
1124 "arch",
1125 "db",
1126 "PostgreSQL 16 production database server config",
1127 );
1128 assert!(c.is_none());
1129 }
1130}