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 let rehydrate_deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
597 for p in &archives {
598 if std::time::Instant::now() >= rehydrate_deadline {
599 tracing::warn!("ctx_knowledge: rehydrate time budget (10s) exceeded, stopping early");
600 break;
601 }
602 let p_str = p.to_string_lossy().to_string();
603 let Ok(facts) = crate::core::memory_lifecycle::restore_archive(&p_str) else {
604 continue;
605 };
606 for f in facts {
607 if let Some(cat) = category {
608 if f.category != cat {
609 continue;
610 }
611 }
612 if terms.is_empty() {
613 cands.push(Cand {
614 category: f.category,
615 key: f.key,
616 value: f.value,
617 confidence: f.confidence,
618 score: f.confidence,
619 });
620 } else {
621 let searchable = format!(
622 "{} {} {} {}",
623 f.category.to_lowercase(),
624 f.key.to_lowercase(),
625 f.value.to_lowercase(),
626 f.source_session.to_lowercase()
627 );
628 let match_count = terms.iter().filter(|t| searchable.contains(*t)).count();
629 if match_count == 0 {
630 continue;
631 }
632 let rel = match_count as f32 / terms.len() as f32;
633 let score = rel * f.confidence;
634 cands.push(Cand {
635 category: f.category,
636 key: f.key,
637 value: f.value,
638 confidence: f.confidence,
639 score,
640 });
641 }
642 }
643 }
644
645 if cands.is_empty() {
646 return false;
647 }
648
649 cands.sort_by(|a, b| {
650 b.score
651 .partial_cmp(&a.score)
652 .unwrap_or(std::cmp::Ordering::Equal)
653 .then_with(|| {
654 b.confidence
655 .partial_cmp(&a.confidence)
656 .unwrap_or(std::cmp::Ordering::Equal)
657 })
658 .then_with(|| a.category.cmp(&b.category))
659 .then_with(|| a.key.cmp(&b.key))
660 .then_with(|| a.value.cmp(&b.value))
661 });
662 cands.truncate(crate::core::budgets::KNOWLEDGE_REHYDRATE_LIMIT);
663
664 let mut any = false;
665 for c in &cands {
666 knowledge.remember(
667 &c.category,
668 &c.key,
669 &c.value,
670 session_id,
671 c.confidence.max(0.6),
672 policy,
673 );
674 any = true;
675 }
676 if any {
677 let _ = knowledge.run_memory_lifecycle(policy);
678 }
679 any
680}
681
682fn handle_pattern(
683 project_root: &str,
684 pattern_type: Option<&str>,
685 value: Option<&str>,
686 examples: Option<Vec<String>>,
687 session_id: &str,
688) -> String {
689 let Some(pt) = pattern_type else {
690 return "Error: pattern_type is required".to_string();
691 };
692 let Some(desc) = value else {
693 return "Error: value (description) is required for pattern".to_string();
694 };
695 let exs = examples.unwrap_or_default();
696 let policy = match crate::core::config::Config::load().memory_policy_effective() {
697 Ok(p) => p,
698 Err(e) => {
699 let path = crate::core::config::Config::path().map_or_else(
700 || "~/.lean-ctx/config.toml".to_string(),
701 |p| p.display().to_string(),
702 );
703 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
704 }
705 };
706 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
707 knowledge.add_pattern(pt, desc, exs, session_id, &policy);
708 match knowledge.save() {
709 Ok(()) => format!("Pattern [{pt}] added: {desc}"),
710 Err(e) => format!("Pattern added but save failed: {e}"),
711 }
712}
713
714fn handle_status(project_root: &str) -> String {
715 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
716 return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
717 };
718
719 let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
720 let archived_facts = knowledge.facts.len() - current_facts;
721
722 let mut out = format!(
723 "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
724 current_facts,
725 archived_facts,
726 knowledge.patterns.len(),
727 knowledge.history.len()
728 );
729 out.push_str(&format!(
730 "Last updated: {}\n",
731 knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
732 ));
733
734 let rooms = knowledge.list_rooms();
735 if !rooms.is_empty() {
736 out.push_str("Rooms: ");
737 let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
738 out.push_str(&room_strs.join(", "));
739 out.push('\n');
740 }
741
742 out.push_str(&knowledge.format_summary());
743 out
744}
745
746fn handle_health(project_root: &str) -> String {
747 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
748 return "No knowledge stored. Nothing to report.".to_string();
749 };
750
751 let total = knowledge.facts.len();
752 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
753 let archived = total - current.len();
754
755 let mut low_quality = 0u32;
756 let mut high_quality = 0u32;
757 let mut stale_candidates = 0u32;
758 let mut total_quality: f32 = 0.0;
759 let mut never_retrieved = 0u32;
760 let mut room_counts: std::collections::HashMap<String, (u32, f32)> =
761 std::collections::HashMap::new();
762
763 let now = chrono::Utc::now();
764 for f in ¤t {
765 let q = f.quality_score();
766 total_quality += q;
767 if q < 0.4 {
768 low_quality += 1;
769 } else if q >= 0.8 {
770 high_quality += 1;
771 }
772 if f.retrieval_count == 0 {
773 never_retrieved += 1;
774 }
775 let age_days = (now - f.created_at).num_days();
776 if age_days > 30 && f.retrieval_count == 0 {
777 stale_candidates += 1;
778 }
779
780 let entry = room_counts.entry(f.category.clone()).or_insert((0, 0.0));
781 entry.0 += 1;
782 entry.1 += q;
783 }
784
785 let avg_quality = if current.is_empty() {
786 0.0
787 } else {
788 total_quality / current.len() as f32
789 };
790
791 let mut out = String::from("=== Knowledge Health Report ===\n");
792 out.push_str(&format!(
793 "Total: {} facts ({} active, {} archived)\n",
794 total,
795 current.len(),
796 archived
797 ));
798 out.push_str(&format!("Avg Quality: {avg_quality:.2}\n"));
799 out.push_str(&format!(
800 "Distribution: {high_quality} high (>=0.8) | {low_quality} low (<0.4)\n"
801 ));
802 out.push_str(&format!(
803 "Stale (>30d, never retrieved): {stale_candidates}\n"
804 ));
805 out.push_str(&format!("Never retrieved: {never_retrieved}\n"));
806
807 if !room_counts.is_empty() {
808 out.push_str("\nRoom Balance:\n");
809 let mut rooms: Vec<_> = room_counts.into_iter().collect();
810 rooms.sort_by_key(|x| std::cmp::Reverse(x.1 .0));
811 for (cat, (count, total_q)) in &rooms {
812 let avg = if *count > 0 {
813 total_q / *count as f32
814 } else {
815 0.0
816 };
817 out.push_str(&format!(" {cat}: {count} facts, avg quality {avg:.2}\n"));
818 }
819 }
820
821 let policy = crate::core::memory_policy::MemoryPolicy::default();
822 out.push_str(&format!(
823 "\nPolicy: max {} facts, max {} patterns\n",
824 policy.knowledge.max_facts, policy.knowledge.max_patterns
825 ));
826
827 if current.len() > policy.knowledge.max_facts {
828 out.push_str(&format!(
829 "WARNING: Active facts ({}) exceed policy max ({})\n",
830 current.len(),
831 policy.knowledge.max_facts
832 ));
833 }
834
835 out
836}
837
838fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
839 let Some(cat) = category else {
840 return "Error: category is required for remove".to_string();
841 };
842 let Some(k) = key else {
843 return "Error: key is required for remove".to_string();
844 };
845 let policy = match crate::core::config::Config::load().memory_policy_effective() {
846 Ok(p) => p,
847 Err(e) => {
848 let path = crate::core::config::Config::path().map_or_else(
849 || "~/.lean-ctx/config.toml".to_string(),
850 |p| p.display().to_string(),
851 );
852 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
853 }
854 };
855 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
856 if knowledge.remove_fact(cat, k) {
857 let _ = knowledge.run_memory_lifecycle(&policy);
858
859 #[cfg(feature = "embeddings")]
860 {
861 if let Some(mut idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
862 &knowledge.project_hash,
863 ) {
864 idx.remove(cat, k);
865 crate::core::knowledge_embedding::compact_against_knowledge(
866 &mut idx, &knowledge, &policy,
867 );
868 let _ = idx.save();
869 }
870 }
871
872 match knowledge.save() {
873 Ok(()) => format!("Removed [{cat}] {k}"),
874 Err(e) => format!("Removed but save failed: {e}"),
875 }
876 } else {
877 format!("No fact found: [{cat}] {k}")
878 }
879}
880
881fn handle_export(project_root: &str) -> String {
882 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
883 return "No knowledge to export.".to_string();
884 };
885 let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
886 Ok(d) => d,
887 Err(e) => return format!("Export failed: {e}"),
888 };
889
890 let export_dir = data_dir.join("exports").join("knowledge");
891 let ts = Utc::now().format("%Y%m%d-%H%M%S");
892 let filename = format!(
893 "knowledge-{}-{ts}.json",
894 short_hash(&knowledge.project_hash)
895 );
896 let path = export_dir.join(filename);
897
898 match serde_json::to_string_pretty(&knowledge) {
899 Ok(mut json) => {
900 json.push('\n');
901 match crate::config_io::write_atomic_with_backup(&path, &json) {
902 Ok(()) => format!(
903 "Export saved: {} (active facts: {}, patterns: {}, history: {})",
904 path.display(),
905 knowledge.facts.iter().filter(|f| f.is_current()).count(),
906 knowledge.patterns.len(),
907 knowledge.history.len()
908 ),
909 Err(e) => format!("Export failed: {e}"),
910 }
911 }
912 Err(e) => format!("Export failed: {e}"),
913 }
914}
915
916fn handle_consolidate(project_root: &str) -> String {
917 let Some(session) = SessionState::load_latest() else {
918 return "No active session to consolidate.".to_string();
919 };
920 let policy = match crate::core::config::Config::load().memory_policy_effective() {
921 Ok(p) => p,
922 Err(e) => {
923 let path = crate::core::config::Config::path().map_or_else(
924 || "~/.lean-ctx/config.toml".to_string(),
925 |p| p.display().to_string(),
926 );
927 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
928 }
929 };
930
931 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
932 let mut consolidated = 0u32;
933
934 for finding in &session.findings {
935 let key_text = if let Some(ref file) = finding.file {
936 if let Some(line) = finding.line {
937 format!("{file}:{line}")
938 } else {
939 file.clone()
940 }
941 } else {
942 format!("finding-{consolidated}")
943 };
944
945 knowledge.remember(
946 "finding",
947 &key_text,
948 &finding.summary,
949 &session.id,
950 0.7,
951 &policy,
952 );
953 consolidated += 1;
954 }
955
956 for decision in &session.decisions {
957 let key_text = decision
958 .summary
959 .chars()
960 .take(50)
961 .collect::<String>()
962 .replace(' ', "-")
963 .to_lowercase();
964
965 knowledge.remember(
966 "decision",
967 &key_text,
968 &decision.summary,
969 &session.id,
970 0.85,
971 &policy,
972 );
973 consolidated += 1;
974 }
975
976 let task_desc = session
977 .task
978 .as_ref()
979 .map_or_else(|| "(no task)".into(), |t| t.description.clone());
980
981 let summary = format!(
982 "Session {}: {} — {} findings, {} decisions consolidated",
983 session.id,
984 task_desc,
985 session.findings.len(),
986 session.decisions.len()
987 );
988 knowledge.consolidate(&summary, vec![session.id.clone()], &policy);
989 let _ = knowledge.run_memory_lifecycle(&policy);
990
991 match knowledge.save() {
992 Ok(()) => format!(
993 "Consolidated {consolidated} items from session {} into project knowledge.\n\
994 Facts: {}, Patterns: {}, History: {}",
995 session.id,
996 knowledge.facts.len(),
997 knowledge.patterns.len(),
998 knowledge.history.len()
999 ),
1000 Err(e) => format!("Consolidation done but save failed: {e}"),
1001 }
1002}
1003
1004fn handle_timeline(project_root: &str, category: Option<&str>) -> String {
1005 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1006 return "No knowledge stored yet.".to_string();
1007 };
1008
1009 let policy = match load_policy_or_error() {
1010 Ok(p) => p,
1011 Err(e) => return e,
1012 };
1013
1014 let Some(cat) = category else {
1015 return "Error: category is required for timeline".to_string();
1016 };
1017
1018 let facts = knowledge.timeline(cat);
1019 if facts.is_empty() {
1020 return format!("No history for category '{cat}'.");
1021 }
1022
1023 let mut ordered: Vec<&crate::core::knowledge::KnowledgeFact> = facts;
1024 ordered.sort_by(|a, b| {
1025 let a_start = a.valid_from.unwrap_or(a.created_at);
1026 let b_start = b.valid_from.unwrap_or(b.created_at);
1027 a_start
1028 .cmp(&b_start)
1029 .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
1030 .then_with(|| a.key.cmp(&b.key))
1031 .then_with(|| a.value.cmp(&b.value))
1032 });
1033
1034 let total = ordered.len();
1035 let limit = policy.knowledge.timeline_limit;
1036 if ordered.len() > limit {
1037 ordered = ordered[ordered.len() - limit..].to_vec();
1038 }
1039
1040 let mut out = format!(
1041 "Timeline [{cat}] (showing {}/{} entries):\n",
1042 ordered.len(),
1043 total
1044 );
1045 for f in &ordered {
1046 let status = if f.is_current() {
1047 "CURRENT"
1048 } else {
1049 "archived"
1050 };
1051 let valid_range = match (f.valid_from, f.valid_until) {
1052 (Some(from), Some(until)) => format!(
1053 "{} → {}",
1054 from.format("%Y-%m-%d %H:%M"),
1055 until.format("%Y-%m-%d %H:%M")
1056 ),
1057 (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
1058 _ => "unknown".to_string(),
1059 };
1060 out.push_str(&format!(
1061 " {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
1062 f.key,
1063 f.value,
1064 f.confidence * 100.0,
1065 f.confirmation_count
1066 ));
1067 }
1068 out
1069}
1070
1071fn handle_rooms(project_root: &str) -> String {
1072 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1073 return "No knowledge stored yet.".to_string();
1074 };
1075
1076 let policy = match load_policy_or_error() {
1077 Ok(p) => p,
1078 Err(e) => return e,
1079 };
1080
1081 let rooms = knowledge.list_rooms();
1082 if rooms.is_empty() {
1083 return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
1084 }
1085
1086 let mut rooms = rooms;
1087 rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1088 let total = rooms.len();
1089 rooms.truncate(policy.knowledge.rooms_limit);
1090
1091 let mut out = format!(
1092 "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
1093 rooms.len(),
1094 total,
1095 short_hash(&knowledge.project_hash)
1096 );
1097 for (cat, count) in &rooms {
1098 out.push_str(&format!(" [{cat}] {count} fact(s)\n"));
1099 }
1100 out
1101}
1102
1103fn handle_search(query: Option<&str>) -> String {
1104 let Some(q) = query else {
1105 return "Error: query is required for search".to_string();
1106 };
1107
1108 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1109 return "Cannot determine data directory.".to_string();
1110 };
1111
1112 let sessions_dir = data_dir.join("sessions");
1113
1114 if !sessions_dir.exists() {
1115 return "No sessions found.".to_string();
1116 }
1117
1118 let knowledge_dir = data_dir.join("knowledge");
1119
1120 let allow_cross_project = {
1121 let role = crate::core::roles::active_role();
1122 role.io.allow_cross_project_search
1123 };
1124
1125 let current_project_hash = std::env::current_dir()
1126 .ok()
1127 .map(|p| crate::core::project_hash::hash_project_root(&p.to_string_lossy()));
1128
1129 let q_lower = q.to_lowercase();
1130 let terms: Vec<&str> = q_lower.split_whitespace().collect();
1131 let mut results = Vec::new();
1132
1133 if knowledge_dir.exists() {
1134 if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
1135 for entry in entries.flatten() {
1136 let dir_name = entry.file_name().to_string_lossy().to_string();
1137
1138 if !allow_cross_project {
1139 if let Some(ref current_hash) = current_project_hash {
1140 if &dir_name != current_hash {
1141 continue;
1142 }
1143 }
1144 }
1145
1146 if let Some(ref current_hash) = current_project_hash {
1147 if dir_name != *current_hash {
1148 let policy = crate::core::config::Config::load().boundary_policy;
1149 let allowed = crate::core::memory_boundary::check_boundary(
1150 current_hash,
1151 &dir_name,
1152 &policy,
1153 &crate::core::memory_boundary::CrossProjectEventType::Search,
1154 );
1155 crate::core::memory_boundary::record_audit_event(
1156 &crate::core::memory_boundary::CrossProjectAuditEvent {
1157 timestamp: Utc::now().to_rfc3339(),
1158 event_type:
1159 crate::core::memory_boundary::CrossProjectEventType::Search,
1160 source_project_hash: current_hash.clone(),
1161 target_project_hash: dir_name.clone(),
1162 tool: "ctx_knowledge".to_string(),
1163 action: "search".to_string(),
1164 facts_accessed: 0,
1165 allowed,
1166 policy_reason: if allowed {
1167 "boundary_policy_allowed".to_string()
1168 } else {
1169 "boundary_policy_denied".to_string()
1170 },
1171 },
1172 );
1173 if !allowed {
1174 continue;
1175 }
1176 }
1177 }
1178
1179 let knowledge_file = entry.path().join("knowledge.json");
1180 if let Ok(content) = std::fs::read_to_string(&knowledge_file) {
1181 if let Ok(knowledge) = serde_json::from_str::<ProjectKnowledge>(&content) {
1182 let is_foreign = current_project_hash
1183 .as_ref()
1184 .is_some_and(|h| h != &knowledge.project_hash);
1185
1186 for fact in &knowledge.facts {
1187 if is_foreign
1188 && fact.privacy
1189 == crate::core::memory_boundary::FactPrivacy::ProjectOnly
1190 {
1191 continue;
1192 }
1193
1194 let searchable = format!(
1195 "{} {} {}",
1196 fact.category.to_lowercase(),
1197 fact.key.to_lowercase(),
1198 fact.value.to_lowercase()
1199 );
1200 let match_count =
1201 terms.iter().filter(|t| searchable.contains(**t)).count();
1202 if match_count > 0 {
1203 results.push((
1204 knowledge.project_root.clone(),
1205 fact.category.clone(),
1206 fact.key.clone(),
1207 fact.value.clone(),
1208 fact.confidence,
1209 match_count as f32 / terms.len() as f32,
1210 ));
1211 }
1212 }
1213 }
1214 }
1215 }
1216 }
1217 }
1218
1219 if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
1220 for entry in entries.flatten() {
1221 let path = entry.path();
1222 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1223 continue;
1224 }
1225 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1226 continue;
1227 }
1228 if let Ok(json) = std::fs::read_to_string(&path) {
1229 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1230 for finding in &session.findings {
1231 let searchable = finding.summary.to_lowercase();
1232 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
1233 if match_count > 0 {
1234 let project = session
1235 .project_root
1236 .clone()
1237 .unwrap_or_else(|| "unknown".to_string());
1238 results.push((
1239 project,
1240 "session-finding".to_string(),
1241 session.id.clone(),
1242 finding.summary.clone(),
1243 0.6,
1244 match_count as f32 / terms.len() as f32,
1245 ));
1246 }
1247 }
1248 for decision in &session.decisions {
1249 let searchable = decision.summary.to_lowercase();
1250 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
1251 if match_count > 0 {
1252 let project = session
1253 .project_root
1254 .clone()
1255 .unwrap_or_else(|| "unknown".to_string());
1256 results.push((
1257 project,
1258 "session-decision".to_string(),
1259 session.id.clone(),
1260 decision.summary.clone(),
1261 0.7,
1262 match_count as f32 / terms.len() as f32,
1263 ));
1264 }
1265 }
1266 }
1267 }
1268 }
1269 }
1270
1271 if results.is_empty() {
1272 return format!("No results found for '{q}' across all sessions and projects.");
1273 }
1274
1275 results.sort_by(|a, b| {
1276 b.5.partial_cmp(&a.5)
1277 .unwrap_or(std::cmp::Ordering::Equal)
1278 .then_with(|| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal))
1279 .then_with(|| a.0.cmp(&b.0))
1280 .then_with(|| a.1.cmp(&b.1))
1281 .then_with(|| a.2.cmp(&b.2))
1282 .then_with(|| a.3.cmp(&b.3))
1283 });
1284 results.truncate(crate::core::budgets::KNOWLEDGE_CROSS_PROJECT_SEARCH_LIMIT);
1285
1286 let mut out = format!("Cross-session search '{q}' ({} results):\n", results.len());
1287 for (project, cat, key, value, conf, _relevance) in &results {
1288 let project_short = short_path(project);
1289 out.push_str(&format!(
1290 " [{cat}/{key}] {value} (project: {project_short}, conf: {:.0}%)\n",
1291 conf * 100.0
1292 ));
1293 }
1294 out
1295}
1296
1297fn handle_cognition_loop(project_root: &str) -> String {
1298 let cfg = crate::core::config::Config::load().autonomy;
1299 if !cfg.cognition_loop_enabled {
1300 return "Cognition loop is disabled (autonomy.cognition_loop_enabled=false).".to_string();
1301 }
1302 let max_steps = cfg.cognition_loop_max_steps;
1303 let report = crate::core::cognition_loop::run_cognition_loop(project_root, max_steps);
1304 format!("{report}")
1305}
1306
1307fn handle_bridge_publish(project_root: &str, session_id: &str) -> String {
1308 let knowledge = ProjectKnowledge::load_or_create(project_root);
1309 let mut bridge =
1310 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1311 let count = bridge.publish(session_id, &knowledge.facts);
1312 match bridge.save() {
1313 Ok(()) => format!(
1314 "Published {count} fact(s) to bridge (total: {}, agent: {session_id})",
1315 bridge.shared_facts.len()
1316 ),
1317 Err(e) => format!("Published {count} fact(s) but save failed: {e}"),
1318 }
1319}
1320
1321fn handle_bridge_pull(project_root: &str, session_id: &str) -> String {
1322 let knowledge = ProjectKnowledge::load_or_create(project_root);
1323 let bridge =
1324 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1325 let entries = bridge.pull(session_id);
1326 if entries.is_empty() {
1327 return "No facts available from other agents.".to_string();
1328 }
1329
1330 let policy = match load_policy_or_error() {
1331 Ok(p) => p,
1332 Err(e) => return e,
1333 };
1334
1335 let mut target = knowledge;
1336 let mut imported = 0u32;
1337 for entry in &entries {
1338 let fact = crate::core::knowledge_bridge::KnowledgeBridge::entry_to_fact(entry);
1339 let existing = target
1340 .facts
1341 .iter()
1342 .any(|f| f.is_current() && f.category == fact.category && f.key == fact.key);
1343 if !existing {
1344 target.remember(
1345 &fact.category,
1346 &fact.key,
1347 &fact.value,
1348 session_id,
1349 fact.confidence,
1350 &policy,
1351 );
1352 imported += 1;
1353 }
1354 }
1355
1356 if imported == 0 {
1357 return format!(
1358 "Bridge has {} fact(s) from other agents, but all already exist locally.",
1359 entries.len()
1360 );
1361 }
1362
1363 match target.save() {
1364 Ok(()) => format!(
1365 "Pulled {imported}/{} fact(s) from bridge into local knowledge.",
1366 entries.len()
1367 ),
1368 Err(e) => format!("Pulled {imported} fact(s) but save failed: {e}"),
1369 }
1370}
1371
1372fn handle_bridge_status(project_root: &str) -> String {
1373 let knowledge = ProjectKnowledge::load_or_create(project_root);
1374 let bridge =
1375 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1376 bridge.summary()
1377}
1378
1379fn handle_wakeup(project_root: &str) -> String {
1380 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1381 return "No knowledge for wake-up briefing.".to_string();
1382 };
1383 let aaak = knowledge.format_aaak();
1384 if aaak.is_empty() {
1385 return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
1386 }
1387 format!("WAKE-UP BRIEFING:\n{aaak}")
1388}
1389
1390#[cfg(feature = "embeddings")]
1391struct SemanticHit {
1392 category: String,
1393 key: String,
1394 value: String,
1395 score: f32,
1396 semantic_score: f32,
1397 confidence_score: f32,
1398}
1399
1400#[cfg(feature = "embeddings")]
1401fn apply_retrieval_signals_from_hits(knowledge: &mut ProjectKnowledge, hits: &[SemanticHit]) {
1402 let now = Utc::now();
1403 for s in hits {
1404 for f in &mut knowledge.facts {
1405 if !f.is_current() {
1406 continue;
1407 }
1408 if f.category == s.category && f.key == s.key {
1409 f.retrieval_count = f.retrieval_count.saturating_add(1);
1410 f.last_retrieved = Some(now);
1411 break;
1412 }
1413 }
1414 }
1415}
1416
1417#[cfg(feature = "embeddings")]
1418fn format_semantic_facts(query: &str, hits: &[SemanticHit]) -> String {
1419 if hits.is_empty() {
1420 return format!("No facts matching '{query}'.");
1421 }
1422 let mut out = format!("Semantic recall '{query}' (showing {}):\n", hits.len());
1423 for s in hits {
1424 out.push_str(&format!(
1425 " [{}/{}]: {} (score: {:.0}%, sem: {:.0}%, conf: {:.0}%)\n",
1426 s.category,
1427 s.key,
1428 s.value,
1429 s.score * 100.0,
1430 s.semantic_score * 100.0,
1431 s.confidence_score * 100.0
1432 ));
1433 }
1434 out
1435}
1436
1437fn format_facts(
1438 facts: &[crate::core::knowledge::KnowledgeFact],
1439 total: usize,
1440 category: Option<&str>,
1441) -> String {
1442 let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> = facts.iter().collect();
1443 facts.sort_by(|a, b| sort_fact_for_output(a, b));
1444
1445 let mut out = String::new();
1446 if let Some(cat) = category {
1447 out.push_str(&format!(
1448 "Facts [{cat}] (showing {}/{}):\n",
1449 facts.len(),
1450 total
1451 ));
1452 } else {
1453 out.push_str(&format!(
1454 "Matching facts (showing {}/{}):\n",
1455 facts.len(),
1456 total
1457 ));
1458 }
1459 for f in facts {
1460 let temporal = if f.is_current() { "" } else { " [archived]" };
1461 out.push_str(&format!(
1462 " [{}/{}]: {} (quality: {:.0}%, confidence: {:.0}%, confirmed: {} x{}){temporal}\n",
1463 f.category,
1464 f.key,
1465 f.value,
1466 f.quality_score() * 100.0,
1467 f.confidence * 100.0,
1468 f.last_confirmed.format("%Y-%m-%d"),
1469 f.confirmation_count
1470 ));
1471 }
1472 out
1473}
1474
1475fn short_path(path: &str) -> String {
1476 let parts: Vec<&str> = path.split('/').collect();
1477 if parts.len() <= 2 {
1478 return path.to_string();
1479 }
1480 parts[parts.len() - 2..].join("/")
1481}
1482
1483fn short_hash(hash: &str) -> &str {
1484 if hash.len() > 8 {
1485 &hash[..8]
1486 } else {
1487 hash
1488 }
1489}
1490
1491fn sort_fact_for_output(
1492 a: &crate::core::knowledge::KnowledgeFact,
1493 b: &crate::core::knowledge::KnowledgeFact,
1494) -> std::cmp::Ordering {
1495 salience_score(b)
1496 .cmp(&salience_score(a))
1497 .then_with(|| {
1498 b.quality_score()
1499 .partial_cmp(&a.quality_score())
1500 .unwrap_or(std::cmp::Ordering::Equal)
1501 })
1502 .then_with(|| {
1503 b.confidence
1504 .partial_cmp(&a.confidence)
1505 .unwrap_or(std::cmp::Ordering::Equal)
1506 })
1507 .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
1508 .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
1509 .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
1510 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
1511 .then_with(|| a.category.cmp(&b.category))
1512 .then_with(|| a.key.cmp(&b.key))
1513 .then_with(|| a.value.cmp(&b.value))
1514}
1515
1516fn salience_score(f: &crate::core::knowledge::KnowledgeFact) -> u32 {
1517 let cat = f.category.to_lowercase();
1518 let base: u32 = match cat.as_str() {
1519 "decision" => 70,
1520 "gotcha" => 75,
1521 "architecture" | "arch" => 60,
1522 "security" => 65,
1523 "testing" | "tests" | "deployment" | "deploy" => 55,
1524 "conventions" | "convention" => 45,
1525 "finding" => 40,
1526 _ => 30,
1527 };
1528
1529 let quality_bonus = (f.quality_score() * 60.0) as u32;
1530 let recency_bonus = f.last_retrieved.map_or(0u32, |t| {
1531 let days = chrono::Utc::now().signed_duration_since(t).num_days();
1532 if days <= 7 {
1533 10u32
1534 } else if days <= 30 {
1535 5u32
1536 } else {
1537 0u32
1538 }
1539 });
1540
1541 base + quality_bonus + recency_bonus
1542}