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