Skip to main content

lean_ctx/tools/
ctx_knowledge.rs

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