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