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