1use chrono::Utc;
2
3#[cfg(feature = "embeddings")]
4use crate::core::embeddings::EmbeddingEngine;
5
6use crate::core::knowledge::ProjectKnowledge;
7use crate::core::memory_policy::MemoryPolicy;
8use crate::core::session::SessionState;
9
10fn load_policy_or_error() -> Result<MemoryPolicy, String> {
11 super::knowledge_shared::load_policy_or_error()
12}
13
14#[allow(clippy::too_many_arguments)]
16pub fn handle(
17 project_root: &str,
18 action: &str,
19 category: Option<&str>,
20 key: Option<&str>,
21 value: Option<&str>,
22 query: Option<&str>,
23 session_id: &str,
24 pattern_type: Option<&str>,
25 examples: Option<Vec<String>>,
26 confidence: Option<f32>,
27 mode: Option<&str>,
28) -> String {
29 match action {
30 "policy" => handle_policy(value),
31 "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
32 "recall" => handle_recall(project_root, category, query, session_id, mode),
33 "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
34 "feedback" => handle_feedback(project_root, category, key, value, session_id),
35 "relate" => crate::tools::ctx_knowledge_relations::handle_relate(
36 project_root,
37 category,
38 key,
39 value,
40 query,
41 session_id,
42 ),
43 "unrelate" => crate::tools::ctx_knowledge_relations::handle_unrelate(
44 project_root,
45 category,
46 key,
47 value,
48 query,
49 ),
50 "relations" => crate::tools::ctx_knowledge_relations::handle_relations(
51 project_root,
52 category,
53 key,
54 value,
55 query,
56 ),
57 "relations_diagram" => crate::tools::ctx_knowledge_relations::handle_relations_diagram(
58 project_root,
59 category,
60 key,
61 value,
62 query,
63 ),
64 "status" => handle_status(project_root),
65 "health" => handle_health(project_root),
66 "remove" => handle_remove(project_root, category, key),
67 "export" => handle_export(project_root),
68 "consolidate" => handle_consolidate(project_root),
69 "timeline" => handle_timeline(project_root, category),
70 "rooms" => handle_rooms(project_root),
71 "search" => handle_search(query),
72 "wakeup" => handle_wakeup(project_root),
73 "embeddings_status" => handle_embeddings_status(project_root),
74 "embeddings_reset" => handle_embeddings_reset(project_root),
75 "embeddings_reindex" => handle_embeddings_reindex(project_root),
76 "cognition_loop" => handle_cognition_loop(project_root),
77 "bridge_publish" => handle_bridge_publish(project_root, session_id),
78 "bridge_pull" => handle_bridge_pull(project_root, session_id),
79 "bridge_status" => handle_bridge_status(project_root),
80 _ => format!(
81 "Unknown action: {action}. Use: policy, remember, recall, pattern, feedback, relate, unrelate, relations, relations_diagram, status, health, remove, export, consolidate, timeline, rooms, search, wakeup, embeddings_status, embeddings_reset, embeddings_reindex, cognition_loop, bridge_publish, bridge_pull, bridge_status"
82 ),
83 }
84}
85
86fn handle_policy(value: Option<&str>) -> String {
87 let sub = value.unwrap_or("show").trim().to_lowercase();
88 let profile = crate::core::profiles::active_profile_name();
89
90 match sub.as_str() {
91 "show" => {
92 let policy = match load_policy_or_error() {
93 Ok(p) => p,
94 Err(e) => return e,
95 };
96
97 let cfg_path = crate::core::config::Config::path().map_or_else(
98 || "~/.lean-ctx/config.toml".to_string(),
99 |p| p.display().to_string(),
100 );
101
102 format!(
103 "Knowledge policy (effective, profile={profile}):\n\
104 - memory.knowledge.max_facts={}\n\
105 - memory.knowledge.contradiction_threshold={}\n\
106 - memory.knowledge.recall_facts_limit={}\n\
107 - memory.knowledge.rooms_limit={}\n\
108 - memory.knowledge.timeline_limit={}\n\
109 - memory.knowledge.relations_limit={}\n\
110 - memory.lifecycle.decay_rate={}\n\
111 - memory.lifecycle.stale_days={}\n\
112 \nConfig: {cfg_path}",
113 policy.knowledge.max_facts,
114 policy.knowledge.contradiction_threshold,
115 policy.knowledge.recall_facts_limit,
116 policy.knowledge.rooms_limit,
117 policy.knowledge.timeline_limit,
118 policy.knowledge.relations_limit,
119 policy.lifecycle.decay_rate,
120 policy.lifecycle.stale_days
121 )
122 }
123 "validate" => match load_policy_or_error() {
124 Ok(_) => format!("OK: memory policy valid (profile={profile})"),
125 Err(e) => e,
126 },
127 _ => "Error: policy value must be show|validate".to_string(),
128 }
129}
130
131fn handle_feedback(
132 project_root: &str,
133 category: Option<&str>,
134 key: Option<&str>,
135 value: Option<&str>,
136 session_id: &str,
137) -> String {
138 let Some(cat) = category else {
139 return "Error: category is required for feedback".to_string();
140 };
141 let Some(k) = key else {
142 return "Error: key is required for feedback".to_string();
143 };
144 let dir = value.unwrap_or("up").trim().to_lowercase();
145 let is_up = matches!(dir.as_str(), "up" | "+1" | "+" | "true" | "1");
146 let is_down = matches!(dir.as_str(), "down" | "-1" | "-" | "false" | "0");
147 if !is_up && !is_down {
148 return "Error: feedback value must be up|down (+1|-1)".to_string();
149 }
150
151 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
152 let Some(f) = knowledge
153 .facts
154 .iter_mut()
155 .find(|f| f.is_current() && f.category == cat && f.key == k)
156 else {
157 return format!("No current fact found: [{cat}] {k}");
158 };
159
160 if is_up {
161 f.feedback_up = f.feedback_up.saturating_add(1);
162 } else {
163 f.feedback_down = f.feedback_down.saturating_add(1);
164 }
165 f.last_feedback = Some(Utc::now());
166
167 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
168 category: cat.to_string(),
169 key: k.to_string(),
170 action: if is_up {
171 "feedback_up"
172 } else {
173 "feedback_down"
174 }
175 .to_string(),
176 });
177
178 let quality = f.quality_score();
179 let up = f.feedback_up;
180 let down = f.feedback_down;
181 let conf = f.confidence;
182
183 match knowledge.save() {
184 Ok(()) => format!(
185 "Feedback recorded ({dir}) for [{cat}] {k} (up={up}, down={down}, quality={quality:.2}, confidence={conf:.2}, session={session_id})"
186 ),
187 Err(e) => format!(
188 "Feedback recorded ({dir}) but save failed: {e} (up={up}, down={down}, quality={quality:.2})"
189 ),
190 }
191}
192
193#[cfg(feature = "embeddings")]
194fn embeddings_auto_download_allowed() -> bool {
195 std::env::var("LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD")
196 .ok()
197 .is_some_and(|v| {
198 matches!(
199 v.trim().to_lowercase().as_str(),
200 "1" | "true" | "yes" | "on"
201 )
202 })
203}
204
205#[cfg(feature = "embeddings")]
206fn embedding_engine() -> Option<&'static EmbeddingEngine> {
207 let cfg = crate::core::config::Config::load();
208 let profile = crate::core::config::MemoryProfile::effective(&cfg);
209 if !profile.embeddings_enabled() {
210 return None;
211 }
212 if !EmbeddingEngine::is_available() && !embeddings_auto_download_allowed() {
213 return None;
214 }
215 crate::core::embeddings::shared_engine()
216}
217
218fn handle_embeddings_status(project_root: &str) -> String {
219 #[cfg(feature = "embeddings")]
220 {
221 let knowledge = ProjectKnowledge::load_or_create(project_root);
222 let model_available = EmbeddingEngine::is_available();
223 let auto = embeddings_auto_download_allowed();
224
225 let entries = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
226 &knowledge.project_hash,
227 )
228 .map_or(0, |i| i.entries.len());
229
230 let path = crate::core::data_dir::lean_ctx_data_dir()
231 .ok()
232 .map(|d| {
233 d.join("knowledge")
234 .join(&knowledge.project_hash)
235 .join("embeddings.json")
236 })
237 .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
238
239 format!(
240 "Knowledge embeddings: model={}, auto_download={}, index_entries={}, path={path}",
241 if model_available {
242 "present"
243 } else {
244 "missing"
245 },
246 if auto { "on" } else { "off" },
247 entries
248 )
249 }
250 #[cfg(not(feature = "embeddings"))]
251 {
252 let _ = project_root;
253 "ERR: embeddings feature not enabled".to_string()
254 }
255}
256
257fn handle_embeddings_reset(project_root: &str) -> String {
258 #[cfg(feature = "embeddings")]
259 {
260 let knowledge = ProjectKnowledge::load_or_create(project_root);
261 match crate::core::knowledge_embedding::reset(&knowledge.project_hash) {
262 Ok(()) => "Embeddings index reset.".to_string(),
263 Err(e) => format!("Embeddings reset failed: {e}"),
264 }
265 }
266 #[cfg(not(feature = "embeddings"))]
267 {
268 let _ = project_root;
269 "ERR: embeddings feature not enabled".to_string()
270 }
271}
272
273fn handle_embeddings_reindex(project_root: &str) -> String {
274 #[cfg(feature = "embeddings")]
275 {
276 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
277 return "No knowledge stored for this project yet.".to_string();
278 };
279 let policy = match load_policy_or_error() {
280 Ok(p) => p,
281 Err(e) => return e,
282 };
283
284 let Some(engine) = embedding_engine() else {
285 return "Embeddings model not available. Set LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD=1 to allow auto-download, then re-run."
286 .to_string();
287 };
288
289 let mut idx =
290 crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
291
292 let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> =
293 knowledge.facts.iter().filter(|f| f.is_current()).collect();
294 facts.sort_by(|a, b| {
295 b.confidence
296 .partial_cmp(&a.confidence)
297 .unwrap_or(std::cmp::Ordering::Equal)
298 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
299 .then_with(|| a.category.cmp(&b.category))
300 .then_with(|| a.key.cmp(&b.key))
301 });
302
303 let max = policy.embeddings.max_facts;
304 let mut embedded = 0usize;
305 for f in facts.into_iter().take(max) {
306 if crate::core::knowledge_embedding::embed_and_store(
307 &mut idx,
308 engine,
309 &f.category,
310 &f.key,
311 &f.value,
312 )
313 .is_ok()
314 {
315 embedded += 1;
316 }
317 }
318
319 crate::core::knowledge_embedding::compact_against_knowledge(&mut idx, &knowledge, &policy);
320 match idx.save() {
321 Ok(()) => format!("Embeddings reindex ok (embedded {embedded} facts)."),
322 Err(e) => format!("Embeddings reindex failed: {e}"),
323 }
324 }
325 #[cfg(not(feature = "embeddings"))]
326 {
327 let _ = project_root;
328 "ERR: embeddings feature not enabled".to_string()
329 }
330}
331
332fn handle_remember(
333 project_root: &str,
334 category: Option<&str>,
335 key: Option<&str>,
336 value: Option<&str>,
337 session_id: &str,
338 confidence: Option<f32>,
339) -> String {
340 let Some(cat) = category else {
341 return "Error: category is required for remember".to_string();
342 };
343 let Some(k) = key else {
344 return "Error: key is required for remember".to_string();
345 };
346 let Some(v) = value else {
347 return "Error: value is required for remember".to_string();
348 };
349 let conf = confidence.unwrap_or(0.8);
350 let policy = match load_policy_or_error() {
351 Ok(p) => p,
352 Err(e) => return e,
353 };
354 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
355 let contradiction = knowledge.remember(cat, k, v, session_id, conf, &policy);
356 let _ = knowledge.run_memory_lifecycle(&policy);
357
358 let mut result = format!(
359 "Remembered [{cat}] {k}: {v} (confidence: {:.0}%)",
360 conf * 100.0
361 );
362
363 if let Some(c) = contradiction {
364 result.push_str(&format!("\n⚠ CONTRADICTION DETECTED: {}", c.resolution));
365 }
366
367 #[cfg(feature = "embeddings")]
368 {
369 if let Some(engine) = embedding_engine() {
370 let mut idx = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
371 &knowledge.project_hash,
372 )
373 .unwrap_or_else(|| {
374 crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::new(
375 &knowledge.project_hash,
376 )
377 });
378
379 match crate::core::knowledge_embedding::embed_and_store(&mut idx, engine, cat, k, v) {
380 Ok(()) => {
381 crate::core::knowledge_embedding::compact_against_knowledge(
382 &mut idx, &knowledge, &policy,
383 );
384 if let Err(e) = idx.save() {
385 result.push_str(&format!("\n(warn: embeddings save failed: {e})"));
386 }
387 }
388 Err(e) => {
389 result.push_str(&format!("\n(warn: embeddings update failed: {e})"));
390 }
391 }
392 }
393 }
394
395 match knowledge.save() {
396 Ok(()) => result,
397 Err(e) => format!("{result}\n(save failed: {e})"),
398 }
399}
400
401fn handle_recall(
402 project_root: &str,
403 category: Option<&str>,
404 query: Option<&str>,
405 session_id: &str,
406 mode: Option<&str>,
407) -> String {
408 let Some(mut knowledge) = ProjectKnowledge::load(project_root) else {
409 return "No knowledge stored for this project yet.".to_string();
410 };
411 let policy = match load_policy_or_error() {
412 Ok(p) => p,
413 Err(e) => return e,
414 };
415
416 if let Some(cat) = category {
417 let limit = policy.knowledge.recall_facts_limit;
418 let (facts, total) = knowledge.recall_by_category_for_output(cat, limit);
419 if facts.is_empty() || total == 0 {
420 let rehydrated =
422 rehydrate_from_archives(&mut knowledge, Some(cat), None, session_id, &policy);
423 if rehydrated {
424 let (facts2, total2) = knowledge.recall_by_category_for_output(cat, limit);
425 if !facts2.is_empty() && total2 > 0 {
426 let mut out2 = format_facts(&facts2, total2, Some(cat));
427 if let Err(e) = knowledge.save() {
428 out2.push_str(&format!(
429 "\n(warn: failed to persist retrieval signals: {e})"
430 ));
431 }
432 return out2;
433 }
434 }
435 return format!("No facts in category '{cat}'.");
436 }
437 let mut out = format_facts(&facts, total, Some(cat));
438 if let Err(e) = knowledge.save() {
439 out.push_str(&format!(
440 "\n(warn: failed to persist retrieval signals: {e})"
441 ));
442 }
443 return out;
444 }
445
446 if let Some(q) = query {
447 let mode = mode.unwrap_or("auto").trim().to_lowercase();
448 #[cfg(feature = "embeddings")]
449 {
450 if let Some(engine) = embedding_engine() {
451 if let Some(idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
452 &knowledge.project_hash,
453 ) {
454 let limit = policy.knowledge.recall_facts_limit;
455 if mode == "semantic" {
456 let scored =
457 crate::core::knowledge_embedding::semantic_recall_semantic_only(
458 &knowledge, &idx, engine, q, limit,
459 );
460 if scored.is_empty() {
461 return format!("No semantic facts matching '{q}'.");
462 }
463 let hits: Vec<SemanticHit> = scored
464 .iter()
465 .map(|s| SemanticHit {
466 category: s.fact.category.clone(),
467 key: s.fact.key.clone(),
468 value: s.fact.value.clone(),
469 score: s.score,
470 semantic_score: s.semantic_score,
471 confidence_score: s.confidence_score,
472 })
473 .collect();
474 apply_retrieval_signals_from_hits(&mut knowledge, &hits);
475 let mut out = format_semantic_facts(&format!("{q} (mode=semantic)"), &hits);
476 if let Err(e) = knowledge.save() {
477 out.push_str(&format!(
478 "\n(warn: failed to persist retrieval signals: {e})"
479 ));
480 }
481 return out;
482 }
483
484 if mode == "hybrid" || mode == "auto" {
485 let scored = crate::core::knowledge_embedding::semantic_recall(
486 &knowledge, &idx, engine, q, limit,
487 );
488 if !scored.is_empty() {
489 let hits: Vec<SemanticHit> = scored
490 .iter()
491 .map(|s| SemanticHit {
492 category: s.fact.category.clone(),
493 key: s.fact.key.clone(),
494 value: s.fact.value.clone(),
495 score: s.score,
496 semantic_score: s.semantic_score,
497 confidence_score: s.confidence_score,
498 })
499 .collect();
500 apply_retrieval_signals_from_hits(&mut knowledge, &hits);
501 let mut out =
502 format_semantic_facts(&format!("{q} (mode=hybrid)"), &hits);
503 if let Err(e) = knowledge.save() {
504 out.push_str(&format!(
505 "\n(warn: failed to persist retrieval signals: {e})"
506 ));
507 }
508 return out;
509 }
510 }
511 }
512 }
513 }
514
515 if mode == "semantic" {
516 return "Semantic recall requires embeddings. Run ctx_knowledge(action=\"embeddings_reindex\") and ensure embeddings are enabled.".to_string();
517 }
518
519 let limit = policy.knowledge.recall_facts_limit;
520 let (facts, total) = knowledge.recall_for_output(q, limit);
521 if facts.is_empty() || total == 0 {
522 let rehydrated =
524 rehydrate_from_archives(&mut knowledge, None, Some(q), session_id, &policy);
525 if rehydrated {
526 let (facts2, total2) = knowledge.recall_for_output(q, limit);
527 if !facts2.is_empty() && total2 > 0 {
528 let mut out2 = format_facts(&facts2, total2, None);
529 if let Err(e) = knowledge.save() {
530 out2.push_str(&format!(
531 "\n(warn: failed to persist retrieval signals: {e})"
532 ));
533 }
534 return out2;
535 }
536 }
537 return format!("No facts matching '{q}'.");
538 }
539 let mut out = format_facts(&facts, total, None);
540 if let Err(e) = knowledge.save() {
541 out.push_str(&format!(
542 "\n(warn: failed to persist retrieval signals: {e})"
543 ));
544 }
545 return out;
546 }
547
548 "Error: provide query or category for recall".to_string()
549}
550
551fn rehydrate_from_archives(
552 knowledge: &mut ProjectKnowledge,
553 category: Option<&str>,
554 query: Option<&str>,
555 session_id: &str,
556 policy: &MemoryPolicy,
557) -> bool {
558 let mut archives = crate::core::memory_lifecycle::list_archives();
559 if archives.is_empty() {
560 return false;
561 }
562 archives.sort();
563 let max_archives = crate::core::budgets::KNOWLEDGE_REHYDRATE_MAX_ARCHIVES;
564 if archives.len() > max_archives {
565 archives = archives[archives.len() - max_archives..].to_vec();
566 }
567
568 let terms: Vec<String> = query
569 .unwrap_or("")
570 .to_lowercase()
571 .split_whitespace()
572 .filter(|t| !t.is_empty())
573 .map(std::string::ToString::to_string)
574 .collect();
575
576 #[derive(Clone)]
577 struct Cand {
578 category: String,
579 key: String,
580 value: String,
581 confidence: f32,
582 score: f32,
583 }
584
585 let mut cands: Vec<Cand> = Vec::new();
586
587 for p in &archives {
588 let p_str = p.to_string_lossy().to_string();
589 let Ok(facts) = crate::core::memory_lifecycle::restore_archive(&p_str) else {
590 continue;
591 };
592 for f in facts {
593 if let Some(cat) = category {
594 if f.category != cat {
595 continue;
596 }
597 }
598 if terms.is_empty() {
599 cands.push(Cand {
600 category: f.category,
601 key: f.key,
602 value: f.value,
603 confidence: f.confidence,
604 score: f.confidence,
605 });
606 } else {
607 let searchable = format!(
608 "{} {} {} {}",
609 f.category.to_lowercase(),
610 f.key.to_lowercase(),
611 f.value.to_lowercase(),
612 f.source_session.to_lowercase()
613 );
614 let match_count = terms.iter().filter(|t| searchable.contains(*t)).count();
615 if match_count == 0 {
616 continue;
617 }
618 let rel = match_count as f32 / terms.len() as f32;
619 let score = rel * f.confidence;
620 cands.push(Cand {
621 category: f.category,
622 key: f.key,
623 value: f.value,
624 confidence: f.confidence,
625 score,
626 });
627 }
628 }
629 }
630
631 if cands.is_empty() {
632 return false;
633 }
634
635 cands.sort_by(|a, b| {
636 b.score
637 .partial_cmp(&a.score)
638 .unwrap_or(std::cmp::Ordering::Equal)
639 .then_with(|| {
640 b.confidence
641 .partial_cmp(&a.confidence)
642 .unwrap_or(std::cmp::Ordering::Equal)
643 })
644 .then_with(|| a.category.cmp(&b.category))
645 .then_with(|| a.key.cmp(&b.key))
646 .then_with(|| a.value.cmp(&b.value))
647 });
648 cands.truncate(crate::core::budgets::KNOWLEDGE_REHYDRATE_LIMIT);
649
650 let mut any = false;
651 for c in &cands {
652 knowledge.remember(
653 &c.category,
654 &c.key,
655 &c.value,
656 session_id,
657 c.confidence.max(0.6),
658 policy,
659 );
660 any = true;
661 }
662 if any {
663 let _ = knowledge.run_memory_lifecycle(policy);
664 }
665 any
666}
667
668fn handle_pattern(
669 project_root: &str,
670 pattern_type: Option<&str>,
671 value: Option<&str>,
672 examples: Option<Vec<String>>,
673 session_id: &str,
674) -> String {
675 let Some(pt) = pattern_type else {
676 return "Error: pattern_type is required".to_string();
677 };
678 let Some(desc) = value else {
679 return "Error: value (description) is required for pattern".to_string();
680 };
681 let exs = examples.unwrap_or_default();
682 let policy = match crate::core::config::Config::load().memory_policy_effective() {
683 Ok(p) => p,
684 Err(e) => {
685 let path = crate::core::config::Config::path().map_or_else(
686 || "~/.lean-ctx/config.toml".to_string(),
687 |p| p.display().to_string(),
688 );
689 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
690 }
691 };
692 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
693 knowledge.add_pattern(pt, desc, exs, session_id, &policy);
694 match knowledge.save() {
695 Ok(()) => format!("Pattern [{pt}] added: {desc}"),
696 Err(e) => format!("Pattern added but save failed: {e}"),
697 }
698}
699
700fn handle_status(project_root: &str) -> String {
701 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
702 return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
703 };
704
705 let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
706 let archived_facts = knowledge.facts.len() - current_facts;
707
708 let mut out = format!(
709 "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
710 current_facts,
711 archived_facts,
712 knowledge.patterns.len(),
713 knowledge.history.len()
714 );
715 out.push_str(&format!(
716 "Last updated: {}\n",
717 knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
718 ));
719
720 let rooms = knowledge.list_rooms();
721 if !rooms.is_empty() {
722 out.push_str("Rooms: ");
723 let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
724 out.push_str(&room_strs.join(", "));
725 out.push('\n');
726 }
727
728 out.push_str(&knowledge.format_summary());
729 out
730}
731
732fn handle_health(project_root: &str) -> String {
733 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
734 return "No knowledge stored. Nothing to report.".to_string();
735 };
736
737 let total = knowledge.facts.len();
738 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
739 let archived = total - current.len();
740
741 let mut low_quality = 0u32;
742 let mut high_quality = 0u32;
743 let mut stale_candidates = 0u32;
744 let mut total_quality: f32 = 0.0;
745 let mut never_retrieved = 0u32;
746 let mut room_counts: std::collections::HashMap<String, (u32, f32)> =
747 std::collections::HashMap::new();
748
749 let now = chrono::Utc::now();
750 for f in ¤t {
751 let q = f.quality_score();
752 total_quality += q;
753 if q < 0.4 {
754 low_quality += 1;
755 } else if q >= 0.8 {
756 high_quality += 1;
757 }
758 if f.retrieval_count == 0 {
759 never_retrieved += 1;
760 }
761 let age_days = (now - f.created_at).num_days();
762 if age_days > 30 && f.retrieval_count == 0 {
763 stale_candidates += 1;
764 }
765
766 let entry = room_counts.entry(f.category.clone()).or_insert((0, 0.0));
767 entry.0 += 1;
768 entry.1 += q;
769 }
770
771 let avg_quality = if current.is_empty() {
772 0.0
773 } else {
774 total_quality / current.len() as f32
775 };
776
777 let mut out = String::from("=== Knowledge Health Report ===\n");
778 out.push_str(&format!(
779 "Total: {} facts ({} active, {} archived)\n",
780 total,
781 current.len(),
782 archived
783 ));
784 out.push_str(&format!("Avg Quality: {avg_quality:.2}\n"));
785 out.push_str(&format!(
786 "Distribution: {high_quality} high (>=0.8) | {low_quality} low (<0.4)\n"
787 ));
788 out.push_str(&format!(
789 "Stale (>30d, never retrieved): {stale_candidates}\n"
790 ));
791 out.push_str(&format!("Never retrieved: {never_retrieved}\n"));
792
793 if !room_counts.is_empty() {
794 out.push_str("\nRoom Balance:\n");
795 let mut rooms: Vec<_> = room_counts.into_iter().collect();
796 rooms.sort_by_key(|x| std::cmp::Reverse(x.1 .0));
797 for (cat, (count, total_q)) in &rooms {
798 let avg = if *count > 0 {
799 total_q / *count as f32
800 } else {
801 0.0
802 };
803 out.push_str(&format!(" {cat}: {count} facts, avg quality {avg:.2}\n"));
804 }
805 }
806
807 let policy = crate::core::memory_policy::MemoryPolicy::default();
808 out.push_str(&format!(
809 "\nPolicy: max {} facts, max {} patterns\n",
810 policy.knowledge.max_facts, policy.knowledge.max_patterns
811 ));
812
813 if current.len() > policy.knowledge.max_facts {
814 out.push_str(&format!(
815 "WARNING: Active facts ({}) exceed policy max ({})\n",
816 current.len(),
817 policy.knowledge.max_facts
818 ));
819 }
820
821 out
822}
823
824fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
825 let Some(cat) = category else {
826 return "Error: category is required for remove".to_string();
827 };
828 let Some(k) = key else {
829 return "Error: key is required for remove".to_string();
830 };
831 let policy = match crate::core::config::Config::load().memory_policy_effective() {
832 Ok(p) => p,
833 Err(e) => {
834 let path = crate::core::config::Config::path().map_or_else(
835 || "~/.lean-ctx/config.toml".to_string(),
836 |p| p.display().to_string(),
837 );
838 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
839 }
840 };
841 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
842 if knowledge.remove_fact(cat, k) {
843 let _ = knowledge.run_memory_lifecycle(&policy);
844
845 #[cfg(feature = "embeddings")]
846 {
847 if let Some(mut idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
848 &knowledge.project_hash,
849 ) {
850 idx.remove(cat, k);
851 crate::core::knowledge_embedding::compact_against_knowledge(
852 &mut idx, &knowledge, &policy,
853 );
854 let _ = idx.save();
855 }
856 }
857
858 match knowledge.save() {
859 Ok(()) => format!("Removed [{cat}] {k}"),
860 Err(e) => format!("Removed but save failed: {e}"),
861 }
862 } else {
863 format!("No fact found: [{cat}] {k}")
864 }
865}
866
867fn handle_export(project_root: &str) -> String {
868 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
869 return "No knowledge to export.".to_string();
870 };
871 let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
872 Ok(d) => d,
873 Err(e) => return format!("Export failed: {e}"),
874 };
875
876 let export_dir = data_dir.join("exports").join("knowledge");
877 let ts = Utc::now().format("%Y%m%d-%H%M%S");
878 let filename = format!(
879 "knowledge-{}-{ts}.json",
880 short_hash(&knowledge.project_hash)
881 );
882 let path = export_dir.join(filename);
883
884 match serde_json::to_string_pretty(&knowledge) {
885 Ok(mut json) => {
886 json.push('\n');
887 match crate::config_io::write_atomic_with_backup(&path, &json) {
888 Ok(()) => format!(
889 "Export saved: {} (active facts: {}, patterns: {}, history: {})",
890 path.display(),
891 knowledge.facts.iter().filter(|f| f.is_current()).count(),
892 knowledge.patterns.len(),
893 knowledge.history.len()
894 ),
895 Err(e) => format!("Export failed: {e}"),
896 }
897 }
898 Err(e) => format!("Export failed: {e}"),
899 }
900}
901
902fn handle_consolidate(project_root: &str) -> String {
903 let Some(session) = SessionState::load_latest() else {
904 return "No active session to consolidate.".to_string();
905 };
906 let policy = match crate::core::config::Config::load().memory_policy_effective() {
907 Ok(p) => p,
908 Err(e) => {
909 let path = crate::core::config::Config::path().map_or_else(
910 || "~/.lean-ctx/config.toml".to_string(),
911 |p| p.display().to_string(),
912 );
913 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
914 }
915 };
916
917 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
918 let mut consolidated = 0u32;
919
920 for finding in &session.findings {
921 let key_text = if let Some(ref file) = finding.file {
922 if let Some(line) = finding.line {
923 format!("{file}:{line}")
924 } else {
925 file.clone()
926 }
927 } else {
928 format!("finding-{consolidated}")
929 };
930
931 knowledge.remember(
932 "finding",
933 &key_text,
934 &finding.summary,
935 &session.id,
936 0.7,
937 &policy,
938 );
939 consolidated += 1;
940 }
941
942 for decision in &session.decisions {
943 let key_text = decision
944 .summary
945 .chars()
946 .take(50)
947 .collect::<String>()
948 .replace(' ', "-")
949 .to_lowercase();
950
951 knowledge.remember(
952 "decision",
953 &key_text,
954 &decision.summary,
955 &session.id,
956 0.85,
957 &policy,
958 );
959 consolidated += 1;
960 }
961
962 let task_desc = session
963 .task
964 .as_ref()
965 .map_or_else(|| "(no task)".into(), |t| t.description.clone());
966
967 let summary = format!(
968 "Session {}: {} — {} findings, {} decisions consolidated",
969 session.id,
970 task_desc,
971 session.findings.len(),
972 session.decisions.len()
973 );
974 knowledge.consolidate(&summary, vec![session.id.clone()], &policy);
975 let _ = knowledge.run_memory_lifecycle(&policy);
976
977 match knowledge.save() {
978 Ok(()) => format!(
979 "Consolidated {consolidated} items from session {} into project knowledge.\n\
980 Facts: {}, Patterns: {}, History: {}",
981 session.id,
982 knowledge.facts.len(),
983 knowledge.patterns.len(),
984 knowledge.history.len()
985 ),
986 Err(e) => format!("Consolidation done but save failed: {e}"),
987 }
988}
989
990fn handle_timeline(project_root: &str, category: Option<&str>) -> String {
991 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
992 return "No knowledge stored yet.".to_string();
993 };
994
995 let policy = match load_policy_or_error() {
996 Ok(p) => p,
997 Err(e) => return e,
998 };
999
1000 let Some(cat) = category else {
1001 return "Error: category is required for timeline".to_string();
1002 };
1003
1004 let facts = knowledge.timeline(cat);
1005 if facts.is_empty() {
1006 return format!("No history for category '{cat}'.");
1007 }
1008
1009 let mut ordered: Vec<&crate::core::knowledge::KnowledgeFact> = facts;
1010 ordered.sort_by(|a, b| {
1011 let a_start = a.valid_from.unwrap_or(a.created_at);
1012 let b_start = b.valid_from.unwrap_or(b.created_at);
1013 a_start
1014 .cmp(&b_start)
1015 .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
1016 .then_with(|| a.key.cmp(&b.key))
1017 .then_with(|| a.value.cmp(&b.value))
1018 });
1019
1020 let total = ordered.len();
1021 let limit = policy.knowledge.timeline_limit;
1022 if ordered.len() > limit {
1023 ordered = ordered[ordered.len() - limit..].to_vec();
1024 }
1025
1026 let mut out = format!(
1027 "Timeline [{cat}] (showing {}/{} entries):\n",
1028 ordered.len(),
1029 total
1030 );
1031 for f in &ordered {
1032 let status = if f.is_current() {
1033 "CURRENT"
1034 } else {
1035 "archived"
1036 };
1037 let valid_range = match (f.valid_from, f.valid_until) {
1038 (Some(from), Some(until)) => format!(
1039 "{} → {}",
1040 from.format("%Y-%m-%d %H:%M"),
1041 until.format("%Y-%m-%d %H:%M")
1042 ),
1043 (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
1044 _ => "unknown".to_string(),
1045 };
1046 out.push_str(&format!(
1047 " {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
1048 f.key,
1049 f.value,
1050 f.confidence * 100.0,
1051 f.confirmation_count
1052 ));
1053 }
1054 out
1055}
1056
1057fn handle_rooms(project_root: &str) -> String {
1058 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1059 return "No knowledge stored yet.".to_string();
1060 };
1061
1062 let policy = match load_policy_or_error() {
1063 Ok(p) => p,
1064 Err(e) => return e,
1065 };
1066
1067 let rooms = knowledge.list_rooms();
1068 if rooms.is_empty() {
1069 return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
1070 }
1071
1072 let mut rooms = rooms;
1073 rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1074 let total = rooms.len();
1075 rooms.truncate(policy.knowledge.rooms_limit);
1076
1077 let mut out = format!(
1078 "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
1079 rooms.len(),
1080 total,
1081 short_hash(&knowledge.project_hash)
1082 );
1083 for (cat, count) in &rooms {
1084 out.push_str(&format!(" [{cat}] {count} fact(s)\n"));
1085 }
1086 out
1087}
1088
1089fn handle_search(query: Option<&str>) -> String {
1090 let Some(q) = query else {
1091 return "Error: query is required for search".to_string();
1092 };
1093
1094 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1095 return "Cannot determine data directory.".to_string();
1096 };
1097
1098 let sessions_dir = data_dir.join("sessions");
1099
1100 if !sessions_dir.exists() {
1101 return "No sessions found.".to_string();
1102 }
1103
1104 let knowledge_dir = data_dir.join("knowledge");
1105
1106 let allow_cross_project = {
1107 let role = crate::core::roles::active_role();
1108 role.io.allow_cross_project_search
1109 };
1110
1111 let current_project_hash = std::env::current_dir()
1112 .ok()
1113 .map(|p| crate::core::project_hash::hash_project_root(&p.to_string_lossy()));
1114
1115 let q_lower = q.to_lowercase();
1116 let terms: Vec<&str> = q_lower.split_whitespace().collect();
1117 let mut results = Vec::new();
1118
1119 if knowledge_dir.exists() {
1120 if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
1121 for entry in entries.flatten() {
1122 let dir_name = entry.file_name().to_string_lossy().to_string();
1123
1124 if !allow_cross_project {
1125 if let Some(ref current_hash) = current_project_hash {
1126 if &dir_name != current_hash {
1127 continue;
1128 }
1129 }
1130 }
1131
1132 if let Some(ref current_hash) = current_project_hash {
1133 if dir_name != *current_hash {
1134 let policy = crate::core::config::Config::load().boundary_policy;
1135 let allowed = crate::core::memory_boundary::check_boundary(
1136 current_hash,
1137 &dir_name,
1138 &policy,
1139 &crate::core::memory_boundary::CrossProjectEventType::Search,
1140 );
1141 crate::core::memory_boundary::record_audit_event(
1142 &crate::core::memory_boundary::CrossProjectAuditEvent {
1143 timestamp: Utc::now().to_rfc3339(),
1144 event_type:
1145 crate::core::memory_boundary::CrossProjectEventType::Search,
1146 source_project_hash: current_hash.clone(),
1147 target_project_hash: dir_name.clone(),
1148 tool: "ctx_knowledge".to_string(),
1149 action: "search".to_string(),
1150 facts_accessed: 0,
1151 allowed,
1152 policy_reason: if allowed {
1153 "boundary_policy_allowed".to_string()
1154 } else {
1155 "boundary_policy_denied".to_string()
1156 },
1157 },
1158 );
1159 if !allowed {
1160 continue;
1161 }
1162 }
1163 }
1164
1165 let knowledge_file = entry.path().join("knowledge.json");
1166 if let Ok(content) = std::fs::read_to_string(&knowledge_file) {
1167 if let Ok(knowledge) = serde_json::from_str::<ProjectKnowledge>(&content) {
1168 let is_foreign = current_project_hash
1169 .as_ref()
1170 .is_some_and(|h| h != &knowledge.project_hash);
1171
1172 for fact in &knowledge.facts {
1173 if is_foreign
1174 && fact.privacy
1175 == crate::core::memory_boundary::FactPrivacy::ProjectOnly
1176 {
1177 continue;
1178 }
1179
1180 let searchable = format!(
1181 "{} {} {}",
1182 fact.category.to_lowercase(),
1183 fact.key.to_lowercase(),
1184 fact.value.to_lowercase()
1185 );
1186 let match_count =
1187 terms.iter().filter(|t| searchable.contains(**t)).count();
1188 if match_count > 0 {
1189 results.push((
1190 knowledge.project_root.clone(),
1191 fact.category.clone(),
1192 fact.key.clone(),
1193 fact.value.clone(),
1194 fact.confidence,
1195 match_count as f32 / terms.len() as f32,
1196 ));
1197 }
1198 }
1199 }
1200 }
1201 }
1202 }
1203 }
1204
1205 if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
1206 for entry in entries.flatten() {
1207 let path = entry.path();
1208 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1209 continue;
1210 }
1211 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1212 continue;
1213 }
1214 if let Ok(json) = std::fs::read_to_string(&path) {
1215 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1216 for finding in &session.findings {
1217 let searchable = finding.summary.to_lowercase();
1218 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
1219 if match_count > 0 {
1220 let project = session
1221 .project_root
1222 .clone()
1223 .unwrap_or_else(|| "unknown".to_string());
1224 results.push((
1225 project,
1226 "session-finding".to_string(),
1227 session.id.clone(),
1228 finding.summary.clone(),
1229 0.6,
1230 match_count as f32 / terms.len() as f32,
1231 ));
1232 }
1233 }
1234 for decision in &session.decisions {
1235 let searchable = decision.summary.to_lowercase();
1236 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
1237 if match_count > 0 {
1238 let project = session
1239 .project_root
1240 .clone()
1241 .unwrap_or_else(|| "unknown".to_string());
1242 results.push((
1243 project,
1244 "session-decision".to_string(),
1245 session.id.clone(),
1246 decision.summary.clone(),
1247 0.7,
1248 match_count as f32 / terms.len() as f32,
1249 ));
1250 }
1251 }
1252 }
1253 }
1254 }
1255 }
1256
1257 if results.is_empty() {
1258 return format!("No results found for '{q}' across all sessions and projects.");
1259 }
1260
1261 results.sort_by(|a, b| {
1262 b.5.partial_cmp(&a.5)
1263 .unwrap_or(std::cmp::Ordering::Equal)
1264 .then_with(|| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal))
1265 .then_with(|| a.0.cmp(&b.0))
1266 .then_with(|| a.1.cmp(&b.1))
1267 .then_with(|| a.2.cmp(&b.2))
1268 .then_with(|| a.3.cmp(&b.3))
1269 });
1270 results.truncate(crate::core::budgets::KNOWLEDGE_CROSS_PROJECT_SEARCH_LIMIT);
1271
1272 let mut out = format!("Cross-session search '{q}' ({} results):\n", results.len());
1273 for (project, cat, key, value, conf, _relevance) in &results {
1274 let project_short = short_path(project);
1275 out.push_str(&format!(
1276 " [{cat}/{key}] {value} (project: {project_short}, conf: {:.0}%)\n",
1277 conf * 100.0
1278 ));
1279 }
1280 out
1281}
1282
1283fn handle_cognition_loop(project_root: &str) -> String {
1284 let cfg = crate::core::config::Config::load().autonomy;
1285 if !cfg.cognition_loop_enabled {
1286 return "Cognition loop is disabled (autonomy.cognition_loop_enabled=false).".to_string();
1287 }
1288 let max_steps = cfg.cognition_loop_max_steps;
1289 let report = crate::core::cognition_loop::run_cognition_loop(project_root, max_steps);
1290 format!("{report}")
1291}
1292
1293fn handle_bridge_publish(project_root: &str, session_id: &str) -> String {
1294 let knowledge = ProjectKnowledge::load_or_create(project_root);
1295 let mut bridge =
1296 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1297 let count = bridge.publish(session_id, &knowledge.facts);
1298 match bridge.save() {
1299 Ok(()) => format!(
1300 "Published {count} fact(s) to bridge (total: {}, agent: {session_id})",
1301 bridge.shared_facts.len()
1302 ),
1303 Err(e) => format!("Published {count} fact(s) but save failed: {e}"),
1304 }
1305}
1306
1307fn handle_bridge_pull(project_root: &str, session_id: &str) -> String {
1308 let knowledge = ProjectKnowledge::load_or_create(project_root);
1309 let bridge =
1310 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1311 let entries = bridge.pull(session_id);
1312 if entries.is_empty() {
1313 return "No facts available from other agents.".to_string();
1314 }
1315
1316 let policy = match load_policy_or_error() {
1317 Ok(p) => p,
1318 Err(e) => return e,
1319 };
1320
1321 let mut target = knowledge;
1322 let mut imported = 0u32;
1323 for entry in &entries {
1324 let fact = crate::core::knowledge_bridge::KnowledgeBridge::entry_to_fact(entry);
1325 let existing = target
1326 .facts
1327 .iter()
1328 .any(|f| f.is_current() && f.category == fact.category && f.key == fact.key);
1329 if !existing {
1330 target.remember(
1331 &fact.category,
1332 &fact.key,
1333 &fact.value,
1334 session_id,
1335 fact.confidence,
1336 &policy,
1337 );
1338 imported += 1;
1339 }
1340 }
1341
1342 if imported == 0 {
1343 return format!(
1344 "Bridge has {} fact(s) from other agents, but all already exist locally.",
1345 entries.len()
1346 );
1347 }
1348
1349 match target.save() {
1350 Ok(()) => format!(
1351 "Pulled {imported}/{} fact(s) from bridge into local knowledge.",
1352 entries.len()
1353 ),
1354 Err(e) => format!("Pulled {imported} fact(s) but save failed: {e}"),
1355 }
1356}
1357
1358fn handle_bridge_status(project_root: &str) -> String {
1359 let knowledge = ProjectKnowledge::load_or_create(project_root);
1360 let bridge =
1361 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1362 bridge.summary()
1363}
1364
1365fn handle_wakeup(project_root: &str) -> String {
1366 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1367 return "No knowledge for wake-up briefing.".to_string();
1368 };
1369 let aaak = knowledge.format_aaak();
1370 if aaak.is_empty() {
1371 return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
1372 }
1373 format!("WAKE-UP BRIEFING:\n{aaak}")
1374}
1375
1376#[cfg(feature = "embeddings")]
1377struct SemanticHit {
1378 category: String,
1379 key: String,
1380 value: String,
1381 score: f32,
1382 semantic_score: f32,
1383 confidence_score: f32,
1384}
1385
1386#[cfg(feature = "embeddings")]
1387fn apply_retrieval_signals_from_hits(knowledge: &mut ProjectKnowledge, hits: &[SemanticHit]) {
1388 let now = Utc::now();
1389 for s in hits {
1390 for f in &mut knowledge.facts {
1391 if !f.is_current() {
1392 continue;
1393 }
1394 if f.category == s.category && f.key == s.key {
1395 f.retrieval_count = f.retrieval_count.saturating_add(1);
1396 f.last_retrieved = Some(now);
1397 break;
1398 }
1399 }
1400 }
1401}
1402
1403#[cfg(feature = "embeddings")]
1404fn format_semantic_facts(query: &str, hits: &[SemanticHit]) -> String {
1405 if hits.is_empty() {
1406 return format!("No facts matching '{query}'.");
1407 }
1408 let mut out = format!("Semantic recall '{query}' (showing {}):\n", hits.len());
1409 for s in hits {
1410 out.push_str(&format!(
1411 " [{}/{}]: {} (score: {:.0}%, sem: {:.0}%, conf: {:.0}%)\n",
1412 s.category,
1413 s.key,
1414 s.value,
1415 s.score * 100.0,
1416 s.semantic_score * 100.0,
1417 s.confidence_score * 100.0
1418 ));
1419 }
1420 out
1421}
1422
1423fn format_facts(
1424 facts: &[crate::core::knowledge::KnowledgeFact],
1425 total: usize,
1426 category: Option<&str>,
1427) -> String {
1428 let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> = facts.iter().collect();
1429 facts.sort_by(|a, b| sort_fact_for_output(a, b));
1430
1431 let mut out = String::new();
1432 if let Some(cat) = category {
1433 out.push_str(&format!(
1434 "Facts [{cat}] (showing {}/{}):\n",
1435 facts.len(),
1436 total
1437 ));
1438 } else {
1439 out.push_str(&format!(
1440 "Matching facts (showing {}/{}):\n",
1441 facts.len(),
1442 total
1443 ));
1444 }
1445 for f in facts {
1446 let temporal = if f.is_current() { "" } else { " [archived]" };
1447 out.push_str(&format!(
1448 " [{}/{}]: {} (quality: {:.0}%, confidence: {:.0}%, confirmed: {} x{}){temporal}\n",
1449 f.category,
1450 f.key,
1451 f.value,
1452 f.quality_score() * 100.0,
1453 f.confidence * 100.0,
1454 f.last_confirmed.format("%Y-%m-%d"),
1455 f.confirmation_count
1456 ));
1457 }
1458 out
1459}
1460
1461fn short_path(path: &str) -> String {
1462 let parts: Vec<&str> = path.split('/').collect();
1463 if parts.len() <= 2 {
1464 return path.to_string();
1465 }
1466 parts[parts.len() - 2..].join("/")
1467}
1468
1469fn short_hash(hash: &str) -> &str {
1470 if hash.len() > 8 {
1471 &hash[..8]
1472 } else {
1473 hash
1474 }
1475}
1476
1477fn sort_fact_for_output(
1478 a: &crate::core::knowledge::KnowledgeFact,
1479 b: &crate::core::knowledge::KnowledgeFact,
1480) -> std::cmp::Ordering {
1481 salience_score(b)
1482 .cmp(&salience_score(a))
1483 .then_with(|| {
1484 b.quality_score()
1485 .partial_cmp(&a.quality_score())
1486 .unwrap_or(std::cmp::Ordering::Equal)
1487 })
1488 .then_with(|| {
1489 b.confidence
1490 .partial_cmp(&a.confidence)
1491 .unwrap_or(std::cmp::Ordering::Equal)
1492 })
1493 .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
1494 .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
1495 .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
1496 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
1497 .then_with(|| a.category.cmp(&b.category))
1498 .then_with(|| a.key.cmp(&b.key))
1499 .then_with(|| a.value.cmp(&b.value))
1500}
1501
1502fn salience_score(f: &crate::core::knowledge::KnowledgeFact) -> u32 {
1503 let cat = f.category.to_lowercase();
1504 let base: u32 = match cat.as_str() {
1505 "decision" => 70,
1506 "gotcha" => 75,
1507 "architecture" | "arch" => 60,
1508 "security" => 65,
1509 "testing" | "tests" | "deployment" | "deploy" => 55,
1510 "conventions" | "convention" => 45,
1511 "finding" => 40,
1512 _ => 30,
1513 };
1514
1515 let quality_bonus = (f.quality_score() * 60.0) as u32;
1516 let recency_bonus = f.last_retrieved.map_or(0u32, |t| {
1517 let days = chrono::Utc::now().signed_duration_since(t).num_days();
1518 if days <= 7 {
1519 10u32
1520 } else if days <= 30 {
1521 5u32
1522 } else {
1523 0u32
1524 }
1525 });
1526
1527 base + quality_bonus + recency_bonus
1528}