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