Skip to main content

lean_ctx/tools/
ctx_knowledge.rs

1use chrono::Utc;
2
3#[cfg(feature = "embeddings")]
4use crate::core::embeddings::EmbeddingEngine;
5
6use crate::core::knowledge::ProjectKnowledge;
7use crate::core::memory_policy::MemoryPolicy;
8use crate::core::session::SessionState;
9
10fn load_policy_or_error() -> Result<MemoryPolicy, String> {
11    super::knowledge_shared::load_policy_or_error()
12}
13
14/// Dispatches knowledge base actions (remember, recall, pattern, timeline, etc.).
15#[allow(clippy::too_many_arguments)]
16pub fn handle(
17    project_root: &str,
18    action: &str,
19    category: Option<&str>,
20    key: Option<&str>,
21    value: Option<&str>,
22    query: Option<&str>,
23    session_id: &str,
24    pattern_type: Option<&str>,
25    examples: Option<Vec<String>>,
26    confidence: Option<f32>,
27    mode: Option<&str>,
28) -> String {
29    match action {
30        "policy" => handle_policy(value),
31        "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
32        "recall" => handle_recall(project_root, category, query, session_id, mode),
33        "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
34        "feedback" => handle_feedback(project_root, category, key, value, session_id),
35        "relate" => crate::tools::ctx_knowledge_relations::handle_relate(
36            project_root,
37            category,
38            key,
39            value,
40            query,
41            session_id,
42        ),
43        "unrelate" => crate::tools::ctx_knowledge_relations::handle_unrelate(
44            project_root,
45            category,
46            key,
47            value,
48            query,
49        ),
50        "relations" => crate::tools::ctx_knowledge_relations::handle_relations(
51            project_root,
52            category,
53            key,
54            value,
55            query,
56        ),
57        "relations_diagram" => crate::tools::ctx_knowledge_relations::handle_relations_diagram(
58            project_root,
59            category,
60            key,
61            value,
62            query,
63        ),
64        "status" => handle_status(project_root),
65        "health" => handle_health(project_root),
66        "remove" => handle_remove(project_root, category, key),
67        "export" => handle_export(project_root),
68        "consolidate" => handle_consolidate(project_root),
69        "timeline" => handle_timeline(project_root, category),
70        "rooms" => handle_rooms(project_root),
71        "search" => handle_search(query),
72        "wakeup" => handle_wakeup(project_root),
73        "embeddings_status" => handle_embeddings_status(project_root),
74        "embeddings_reset" => handle_embeddings_reset(project_root),
75        "embeddings_reindex" => handle_embeddings_reindex(project_root),
76        "judge" => handle_judge(project_root, category, key, value, query),
77        "cognition_loop" => handle_cognition_loop(project_root),
78        "bridge_publish" => handle_bridge_publish(project_root, session_id),
79        "bridge_pull" => handle_bridge_pull(project_root, session_id),
80        "bridge_status" => handle_bridge_status(project_root),
81        _ => format!(
82            "Unknown action: {action}. Use: policy, remember, recall, pattern, feedback, judge, relate, unrelate, relations, relations_diagram, status, health, remove, export, consolidate, timeline, rooms, search, wakeup, embeddings_status, embeddings_reset, embeddings_reindex, cognition_loop, bridge_publish, bridge_pull, bridge_status"
83        ),
84    }
85}
86
87fn handle_policy(value: Option<&str>) -> String {
88    let sub = value.unwrap_or("show").trim().to_lowercase();
89    let profile = crate::core::profiles::active_profile_name();
90
91    match sub.as_str() {
92        "show" => {
93            let policy = match load_policy_or_error() {
94                Ok(p) => p,
95                Err(e) => return e,
96            };
97
98            let cfg_path = crate::core::config::Config::path().map_or_else(
99                || "~/.lean-ctx/config.toml".to_string(),
100                |p| p.display().to_string(),
101            );
102
103            format!(
104                "Knowledge policy (effective, profile={profile}):\n\
105                 - memory.knowledge.max_facts={}\n\
106                 - memory.knowledge.contradiction_threshold={}\n\
107                 - memory.knowledge.recall_facts_limit={}\n\
108                 - memory.knowledge.rooms_limit={}\n\
109                 - memory.knowledge.timeline_limit={}\n\
110                 - memory.knowledge.relations_limit={}\n\
111                 - memory.lifecycle.decay_rate={}\n\
112                 - memory.lifecycle.stale_days={}\n\
113                 \nConfig: {cfg_path}",
114                policy.knowledge.max_facts,
115                policy.knowledge.contradiction_threshold,
116                policy.knowledge.recall_facts_limit,
117                policy.knowledge.rooms_limit,
118                policy.knowledge.timeline_limit,
119                policy.knowledge.relations_limit,
120                policy.lifecycle.decay_rate,
121                policy.lifecycle.stale_days
122            )
123        }
124        "validate" => match load_policy_or_error() {
125            Ok(_) => format!("OK: memory policy valid (profile={profile})"),
126            Err(e) => e,
127        },
128        _ => "Error: policy value must be show|validate".to_string(),
129    }
130}
131
132fn handle_feedback(
133    project_root: &str,
134    category: Option<&str>,
135    key: Option<&str>,
136    value: Option<&str>,
137    session_id: &str,
138) -> String {
139    let Some(cat) = category else {
140        return "Error: category is required for feedback".to_string();
141    };
142    let Some(k) = key else {
143        return "Error: key is required for feedback".to_string();
144    };
145    let dir = value.unwrap_or("up").trim().to_lowercase();
146    let is_up = matches!(dir.as_str(), "up" | "+1" | "+" | "true" | "1");
147    let is_down = matches!(dir.as_str(), "down" | "-1" | "-" | "false" | "0");
148    if !is_up && !is_down {
149        return "Error: feedback value must be up|down (+1|-1)".to_string();
150    }
151
152    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
153    let Some(f) = knowledge
154        .facts
155        .iter_mut()
156        .find(|f| f.is_current() && f.category == cat && f.key == k)
157    else {
158        return format!("No current fact found: [{cat}] {k}");
159    };
160
161    if is_up {
162        f.feedback_up = f.feedback_up.saturating_add(1);
163    } else {
164        f.feedback_down = f.feedback_down.saturating_add(1);
165    }
166    f.last_feedback = Some(Utc::now());
167
168    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
169        category: cat.to_string(),
170        key: k.to_string(),
171        action: if is_up {
172            "feedback_up"
173        } else {
174            "feedback_down"
175        }
176        .to_string(),
177    });
178
179    let quality = f.quality_score();
180    let up = f.feedback_up;
181    let down = f.feedback_down;
182    let conf = f.confidence;
183
184    match knowledge.save() {
185        Ok(()) => format!(
186            "Feedback recorded ({dir}) for [{cat}] {k} (up={up}, down={down}, quality={quality:.2}, confidence={conf:.2}, session={session_id})"
187        ),
188        Err(e) => format!(
189            "Feedback recorded ({dir}) but save failed: {e} (up={up}, down={down}, quality={quality:.2})"
190        ),
191    }
192}
193
194fn handle_judge(
195    project_root: &str,
196    category: Option<&str>,
197    key: Option<&str>,
198    value: Option<&str>,
199    query: Option<&str>,
200) -> String {
201    let source = match (category, key) {
202        (Some(cat), Some(k)) => format!("{cat}/{k}"),
203        _ => {
204            if let Some(k) = key.or(category) {
205                if k.contains('/') {
206                    k.to_string()
207                } else {
208                    return "Error: judge requires key as 'category/key' (source fact)".to_string();
209                }
210            } else {
211                return "Error: judge requires category+key (source fact) and value (target 'category/key')"
212                    .to_string();
213            }
214        }
215    };
216
217    let Some(target) = value else {
218        return "Error: judge requires value as target 'category/key'".to_string();
219    };
220    let target = target.trim().to_string();
221    if !target.contains('/') {
222        return "Error: target must be 'category/key' format".to_string();
223    }
224
225    let verdict = query.unwrap_or("compatible").trim().to_lowercase();
226    if !matches!(verdict.as_str(), "supersedes" | "compatible" | "unrelated") {
227        return format!("Error: verdict must be supersedes|compatible|unrelated, got '{verdict}'");
228    }
229
230    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
231
232    let source_exists = {
233        let parts: Vec<&str> = source.splitn(2, '/').collect();
234        parts.len() == 2
235            && knowledge
236                .facts
237                .iter()
238                .any(|f| f.category == parts[0] && f.key == parts[1] && f.is_current())
239    };
240    if !source_exists {
241        return format!("Error: no current fact found for '{source}'");
242    }
243
244    let target_parts: Vec<&str> = target.splitn(2, '/').collect();
245    if target_parts.len() != 2 {
246        return format!("Error: invalid target format '{target}'");
247    }
248    let (tcat, tkey) = (target_parts[0], target_parts[1]);
249
250    let target_exists = knowledge
251        .facts
252        .iter()
253        .any(|f| f.category == tcat && f.key == tkey && f.is_current());
254    if !target_exists {
255        return format!("Error: no current fact found for '{target}'");
256    }
257
258    if verdict == "supersedes" {
259        let now = Utc::now();
260        if let Some(tf) = knowledge
261            .facts
262            .iter_mut()
263            .find(|f| f.category == tcat && f.key == tkey && f.is_current())
264        {
265            tf.valid_until = Some(now);
266            tf.valid_from = tf.valid_from.or(Some(tf.created_at));
267        }
268    }
269
270    knowledge
271        .judged_pairs
272        .push(crate::core::knowledge::JudgedPair {
273            key_a: source.clone(),
274            key_b: target.clone(),
275            verdict: verdict.clone(),
276            judged_at: Utc::now(),
277        });
278
279    let save_msg = match knowledge.save() {
280        Ok(()) => String::new(),
281        Err(e) => format!(" (save warning: {e})"),
282    };
283
284    let action_desc = match verdict.as_str() {
285        "supersedes" => format!("{source} supersedes {target} (target archived)"),
286        "compatible" => format!("{source} ↔ {target} (compatible, suppressed from future similar)"),
287        "unrelated" => format!("{source} ≠ {target} (unrelated, suppressed from future similar)"),
288        _ => unreachable!(),
289    };
290
291    format!("Judged: {action_desc}{save_msg}")
292}
293
294#[cfg(feature = "embeddings")]
295fn embeddings_auto_download_allowed() -> bool {
296    std::env::var("LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD")
297        .ok()
298        .is_some_and(|v| {
299            matches!(
300                v.trim().to_lowercase().as_str(),
301                "1" | "true" | "yes" | "on"
302            )
303        })
304}
305
306#[cfg(feature = "embeddings")]
307fn embedding_engine() -> Option<&'static EmbeddingEngine> {
308    embedding_engine_impl(false)
309}
310
311/// Non-blocking: returns engine only if already loaded. Never triggers model load.
312#[cfg(feature = "embeddings")]
313fn embedding_engine_nonblocking() -> Option<&'static EmbeddingEngine> {
314    embedding_engine_impl(true)
315}
316
317#[cfg(feature = "embeddings")]
318fn embedding_engine_impl(nonblocking: bool) -> Option<&'static EmbeddingEngine> {
319    let cfg = crate::core::config::Config::load();
320    let profile = crate::core::config::MemoryProfile::effective(&cfg);
321    if !profile.embeddings_enabled() {
322        return None;
323    }
324    if !EmbeddingEngine::is_available() && !embeddings_auto_download_allowed() {
325        return None;
326    }
327    if nonblocking {
328        crate::core::embeddings::try_shared_engine()
329    } else {
330        crate::core::embeddings::shared_engine()
331    }
332}
333
334fn handle_embeddings_status(project_root: &str) -> String {
335    #[cfg(feature = "embeddings")]
336    {
337        let knowledge = ProjectKnowledge::load_or_create(project_root);
338        let model_available = EmbeddingEngine::is_available();
339        let auto = embeddings_auto_download_allowed();
340
341        let entries = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
342            &knowledge.project_hash,
343        )
344        .map_or(0, |i| i.entries.len());
345
346        let path = crate::core::data_dir::lean_ctx_data_dir()
347            .ok()
348            .map(|d| {
349                d.join("knowledge")
350                    .join(&knowledge.project_hash)
351                    .join("embeddings.json")
352            })
353            .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
354
355        format!(
356            "Knowledge embeddings: model={}, auto_download={}, index_entries={}, path={path}",
357            if model_available {
358                "present"
359            } else {
360                "missing"
361            },
362            if auto { "on" } else { "off" },
363            entries
364        )
365    }
366    #[cfg(not(feature = "embeddings"))]
367    {
368        let _ = project_root;
369        "ERR: embeddings feature not enabled".to_string()
370    }
371}
372
373fn handle_embeddings_reset(project_root: &str) -> String {
374    #[cfg(feature = "embeddings")]
375    {
376        let knowledge = ProjectKnowledge::load_or_create(project_root);
377        match crate::core::knowledge_embedding::reset(&knowledge.project_hash) {
378            Ok(()) => "Embeddings index reset.".to_string(),
379            Err(e) => format!("Embeddings reset failed: {e}"),
380        }
381    }
382    #[cfg(not(feature = "embeddings"))]
383    {
384        let _ = project_root;
385        "ERR: embeddings feature not enabled".to_string()
386    }
387}
388
389fn handle_embeddings_reindex(project_root: &str) -> String {
390    #[cfg(feature = "embeddings")]
391    {
392        let Some(knowledge) = ProjectKnowledge::load(project_root) else {
393            return "No knowledge stored for this project yet.".to_string();
394        };
395        let policy = match load_policy_or_error() {
396            Ok(p) => p,
397            Err(e) => return e,
398        };
399
400        let Some(engine) = embedding_engine() else {
401            return "Embeddings model not available. Set LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD=1 to allow auto-download, then re-run."
402                    .to_string();
403        };
404
405        let mut idx =
406            crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
407
408        let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> =
409            knowledge.facts.iter().filter(|f| f.is_current()).collect();
410        facts.sort_by(|a, b| {
411            b.confidence
412                .partial_cmp(&a.confidence)
413                .unwrap_or(std::cmp::Ordering::Equal)
414                .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
415                .then_with(|| a.category.cmp(&b.category))
416                .then_with(|| a.key.cmp(&b.key))
417        });
418
419        let max = policy.embeddings.max_facts;
420        let mut embedded = 0usize;
421        for f in facts.into_iter().take(max) {
422            if crate::core::knowledge_embedding::embed_and_store(
423                &mut idx,
424                engine,
425                &f.category,
426                &f.key,
427                &f.value,
428            )
429            .is_ok()
430            {
431                embedded += 1;
432            }
433        }
434
435        crate::core::knowledge_embedding::compact_against_knowledge(&mut idx, &knowledge, &policy);
436        match idx.save() {
437            Ok(()) => format!("Embeddings reindex ok (embedded {embedded} facts)."),
438            Err(e) => format!("Embeddings reindex failed: {e}"),
439        }
440    }
441    #[cfg(not(feature = "embeddings"))]
442    {
443        let _ = project_root;
444        "ERR: embeddings feature not enabled".to_string()
445    }
446}
447
448fn handle_remember(
449    project_root: &str,
450    category: Option<&str>,
451    key: Option<&str>,
452    value: Option<&str>,
453    session_id: &str,
454    confidence: Option<f32>,
455) -> String {
456    let Some(cat) = category else {
457        return "Error: category is required for remember".to_string();
458    };
459    let Some(k) = key else {
460        return "Error: key is required for remember".to_string();
461    };
462    let Some(v) = value else {
463        return "Error: value is required for remember".to_string();
464    };
465    let conf = confidence.unwrap_or(0.8);
466    let (v, _secret_matches) = crate::core::secret_detection::scan_and_redact_from_config(v);
467    let v = v.as_str();
468    let policy = match load_policy_or_error() {
469        Ok(p) => p,
470        Err(e) => return e,
471    };
472    // Serialize the read-modify-write under a per-project lock so parallel
473    // `remember` calls cannot clobber each other (issue #326). The closure
474    // operates on the freshly (re)loaded state inside the lock.
475    let (knowledge, contradiction) = match ProjectKnowledge::mutate_locked(project_root, |kn| {
476        let c = kn.remember(cat, k, v, session_id, conf, &policy);
477        let _ = kn.run_memory_lifecycle(&policy);
478        c
479    }) {
480        Ok(pair) => pair,
481        Err(e) => return format!("Remembered [{cat}] {k}: {v}\n(save failed: {e})"),
482    };
483
484    let current_fact = knowledge
485        .facts
486        .iter()
487        .find(|f| f.category == cat && f.key == k && f.is_current());
488    let rev = current_fact.map_or(1, |f| f.revision_count);
489    let conf_count = current_fact.map_or(1, |f| f.confirmation_count);
490
491    let mut result = if contradiction.is_some() {
492        format!(
493            "Updated [{cat}] {k}: {v} → revision {rev} (previous archived, confidence: {:.0}%)",
494            conf * 100.0
495        )
496    } else if rev > 1 {
497        format!(
498            "Confirmed [{cat}] {k}: {v} (revision {rev}, confirmed {conf_count}x, confidence: {:.0}%)",
499            current_fact.map_or(conf, |f| f.confidence) * 100.0
500        )
501    } else {
502        format!(
503            "Remembered [{cat}] {k}: {v} (revision 1, confidence: {:.0}%)",
504            conf * 100.0
505        )
506    };
507
508    if let Some(c) = &contradiction {
509        result.push_str(&format!("\n⚠ CONTRADICTION: {}", c.resolution));
510    }
511
512    let similar = crate::core::knowledge::find_cross_key_similar(
513        cat,
514        k,
515        v,
516        &knowledge.facts,
517        &knowledge.judged_pairs,
518        3,
519    );
520    if !similar.is_empty() {
521        result.push_str(&format!("\n\nSIMILAR FACTS ({} found):", similar.len()));
522        for sf in &similar {
523            result.push_str(&format!(
524                "\n  {}/{} ({:.0}%) — \"{}\"",
525                sf.category,
526                sf.key,
527                sf.similarity * 100.0,
528                sf.value_preview
529            ));
530        }
531        result.push_str(
532            "\n→ ctx_knowledge(action=\"judge\", key=\"<cat/key>\", value=\"<target_cat/key>\", query=\"supersedes|compatible|unrelated\")"
533        );
534    }
535
536    #[cfg(feature = "embeddings")]
537    {
538        if let Some(engine) = embedding_engine() {
539            let mut idx = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
540                &knowledge.project_hash,
541            )
542            .unwrap_or_else(|| {
543                crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::new(
544                    &knowledge.project_hash,
545                )
546            });
547
548            match crate::core::knowledge_embedding::embed_and_store(&mut idx, engine, cat, k, v) {
549                Ok(()) => {
550                    crate::core::knowledge_embedding::compact_against_knowledge(
551                        &mut idx, &knowledge, &policy,
552                    );
553                    if let Err(e) = idx.save() {
554                        result.push_str(&format!("\n(warn: embeddings save failed: {e})"));
555                    }
556                }
557                Err(e) => {
558                    result.push_str(&format!("\n(warn: embeddings update failed: {e})"));
559                }
560            }
561        }
562    }
563
564    result
565}
566
567fn handle_recall(
568    project_root: &str,
569    category: Option<&str>,
570    query: Option<&str>,
571    session_id: &str,
572    mode: Option<&str>,
573) -> String {
574    let Some(mut knowledge) = ProjectKnowledge::load(project_root) else {
575        return "No knowledge stored for this project yet.".to_string();
576    };
577    let policy = match load_policy_or_error() {
578        Ok(p) => p,
579        Err(e) => return e,
580    };
581
582    if let Some(cat) = category {
583        let limit = policy.knowledge.recall_facts_limit;
584        let (facts, total) = knowledge.recall_by_category_for_output(cat, limit);
585        if facts.is_empty() || total == 0 {
586            // System 2: archive rehydrate (category-only)
587            let rehydrated =
588                rehydrate_from_archives(&mut knowledge, Some(cat), None, session_id, &policy);
589            if rehydrated {
590                let (facts2, total2) = knowledge.recall_by_category_for_output(cat, limit);
591                if !facts2.is_empty() && total2 > 0 {
592                    let out2 = format_facts_with_annotations(
593                        &facts2,
594                        total2,
595                        Some(cat),
596                        &knowledge.judged_pairs,
597                    );
598                    save_knowledge_deferred(knowledge);
599                    return out2;
600                }
601            }
602            return format!("No facts in category '{cat}'.");
603        }
604        let out = format_facts_with_annotations(&facts, total, Some(cat), &knowledge.judged_pairs);
605        save_knowledge_deferred(knowledge);
606        return out;
607    }
608
609    if let Some(q) = query {
610        let mode = mode.unwrap_or("auto").trim().to_lowercase();
611        #[cfg(feature = "embeddings")]
612        {
613            // Use non-blocking engine access for auto/hybrid: never block recall
614            // waiting for model load. Only explicit "semantic" mode may block.
615            let engine_opt = if mode == "semantic" {
616                embedding_engine()
617            } else {
618                embedding_engine_nonblocking()
619            };
620            if let Some(engine) = engine_opt {
621                if let Some(idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
622                    &knowledge.project_hash,
623                ) {
624                    let limit = policy.knowledge.recall_facts_limit;
625                    if mode == "semantic" {
626                        let scored =
627                            crate::core::knowledge_embedding::semantic_recall_semantic_only(
628                                &knowledge, &idx, engine, q, limit,
629                            );
630                        if scored.is_empty() {
631                            return format!("No semantic facts matching '{q}'.");
632                        }
633                        let hits: Vec<SemanticHit> = scored
634                            .iter()
635                            .map(|s| SemanticHit {
636                                category: s.fact.category.clone(),
637                                key: s.fact.key.clone(),
638                                value: s.fact.value.clone(),
639                                score: s.score,
640                                semantic_score: s.semantic_score,
641                                confidence_score: s.confidence_score,
642                            })
643                            .collect();
644                        apply_retrieval_signals_from_hits(&mut knowledge, &hits);
645                        let out = format_semantic_facts(&format!("{q} (mode=semantic)"), &hits);
646                        save_knowledge_deferred(knowledge);
647                        return out;
648                    }
649
650                    if mode == "hybrid" || mode == "auto" {
651                        let scored = crate::core::knowledge_embedding::semantic_recall(
652                            &knowledge, &idx, engine, q, limit,
653                        );
654                        if !scored.is_empty() {
655                            let hits: Vec<SemanticHit> = scored
656                                .iter()
657                                .map(|s| SemanticHit {
658                                    category: s.fact.category.clone(),
659                                    key: s.fact.key.clone(),
660                                    value: s.fact.value.clone(),
661                                    score: s.score,
662                                    semantic_score: s.semantic_score,
663                                    confidence_score: s.confidence_score,
664                                })
665                                .collect();
666                            apply_retrieval_signals_from_hits(&mut knowledge, &hits);
667                            let out = format_semantic_facts(&format!("{q} (mode=hybrid)"), &hits);
668                            save_knowledge_deferred(knowledge);
669                            return out;
670                        }
671                    }
672                }
673            }
674        }
675
676        if mode == "semantic" {
677            return "Semantic recall requires embeddings. Run ctx_knowledge(action=\"embeddings_reindex\") and ensure embeddings are enabled.".to_string();
678        }
679
680        let limit = policy.knowledge.recall_facts_limit;
681        let (facts, total) = knowledge.recall_for_output(q, limit);
682        if facts.is_empty() || total == 0 {
683            // System 2: archive rehydrate (query)
684            let rehydrated =
685                rehydrate_from_archives(&mut knowledge, None, Some(q), session_id, &policy);
686            if rehydrated {
687                let (facts2, total2) = knowledge.recall_for_output(q, limit);
688                if !facts2.is_empty() && total2 > 0 {
689                    let out2 = format_facts_with_annotations(
690                        &facts2,
691                        total2,
692                        None,
693                        &knowledge.judged_pairs,
694                    );
695                    save_knowledge_deferred(knowledge);
696                    return out2;
697                }
698            }
699            return format!("No facts matching '{q}'.");
700        }
701        let out = format_facts_with_annotations(&facts, total, None, &knowledge.judged_pairs);
702        save_knowledge_deferred(knowledge);
703        return out;
704    }
705
706    "Error: provide query or category for recall".to_string()
707}
708
709/// Persist knowledge to disk on a background thread so recall returns immediately.
710/// Retrieval signals (retrieval_count, last_retrieved) are best-effort metadata;
711/// losing them on crash is acceptable.
712fn save_knowledge_deferred(knowledge: ProjectKnowledge) {
713    std::thread::Builder::new()
714        .name("knowledge-save".into())
715        .spawn(move || {
716            let _ = knowledge.save();
717        })
718        .ok();
719}
720
721fn rehydrate_from_archives(
722    knowledge: &mut ProjectKnowledge,
723    category: Option<&str>,
724    query: Option<&str>,
725    session_id: &str,
726    policy: &MemoryPolicy,
727) -> bool {
728    let mut archives = crate::core::memory_lifecycle::list_archives();
729    if archives.is_empty() {
730        return false;
731    }
732    archives.sort();
733    let max_archives = crate::core::budgets::KNOWLEDGE_REHYDRATE_MAX_ARCHIVES;
734    if archives.len() > max_archives {
735        archives = archives[archives.len() - max_archives..].to_vec();
736    }
737
738    let terms: Vec<String> = query
739        .unwrap_or("")
740        .to_lowercase()
741        .split_whitespace()
742        .filter(|t| !t.is_empty())
743        .map(std::string::ToString::to_string)
744        .collect();
745
746    #[derive(Clone)]
747    struct Cand {
748        category: String,
749        key: String,
750        value: String,
751        confidence: f32,
752        score: f32,
753    }
754
755    let mut cands: Vec<Cand> = Vec::new();
756
757    let rehydrate_deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
758    for p in &archives {
759        if std::time::Instant::now() >= rehydrate_deadline {
760            tracing::warn!("ctx_knowledge: rehydrate time budget (10s) exceeded, stopping early");
761            break;
762        }
763        let p_str = p.to_string_lossy().to_string();
764        let Ok(facts) = crate::core::memory_lifecycle::restore_archive(&p_str) else {
765            continue;
766        };
767        for f in facts {
768            if let Some(cat) = category {
769                if f.category != cat {
770                    continue;
771                }
772            }
773            if terms.is_empty() {
774                cands.push(Cand {
775                    category: f.category,
776                    key: f.key,
777                    value: f.value,
778                    confidence: f.confidence,
779                    score: f.confidence,
780                });
781            } else {
782                let searchable = format!(
783                    "{} {} {} {}",
784                    f.category.to_lowercase(),
785                    f.key.to_lowercase(),
786                    f.value.to_lowercase(),
787                    f.source_session.to_lowercase()
788                );
789                let match_count = terms.iter().filter(|t| searchable.contains(*t)).count();
790                if match_count == 0 {
791                    continue;
792                }
793                let rel = match_count as f32 / terms.len() as f32;
794                let score = rel * f.confidence;
795                cands.push(Cand {
796                    category: f.category,
797                    key: f.key,
798                    value: f.value,
799                    confidence: f.confidence,
800                    score,
801                });
802            }
803        }
804    }
805
806    if cands.is_empty() {
807        return false;
808    }
809
810    cands.sort_by(|a, b| {
811        b.score
812            .partial_cmp(&a.score)
813            .unwrap_or(std::cmp::Ordering::Equal)
814            .then_with(|| {
815                b.confidence
816                    .partial_cmp(&a.confidence)
817                    .unwrap_or(std::cmp::Ordering::Equal)
818            })
819            .then_with(|| a.category.cmp(&b.category))
820            .then_with(|| a.key.cmp(&b.key))
821            .then_with(|| a.value.cmp(&b.value))
822    });
823    cands.truncate(crate::core::budgets::KNOWLEDGE_REHYDRATE_LIMIT);
824
825    let mut any = false;
826    for c in &cands {
827        knowledge.remember(
828            &c.category,
829            &c.key,
830            &c.value,
831            session_id,
832            c.confidence.max(0.6),
833            policy,
834        );
835        any = true;
836    }
837    if any {
838        let _ = knowledge.run_memory_lifecycle(policy);
839    }
840    any
841}
842
843fn handle_pattern(
844    project_root: &str,
845    pattern_type: Option<&str>,
846    value: Option<&str>,
847    examples: Option<Vec<String>>,
848    session_id: &str,
849) -> String {
850    let Some(pt) = pattern_type else {
851        return "Error: pattern_type is required".to_string();
852    };
853    let Some(desc) = value else {
854        return "Error: value (description) is required for pattern".to_string();
855    };
856    let exs = examples.unwrap_or_default();
857    let policy = match crate::core::config::Config::load().memory_policy_effective() {
858        Ok(p) => p,
859        Err(e) => {
860            let path = crate::core::config::Config::path().map_or_else(
861                || "~/.lean-ctx/config.toml".to_string(),
862                |p| p.display().to_string(),
863            );
864            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
865        }
866    };
867    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
868    knowledge.add_pattern(pt, desc, exs, session_id, &policy);
869    match knowledge.save() {
870        Ok(()) => format!("Pattern [{pt}] added: {desc}"),
871        Err(e) => format!("Pattern added but save failed: {e}"),
872    }
873}
874
875fn handle_status(project_root: &str) -> String {
876    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
877        return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
878    };
879
880    let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
881    let archived_facts = knowledge.facts.len() - current_facts;
882
883    let mut out = format!(
884        "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
885        current_facts,
886        archived_facts,
887        knowledge.patterns.len(),
888        knowledge.history.len()
889    );
890    out.push_str(&format!(
891        "Last updated: {}\n",
892        knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
893    ));
894
895    let rooms = knowledge.list_rooms();
896    if !rooms.is_empty() {
897        out.push_str("Rooms: ");
898        let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
899        out.push_str(&room_strs.join(", "));
900        out.push('\n');
901    }
902
903    out.push_str(&knowledge.format_summary());
904    out
905}
906
907fn handle_health(project_root: &str) -> String {
908    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
909        return "No knowledge stored. Nothing to report.".to_string();
910    };
911
912    let total = knowledge.facts.len();
913    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
914    let archived = total - current.len();
915
916    let mut low_quality = 0u32;
917    let mut high_quality = 0u32;
918    let mut stale_candidates = 0u32;
919    let mut total_quality: f32 = 0.0;
920    let mut never_retrieved = 0u32;
921    let mut room_counts: std::collections::HashMap<String, (u32, f32)> =
922        std::collections::HashMap::new();
923
924    let now = chrono::Utc::now();
925    for f in &current {
926        let q = f.quality_score();
927        total_quality += q;
928        if q < 0.4 {
929            low_quality += 1;
930        } else if q >= 0.8 {
931            high_quality += 1;
932        }
933        if f.retrieval_count == 0 {
934            never_retrieved += 1;
935        }
936        let age_days = (now - f.created_at).num_days();
937        if age_days > 30 && f.retrieval_count == 0 {
938            stale_candidates += 1;
939        }
940
941        let entry = room_counts.entry(f.category.clone()).or_insert((0, 0.0));
942        entry.0 += 1;
943        entry.1 += q;
944    }
945
946    let avg_quality = if current.is_empty() {
947        0.0
948    } else {
949        total_quality / current.len() as f32
950    };
951
952    let mut out = String::from("=== Knowledge Health Report ===\n");
953    out.push_str(&format!(
954        "Total: {} facts ({} active, {} archived)\n",
955        total,
956        current.len(),
957        archived
958    ));
959    out.push_str(&format!("Avg Quality: {avg_quality:.2}\n"));
960    out.push_str(&format!(
961        "Distribution: {high_quality} high (>=0.8) | {low_quality} low (<0.4)\n"
962    ));
963    out.push_str(&format!(
964        "Stale (>30d, never retrieved): {stale_candidates}\n"
965    ));
966    out.push_str(&format!("Never retrieved: {never_retrieved}\n"));
967
968    if !room_counts.is_empty() {
969        out.push_str("\nRoom Balance:\n");
970        let mut rooms: Vec<_> = room_counts.into_iter().collect();
971        rooms.sort_by_key(|x| std::cmp::Reverse(x.1 .0));
972        for (cat, (count, total_q)) in &rooms {
973            let avg = if *count > 0 {
974                total_q / *count as f32
975            } else {
976                0.0
977            };
978            out.push_str(&format!("  {cat}: {count} facts, avg quality {avg:.2}\n"));
979        }
980    }
981
982    let policy = crate::core::config::Config::load()
983        .memory_policy_effective()
984        .unwrap_or_default();
985    out.push_str(&format!(
986        "\nPolicy: max {} facts, max {} patterns\n",
987        policy.knowledge.max_facts, policy.knowledge.max_patterns
988    ));
989
990    if current.len() > policy.knowledge.max_facts {
991        out.push_str(&format!(
992            "WARNING: Active facts ({}) exceed policy max ({})\n",
993            current.len(),
994            policy.knowledge.max_facts
995        ));
996    }
997
998    out
999}
1000
1001fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
1002    let Some(cat) = category else {
1003        return "Error: category is required for remove".to_string();
1004    };
1005    let Some(k) = key else {
1006        return "Error: key is required for remove".to_string();
1007    };
1008    let policy = match crate::core::config::Config::load().memory_policy_effective() {
1009        Ok(p) => p,
1010        Err(e) => {
1011            let path = crate::core::config::Config::path().map_or_else(
1012                || "~/.lean-ctx/config.toml".to_string(),
1013                |p| p.display().to_string(),
1014            );
1015            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
1016        }
1017    };
1018    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
1019    if knowledge.remove_fact(cat, k) {
1020        let _ = knowledge.run_memory_lifecycle(&policy);
1021
1022        #[cfg(feature = "embeddings")]
1023        {
1024            if let Some(mut idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
1025                &knowledge.project_hash,
1026            ) {
1027                idx.remove(cat, k);
1028                crate::core::knowledge_embedding::compact_against_knowledge(
1029                    &mut idx, &knowledge, &policy,
1030                );
1031                let _ = idx.save();
1032            }
1033        }
1034
1035        match knowledge.save() {
1036            Ok(()) => format!("Removed [{cat}] {k}"),
1037            Err(e) => format!("Removed but save failed: {e}"),
1038        }
1039    } else {
1040        format!("No fact found: [{cat}] {k}")
1041    }
1042}
1043
1044fn handle_export(project_root: &str) -> String {
1045    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1046        return "No knowledge to export.".to_string();
1047    };
1048    let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
1049        Ok(d) => d,
1050        Err(e) => return format!("Export failed: {e}"),
1051    };
1052
1053    let export_dir = data_dir.join("exports").join("knowledge");
1054    let ts = Utc::now().format("%Y%m%d-%H%M%S");
1055    let filename = format!(
1056        "knowledge-{}-{ts}.json",
1057        short_hash(&knowledge.project_hash)
1058    );
1059    let path = export_dir.join(filename);
1060
1061    match serde_json::to_string_pretty(&knowledge) {
1062        Ok(mut json) => {
1063            json.push('\n');
1064            match crate::config_io::write_atomic_with_backup(&path, &json) {
1065                Ok(()) => format!(
1066                    "Export saved: {} (active facts: {}, patterns: {}, history: {})",
1067                    path.display(),
1068                    knowledge.facts.iter().filter(|f| f.is_current()).count(),
1069                    knowledge.patterns.len(),
1070                    knowledge.history.len()
1071                ),
1072                Err(e) => format!("Export failed: {e}"),
1073            }
1074        }
1075        Err(e) => format!("Export failed: {e}"),
1076    }
1077}
1078
1079fn handle_consolidate(project_root: &str) -> String {
1080    let Some(session) = SessionState::load_latest() else {
1081        return "No active session to consolidate.".to_string();
1082    };
1083    let policy = match crate::core::config::Config::load().memory_policy_effective() {
1084        Ok(p) => p,
1085        Err(e) => {
1086            let path = crate::core::config::Config::path().map_or_else(
1087                || "~/.lean-ctx/config.toml".to_string(),
1088                |p| p.display().to_string(),
1089            );
1090            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
1091        }
1092    };
1093
1094    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
1095    let mut consolidated = 0u32;
1096
1097    for finding in &session.findings {
1098        let key_text = if let Some(ref file) = finding.file {
1099            if let Some(line) = finding.line {
1100                format!("{file}:{line}")
1101            } else {
1102                file.clone()
1103            }
1104        } else {
1105            format!("finding-{consolidated}")
1106        };
1107
1108        knowledge.remember(
1109            "finding",
1110            &key_text,
1111            &finding.summary,
1112            &session.id,
1113            0.7,
1114            &policy,
1115        );
1116        consolidated += 1;
1117    }
1118
1119    for decision in &session.decisions {
1120        let key_text = decision
1121            .summary
1122            .chars()
1123            .take(50)
1124            .collect::<String>()
1125            .replace(' ', "-")
1126            .to_lowercase();
1127
1128        knowledge.remember(
1129            "decision",
1130            &key_text,
1131            &decision.summary,
1132            &session.id,
1133            0.85,
1134            &policy,
1135        );
1136        consolidated += 1;
1137    }
1138
1139    let task_desc = session
1140        .task
1141        .as_ref()
1142        .map_or_else(|| "(no task)".into(), |t| t.description.clone());
1143
1144    let summary = format!(
1145        "Session {}: {} — {} findings, {} decisions consolidated",
1146        session.id,
1147        task_desc,
1148        session.findings.len(),
1149        session.decisions.len()
1150    );
1151    knowledge.consolidate(&summary, vec![session.id.clone()], &policy);
1152    let _ = knowledge.run_memory_lifecycle(&policy);
1153
1154    match knowledge.save() {
1155        Ok(()) => format!(
1156            "Consolidated {consolidated} items from session {} into project knowledge.\n\
1157             Facts: {}, Patterns: {}, History: {}",
1158            session.id,
1159            knowledge.facts.len(),
1160            knowledge.patterns.len(),
1161            knowledge.history.len()
1162        ),
1163        Err(e) => format!("Consolidation done but save failed: {e}"),
1164    }
1165}
1166
1167fn handle_timeline(project_root: &str, category: Option<&str>) -> String {
1168    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1169        return "No knowledge stored yet.".to_string();
1170    };
1171
1172    let policy = match load_policy_or_error() {
1173        Ok(p) => p,
1174        Err(e) => return e,
1175    };
1176
1177    let Some(cat) = category else {
1178        return "Error: category is required for timeline".to_string();
1179    };
1180
1181    let facts = knowledge.timeline(cat);
1182    if facts.is_empty() {
1183        return format!("No history for category '{cat}'.");
1184    }
1185
1186    let mut ordered: Vec<&crate::core::knowledge::KnowledgeFact> = facts;
1187    ordered.sort_by(|a, b| {
1188        let a_start = a.valid_from.unwrap_or(a.created_at);
1189        let b_start = b.valid_from.unwrap_or(b.created_at);
1190        a_start
1191            .cmp(&b_start)
1192            .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
1193            .then_with(|| a.key.cmp(&b.key))
1194            .then_with(|| a.value.cmp(&b.value))
1195    });
1196
1197    let total = ordered.len();
1198    let limit = policy.knowledge.timeline_limit;
1199    if ordered.len() > limit {
1200        ordered = ordered[ordered.len() - limit..].to_vec();
1201    }
1202
1203    let mut out = format!(
1204        "Timeline [{cat}] (showing {}/{} entries):\n",
1205        ordered.len(),
1206        total
1207    );
1208    for f in &ordered {
1209        let status = if f.is_current() {
1210            "CURRENT"
1211        } else {
1212            "archived"
1213        };
1214        let valid_range = match (f.valid_from, f.valid_until) {
1215            (Some(from), Some(until)) => format!(
1216                "{} → {}",
1217                from.format("%Y-%m-%d %H:%M"),
1218                until.format("%Y-%m-%d %H:%M")
1219            ),
1220            (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
1221            _ => "unknown".to_string(),
1222        };
1223        out.push_str(&format!(
1224            "  {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
1225            f.key,
1226            f.value,
1227            f.confidence * 100.0,
1228            f.confirmation_count
1229        ));
1230    }
1231    out
1232}
1233
1234fn handle_rooms(project_root: &str) -> String {
1235    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1236        return "No knowledge stored yet.".to_string();
1237    };
1238
1239    let policy = match load_policy_or_error() {
1240        Ok(p) => p,
1241        Err(e) => return e,
1242    };
1243
1244    let rooms = knowledge.list_rooms();
1245    if rooms.is_empty() {
1246        return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
1247    }
1248
1249    let mut rooms = rooms;
1250    rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1251    let total = rooms.len();
1252    rooms.truncate(policy.knowledge.rooms_limit);
1253
1254    let mut out = format!(
1255        "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
1256        rooms.len(),
1257        total,
1258        short_hash(&knowledge.project_hash)
1259    );
1260    for (cat, count) in &rooms {
1261        out.push_str(&format!("  [{cat}] {count} fact(s)\n"));
1262    }
1263    out
1264}
1265
1266fn handle_search(query: Option<&str>) -> String {
1267    let Some(q) = query else {
1268        return "Error: query is required for search".to_string();
1269    };
1270
1271    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1272        return "Cannot determine data directory.".to_string();
1273    };
1274
1275    let sessions_dir = data_dir.join("sessions");
1276
1277    if !sessions_dir.exists() {
1278        return "No sessions found.".to_string();
1279    }
1280
1281    let knowledge_dir = data_dir.join("knowledge");
1282
1283    let allow_cross_project = {
1284        let role = crate::core::roles::active_role();
1285        role.io.allow_cross_project_search
1286    };
1287
1288    let current_project_hash = std::env::current_dir()
1289        .ok()
1290        .map(|p| crate::core::project_hash::hash_project_root(&p.to_string_lossy()));
1291
1292    let q_lower = q.to_lowercase();
1293    let terms: Vec<&str> = q_lower.split_whitespace().collect();
1294    let mut results = Vec::new();
1295
1296    if knowledge_dir.exists() {
1297        if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
1298            for entry in entries.flatten() {
1299                let dir_name = entry.file_name().to_string_lossy().to_string();
1300
1301                if !allow_cross_project {
1302                    if let Some(ref current_hash) = current_project_hash {
1303                        if &dir_name != current_hash {
1304                            continue;
1305                        }
1306                    }
1307                }
1308
1309                if let Some(ref current_hash) = current_project_hash {
1310                    if dir_name != *current_hash {
1311                        let policy = crate::core::config::Config::load().boundary_policy;
1312                        let allowed = crate::core::memory_boundary::check_boundary(
1313                            current_hash,
1314                            &dir_name,
1315                            &policy,
1316                            &crate::core::memory_boundary::CrossProjectEventType::Search,
1317                        );
1318                        crate::core::memory_boundary::record_audit_event(
1319                            &crate::core::memory_boundary::CrossProjectAuditEvent {
1320                                timestamp: Utc::now().to_rfc3339(),
1321                                event_type:
1322                                    crate::core::memory_boundary::CrossProjectEventType::Search,
1323                                source_project_hash: current_hash.clone(),
1324                                target_project_hash: dir_name.clone(),
1325                                tool: "ctx_knowledge".to_string(),
1326                                action: "search".to_string(),
1327                                facts_accessed: 0,
1328                                allowed,
1329                                policy_reason: if allowed {
1330                                    "boundary_policy_allowed".to_string()
1331                                } else {
1332                                    "boundary_policy_denied".to_string()
1333                                },
1334                            },
1335                        );
1336                        if !allowed {
1337                            continue;
1338                        }
1339                    }
1340                }
1341
1342                let knowledge_file = entry.path().join("knowledge.json");
1343                if let Ok(content) = std::fs::read_to_string(&knowledge_file) {
1344                    if let Ok(knowledge) = serde_json::from_str::<ProjectKnowledge>(&content) {
1345                        let is_foreign = current_project_hash
1346                            .as_ref()
1347                            .is_some_and(|h| h != &knowledge.project_hash);
1348
1349                        for fact in &knowledge.facts {
1350                            if is_foreign
1351                                && fact.privacy
1352                                    == crate::core::memory_boundary::FactPrivacy::ProjectOnly
1353                            {
1354                                continue;
1355                            }
1356
1357                            let searchable = format!(
1358                                "{} {} {}",
1359                                fact.category.to_lowercase(),
1360                                fact.key.to_lowercase(),
1361                                fact.value.to_lowercase()
1362                            );
1363                            let match_count =
1364                                terms.iter().filter(|t| searchable.contains(**t)).count();
1365                            if match_count > 0 {
1366                                results.push((
1367                                    knowledge.project_root.clone(),
1368                                    fact.category.clone(),
1369                                    fact.key.clone(),
1370                                    fact.value.clone(),
1371                                    fact.confidence,
1372                                    match_count as f32 / terms.len() as f32,
1373                                ));
1374                            }
1375                        }
1376                    }
1377                }
1378            }
1379        }
1380    }
1381
1382    if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
1383        for entry in entries.flatten() {
1384            let path = entry.path();
1385            if path.extension().and_then(|e| e.to_str()) != Some("json") {
1386                continue;
1387            }
1388            if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1389                continue;
1390            }
1391            if let Ok(json) = std::fs::read_to_string(&path) {
1392                if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1393                    for finding in &session.findings {
1394                        let searchable = finding.summary.to_lowercase();
1395                        let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
1396                        if match_count > 0 {
1397                            let project = session
1398                                .project_root
1399                                .clone()
1400                                .unwrap_or_else(|| "unknown".to_string());
1401                            results.push((
1402                                project,
1403                                "session-finding".to_string(),
1404                                session.id.clone(),
1405                                finding.summary.clone(),
1406                                0.6,
1407                                match_count as f32 / terms.len() as f32,
1408                            ));
1409                        }
1410                    }
1411                    for decision in &session.decisions {
1412                        let searchable = decision.summary.to_lowercase();
1413                        let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
1414                        if match_count > 0 {
1415                            let project = session
1416                                .project_root
1417                                .clone()
1418                                .unwrap_or_else(|| "unknown".to_string());
1419                            results.push((
1420                                project,
1421                                "session-decision".to_string(),
1422                                session.id.clone(),
1423                                decision.summary.clone(),
1424                                0.7,
1425                                match_count as f32 / terms.len() as f32,
1426                            ));
1427                        }
1428                    }
1429                }
1430            }
1431        }
1432    }
1433
1434    if results.is_empty() {
1435        return format!("No results found for '{q}' across all sessions and projects.");
1436    }
1437
1438    results.sort_by(|a, b| {
1439        b.5.partial_cmp(&a.5)
1440            .unwrap_or(std::cmp::Ordering::Equal)
1441            .then_with(|| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal))
1442            .then_with(|| a.0.cmp(&b.0))
1443            .then_with(|| a.1.cmp(&b.1))
1444            .then_with(|| a.2.cmp(&b.2))
1445            .then_with(|| a.3.cmp(&b.3))
1446    });
1447    results.truncate(crate::core::budgets::KNOWLEDGE_CROSS_PROJECT_SEARCH_LIMIT);
1448
1449    let mut out = format!("Cross-session search '{q}' ({} results):\n", results.len());
1450    for (project, cat, key, value, conf, _relevance) in &results {
1451        let project_short = short_path(project);
1452        out.push_str(&format!(
1453            "  [{cat}/{key}] {value} (project: {project_short}, conf: {:.0}%)\n",
1454            conf * 100.0
1455        ));
1456    }
1457    out
1458}
1459
1460fn handle_cognition_loop(project_root: &str) -> String {
1461    let cfg = crate::core::config::Config::load().autonomy;
1462    if !cfg.cognition_loop_enabled {
1463        return "Cognition loop is disabled (autonomy.cognition_loop_enabled=false).".to_string();
1464    }
1465    let max_steps = cfg.cognition_loop_max_steps;
1466    let report = crate::core::cognition_loop::run_cognition_loop(project_root, max_steps);
1467    format!("{report}")
1468}
1469
1470fn handle_bridge_publish(project_root: &str, session_id: &str) -> String {
1471    let knowledge = ProjectKnowledge::load_or_create(project_root);
1472    let mut bridge =
1473        crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1474    let count = bridge.publish(session_id, &knowledge.facts);
1475    match bridge.save() {
1476        Ok(()) => format!(
1477            "Published {count} fact(s) to bridge (total: {}, agent: {session_id})",
1478            bridge.shared_facts.len()
1479        ),
1480        Err(e) => format!("Published {count} fact(s) but save failed: {e}"),
1481    }
1482}
1483
1484fn handle_bridge_pull(project_root: &str, session_id: &str) -> String {
1485    let knowledge = ProjectKnowledge::load_or_create(project_root);
1486    let bridge =
1487        crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1488    let entries = bridge.pull(session_id);
1489    if entries.is_empty() {
1490        return "No facts available from other agents.".to_string();
1491    }
1492
1493    let policy = match load_policy_or_error() {
1494        Ok(p) => p,
1495        Err(e) => return e,
1496    };
1497
1498    let mut target = knowledge;
1499    let mut imported = 0u32;
1500    for entry in &entries {
1501        let fact = crate::core::knowledge_bridge::KnowledgeBridge::entry_to_fact(entry);
1502        let existing = target
1503            .facts
1504            .iter()
1505            .any(|f| f.is_current() && f.category == fact.category && f.key == fact.key);
1506        if !existing {
1507            target.remember(
1508                &fact.category,
1509                &fact.key,
1510                &fact.value,
1511                session_id,
1512                fact.confidence,
1513                &policy,
1514            );
1515            imported += 1;
1516        }
1517    }
1518
1519    if imported == 0 {
1520        return format!(
1521            "Bridge has {} fact(s) from other agents, but all already exist locally.",
1522            entries.len()
1523        );
1524    }
1525
1526    match target.save() {
1527        Ok(()) => format!(
1528            "Pulled {imported}/{} fact(s) from bridge into local knowledge.",
1529            entries.len()
1530        ),
1531        Err(e) => format!("Pulled {imported} fact(s) but save failed: {e}"),
1532    }
1533}
1534
1535fn handle_bridge_status(project_root: &str) -> String {
1536    let knowledge = ProjectKnowledge::load_or_create(project_root);
1537    let bridge =
1538        crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1539    bridge.summary()
1540}
1541
1542fn handle_wakeup(project_root: &str) -> String {
1543    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1544        return "No knowledge for wake-up briefing.".to_string();
1545    };
1546    let aaak = knowledge.format_aaak();
1547    if aaak.is_empty() {
1548        return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
1549    }
1550    format!("WAKE-UP BRIEFING:\n{aaak}")
1551}
1552
1553#[cfg(feature = "embeddings")]
1554struct SemanticHit {
1555    category: String,
1556    key: String,
1557    value: String,
1558    score: f32,
1559    semantic_score: f32,
1560    confidence_score: f32,
1561}
1562
1563#[cfg(feature = "embeddings")]
1564fn apply_retrieval_signals_from_hits(knowledge: &mut ProjectKnowledge, hits: &[SemanticHit]) {
1565    let now = Utc::now();
1566    for s in hits {
1567        for f in &mut knowledge.facts {
1568            if !f.is_current() {
1569                continue;
1570            }
1571            if f.category == s.category && f.key == s.key {
1572                f.retrieval_count = f.retrieval_count.saturating_add(1);
1573                f.last_retrieved = Some(now);
1574                break;
1575            }
1576        }
1577    }
1578}
1579
1580#[cfg(feature = "embeddings")]
1581fn format_semantic_facts(query: &str, hits: &[SemanticHit]) -> String {
1582    if hits.is_empty() {
1583        return format!("No facts matching '{query}'.");
1584    }
1585    let mut out = format!("Semantic recall '{query}' (showing {}):\n", hits.len());
1586    for s in hits {
1587        out.push_str(&format!(
1588            "  [{}/{}]: {} (score: {:.0}%, sem: {:.0}%, conf: {:.0}%)\n",
1589            s.category,
1590            s.key,
1591            s.value,
1592            s.score * 100.0,
1593            s.semantic_score * 100.0,
1594            s.confidence_score * 100.0
1595        ));
1596    }
1597    out
1598}
1599
1600fn format_facts_with_annotations(
1601    facts: &[crate::core::knowledge::KnowledgeFact],
1602    total: usize,
1603    category: Option<&str>,
1604    judged_pairs: &[crate::core::knowledge::JudgedPair],
1605) -> String {
1606    let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> = facts.iter().collect();
1607    facts.sort_by(|a, b| sort_fact_for_output(a, b));
1608
1609    let mut out = String::new();
1610    if let Some(cat) = category {
1611        out.push_str(&format!(
1612            "Facts [{cat}] (showing {}/{}):\n",
1613            facts.len(),
1614            total
1615        ));
1616    } else {
1617        out.push_str(&format!(
1618            "Matching facts (showing {}/{}):\n",
1619            facts.len(),
1620            total
1621        ));
1622    }
1623    for f in facts {
1624        let temporal = if f.is_current() { "" } else { " [archived]" };
1625        let rev = if f.revision_count > 1 {
1626            format!(" rev {}", f.revision_count)
1627        } else {
1628            String::new()
1629        };
1630        out.push_str(&format!(
1631            "  [{}/{}]: {} (quality: {:.0}%, confidence: {:.0}%, confirmed: {} x{}){rev}{temporal}\n",
1632            f.category,
1633            f.key,
1634            f.value,
1635            f.quality_score() * 100.0,
1636            f.confidence * 100.0,
1637            f.last_confirmed.format("%Y-%m-%d"),
1638            f.confirmation_count
1639        ));
1640
1641        if !judged_pairs.is_empty() {
1642            let composite = format!("{}/{}", f.category, f.key);
1643            for jp in judged_pairs {
1644                if jp.key_a == composite {
1645                    out.push_str(&format!("    ↳ {} {}\n", jp.verdict, jp.key_b));
1646                } else if jp.key_b == composite && jp.verdict == "supersedes" {
1647                    out.push_str(&format!("    ↳ superseded by {}\n", jp.key_a));
1648                }
1649            }
1650        }
1651    }
1652    out
1653}
1654
1655fn short_path(path: &str) -> String {
1656    let parts: Vec<&str> = path.split('/').collect();
1657    if parts.len() <= 2 {
1658        return path.to_string();
1659    }
1660    parts[parts.len() - 2..].join("/")
1661}
1662
1663fn short_hash(hash: &str) -> &str {
1664    if hash.len() > 8 {
1665        &hash[..8]
1666    } else {
1667        hash
1668    }
1669}
1670
1671fn sort_fact_for_output(
1672    a: &crate::core::knowledge::KnowledgeFact,
1673    b: &crate::core::knowledge::KnowledgeFact,
1674) -> std::cmp::Ordering {
1675    salience_score(b)
1676        .cmp(&salience_score(a))
1677        .then_with(|| {
1678            b.quality_score()
1679                .partial_cmp(&a.quality_score())
1680                .unwrap_or(std::cmp::Ordering::Equal)
1681        })
1682        .then_with(|| {
1683            b.confidence
1684                .partial_cmp(&a.confidence)
1685                .unwrap_or(std::cmp::Ordering::Equal)
1686        })
1687        .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
1688        .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
1689        .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
1690        .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
1691        .then_with(|| a.category.cmp(&b.category))
1692        .then_with(|| a.key.cmp(&b.key))
1693        .then_with(|| a.value.cmp(&b.value))
1694}
1695
1696fn salience_score(f: &crate::core::knowledge::KnowledgeFact) -> u32 {
1697    let cat = f.category.to_lowercase();
1698    let base: u32 = match cat.as_str() {
1699        "decision" => 70,
1700        "gotcha" => 75,
1701        "architecture" | "arch" => 60,
1702        "security" => 65,
1703        "testing" | "tests" | "deployment" | "deploy" => 55,
1704        "conventions" | "convention" => 45,
1705        "finding" => 40,
1706        _ => 30,
1707    };
1708
1709    let quality_bonus = (f.quality_score() * 60.0) as u32;
1710    let recency_bonus = f.last_retrieved.map_or(0u32, |t| {
1711        let days = chrono::Utc::now().signed_duration_since(t).num_days();
1712        if days <= 7 {
1713            10u32
1714        } else if days <= 30 {
1715            5u32
1716        } else {
1717            0u32
1718        }
1719    });
1720
1721    base + quality_bonus + recency_bonus
1722}