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