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