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