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