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