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