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