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