Skip to main content

lean_ctx/tools/ctx_knowledge/
mod.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;
9pub(crate) mod embeddings;
10pub(crate) use embeddings::*;
11mod remember;
12pub(crate) use remember::*;
13mod search;
14pub(crate) use search::*;
15
16fn load_policy_or_error() -> Result<MemoryPolicy, String> {
17    super::knowledge_shared::load_policy_or_error()
18}
19
20/// Dispatches knowledge base actions (remember, recall, pattern, timeline, etc.).
21#[allow(clippy::too_many_arguments)]
22pub fn handle(
23    project_root: &str,
24    action: &str,
25    category: Option<&str>,
26    key: Option<&str>,
27    value: Option<&str>,
28    query: Option<&str>,
29    session_id: &str,
30    pattern_type: Option<&str>,
31    examples: Option<Vec<String>>,
32    confidence: Option<f32>,
33    mode: Option<&str>,
34    as_of: Option<&str>,
35) -> String {
36    match action {
37        "policy" => handle_policy(value),
38        "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
39        "recall" => handle_recall(project_root, category, query, session_id, mode, as_of),
40        "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
41        "feedback" => handle_feedback(project_root, category, key, value, session_id),
42        "relate" => crate::tools::ctx_knowledge_relations::handle_relate(
43            project_root,
44            category,
45            key,
46            value,
47            query,
48            session_id,
49        ),
50        "unrelate" => crate::tools::ctx_knowledge_relations::handle_unrelate(
51            project_root,
52            category,
53            key,
54            value,
55            query,
56        ),
57        "relations" => crate::tools::ctx_knowledge_relations::handle_relations(
58            project_root,
59            category,
60            key,
61            value,
62            query,
63        ),
64        "relations_diagram" => crate::tools::ctx_knowledge_relations::handle_relations_diagram(
65            project_root,
66            category,
67            key,
68            value,
69            query,
70        ),
71        "status" => handle_status(project_root),
72        "health" => handle_health(project_root),
73        "lifecycle_report" => handle_lifecycle_report(project_root),
74        "remove" => handle_remove(project_root, category, key),
75        "export" => handle_export(project_root),
76        "consolidate" => handle_consolidate(project_root),
77        "timeline" => handle_timeline(project_root, category),
78        "rooms" => handle_rooms(project_root),
79        "search" => handle_search(query),
80        "wakeup" => handle_wakeup(project_root),
81        "embeddings_status" => handle_embeddings_status(project_root),
82        "embeddings_reset" => handle_embeddings_reset(project_root),
83        "embeddings_reindex" => handle_embeddings_reindex(project_root),
84        "judge" => handle_judge(project_root, category, key, value, query),
85        "cognition_loop" => handle_cognition_loop(project_root),
86        "bridge_publish" => handle_bridge_publish(project_root, session_id),
87        "bridge_pull" => handle_bridge_pull(project_root, session_id),
88        "bridge_status" => handle_bridge_status(project_root),
89        _ => format!(
90            "Unknown action: {action}. Use: policy, remember, recall, pattern, feedback, judge, relate, unrelate, relations, relations_diagram, status, health, lifecycle_report, remove, export, consolidate, timeline, rooms, search, wakeup, embeddings_status, embeddings_reset, embeddings_reindex, cognition_loop, bridge_publish, bridge_pull, bridge_status"
91        ),
92    }
93}
94
95fn handle_policy(value: Option<&str>) -> String {
96    let sub = value.unwrap_or("show").trim().to_lowercase();
97    let profile = crate::core::profiles::active_profile_name();
98
99    match sub.as_str() {
100        "show" => {
101            let policy = match load_policy_or_error() {
102                Ok(p) => p,
103                Err(e) => return e,
104            };
105
106            let cfg_path = crate::core::config::Config::path().map_or_else(
107                || "~/.lean-ctx/config.toml".to_string(),
108                |p| p.display().to_string(),
109            );
110
111            format!(
112                "Knowledge policy (effective, profile={profile}):\n\
113                 - memory.knowledge.max_facts={}\n\
114                 - memory.knowledge.contradiction_threshold={}\n\
115                 - memory.knowledge.recall_facts_limit={}\n\
116                 - memory.knowledge.rooms_limit={}\n\
117                 - memory.knowledge.timeline_limit={}\n\
118                 - memory.knowledge.relations_limit={}\n\
119                 - memory.lifecycle.decay_rate={}\n\
120                 - memory.lifecycle.stale_days={}\n\
121                 \nConfig: {cfg_path}",
122                policy.knowledge.max_facts,
123                policy.knowledge.contradiction_threshold,
124                policy.knowledge.recall_facts_limit,
125                policy.knowledge.rooms_limit,
126                policy.knowledge.timeline_limit,
127                policy.knowledge.relations_limit,
128                policy.lifecycle.decay_rate,
129                policy.lifecycle.stale_days
130            )
131        }
132        "validate" => match load_policy_or_error() {
133            Ok(_) => format!("OK: memory policy valid (profile={profile})"),
134            Err(e) => e,
135        },
136        _ => "Error: policy value must be show|validate".to_string(),
137    }
138}
139
140fn handle_feedback(
141    project_root: &str,
142    category: Option<&str>,
143    key: Option<&str>,
144    value: Option<&str>,
145    session_id: &str,
146) -> String {
147    let Some(cat) = category else {
148        return "Error: category is required for feedback".to_string();
149    };
150    let Some(k) = key else {
151        return "Error: key is required for feedback".to_string();
152    };
153    let dir = value.unwrap_or("up").trim().to_lowercase();
154    let is_up = matches!(dir.as_str(), "up" | "+1" | "+" | "true" | "1");
155    let is_down = matches!(dir.as_str(), "down" | "-1" | "-" | "false" | "0");
156    if !is_up && !is_down {
157        return "Error: feedback value must be up|down (+1|-1)".to_string();
158    }
159
160    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
161    let Some(f) = knowledge
162        .facts
163        .iter_mut()
164        .find(|f| f.is_current() && f.category == cat && f.key == k)
165    else {
166        return format!("No current fact found: [{cat}] {k}");
167    };
168
169    if is_up {
170        f.feedback_up = f.feedback_up.saturating_add(1);
171    } else {
172        f.feedback_down = f.feedback_down.saturating_add(1);
173    }
174    f.last_feedback = Some(Utc::now());
175
176    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
177        category: cat.to_string(),
178        key: k.to_string(),
179        action: if is_up {
180            "feedback_up"
181        } else {
182            "feedback_down"
183        }
184        .to_string(),
185    });
186
187    let quality = f.quality_score();
188    let up = f.feedback_up;
189    let down = f.feedback_down;
190    let conf = f.confidence;
191
192    match knowledge.save() {
193        Ok(()) => format!(
194            "Feedback recorded ({dir}) for [{cat}] {k} (up={up}, down={down}, quality={quality:.2}, confidence={conf:.2}, session={session_id})"
195        ),
196        Err(e) => format!(
197            "Feedback recorded ({dir}) but save failed: {e} (up={up}, down={down}, quality={quality:.2})"
198        ),
199    }
200}
201
202fn handle_judge(
203    project_root: &str,
204    category: Option<&str>,
205    key: Option<&str>,
206    value: Option<&str>,
207    query: Option<&str>,
208) -> String {
209    let source = match (category, key) {
210        (Some(cat), Some(k)) => format!("{cat}/{k}"),
211        _ => {
212            if let Some(k) = key.or(category) {
213                if k.contains('/') {
214                    k.to_string()
215                } else {
216                    return "Error: judge requires key as 'category/key' (source fact)".to_string();
217                }
218            } else {
219                return "Error: judge requires category+key (source fact) and value (target 'category/key')"
220                    .to_string();
221            }
222        }
223    };
224
225    let Some(target) = value else {
226        return "Error: judge requires value as target 'category/key'".to_string();
227    };
228    let target = target.trim().to_string();
229    if !target.contains('/') {
230        return "Error: target must be 'category/key' format".to_string();
231    }
232
233    let verdict = query.unwrap_or("compatible").trim().to_lowercase();
234    if !matches!(verdict.as_str(), "supersedes" | "compatible" | "unrelated") {
235        return format!("Error: verdict must be supersedes|compatible|unrelated, got '{verdict}'");
236    }
237
238    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
239
240    let source_exists = {
241        let parts: Vec<&str> = source.splitn(2, '/').collect();
242        parts.len() == 2
243            && knowledge
244                .facts
245                .iter()
246                .any(|f| f.category == parts[0] && f.key == parts[1] && f.is_current())
247    };
248    if !source_exists {
249        return format!("Error: no current fact found for '{source}'");
250    }
251
252    let target_parts: Vec<&str> = target.splitn(2, '/').collect();
253    if target_parts.len() != 2 {
254        return format!("Error: invalid target format '{target}'");
255    }
256    let (tcat, tkey) = (target_parts[0], target_parts[1]);
257
258    let target_exists = knowledge
259        .facts
260        .iter()
261        .any(|f| f.category == tcat && f.key == tkey && f.is_current());
262    if !target_exists {
263        return format!("Error: no current fact found for '{target}'");
264    }
265
266    if verdict == "supersedes" {
267        let now = Utc::now();
268        if let Some(tf) = knowledge
269            .facts
270            .iter_mut()
271            .find(|f| f.category == tcat && f.key == tkey && f.is_current())
272        {
273            tf.valid_until = Some(now);
274            tf.valid_from = tf.valid_from.or(Some(tf.created_at));
275        }
276    }
277
278    knowledge
279        .judged_pairs
280        .push(crate::core::knowledge::JudgedPair {
281            key_a: source.clone(),
282            key_b: target.clone(),
283            verdict: verdict.clone(),
284            judged_at: Utc::now(),
285        });
286
287    let save_msg = match knowledge.save() {
288        Ok(()) => String::new(),
289        Err(e) => format!(" (save warning: {e})"),
290    };
291
292    let action_desc = match verdict.as_str() {
293        "supersedes" => format!("{source} supersedes {target} (target archived)"),
294        "compatible" => format!("{source} ↔ {target} (compatible, suppressed from future similar)"),
295        "unrelated" => format!("{source} ≠ {target} (unrelated, suppressed from future similar)"),
296        _ => unreachable!(),
297    };
298
299    format!("Judged: {action_desc}{save_msg}")
300}
301
302fn handle_pattern(
303    project_root: &str,
304    pattern_type: Option<&str>,
305    value: Option<&str>,
306    examples: Option<Vec<String>>,
307    session_id: &str,
308) -> String {
309    let Some(pt) = pattern_type else {
310        return "Error: pattern_type is required".to_string();
311    };
312    let Some(desc) = value else {
313        return "Error: value (description) is required for pattern".to_string();
314    };
315    let exs = examples.unwrap_or_default();
316    let policy = match crate::core::config::Config::load().memory_policy_effective() {
317        Ok(p) => p,
318        Err(e) => {
319            let path = crate::core::config::Config::path().map_or_else(
320                || "~/.lean-ctx/config.toml".to_string(),
321                |p| p.display().to_string(),
322            );
323            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
324        }
325    };
326    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
327    knowledge.add_pattern(pt, desc, exs, session_id, &policy);
328    match knowledge.save() {
329        Ok(()) => format!("Pattern [{pt}] added: {desc}"),
330        Err(e) => format!("Pattern added but save failed: {e}"),
331    }
332}
333
334fn handle_status(project_root: &str) -> String {
335    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
336        return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
337    };
338
339    let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
340    let archived_facts = knowledge.facts.len() - current_facts;
341
342    let mut out = format!(
343        "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
344        current_facts,
345        archived_facts,
346        knowledge.patterns.len(),
347        knowledge.history.len()
348    );
349    out.push_str(&format!(
350        "Last updated: {}\n",
351        knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
352    ));
353
354    let rooms = knowledge.list_rooms();
355    if !rooms.is_empty() {
356        out.push_str("Rooms: ");
357        let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
358        out.push_str(&room_strs.join(", "));
359        out.push('\n');
360    }
361
362    out.push_str(&knowledge.format_summary());
363    out
364}
365
366/// Per-layer lifecycle report (GL#445): item counts, effective policies, and
367/// the next enforcement action for every memory layer. Read-only.
368fn handle_lifecycle_report(project_root: &str) -> String {
369    let policy = match load_policy_or_error() {
370        Ok(p) => p,
371        Err(e) => return e,
372    };
373    let hash = crate::core::project_hash::hash_project_root(project_root);
374
375    let mut out = String::from("=== Memory Lifecycle Report ===\n");
376
377    // Knowledge layer (long-term facts, archive-only eviction).
378    match ProjectKnowledge::load(project_root) {
379        Some(k) => {
380            let active = k.facts.iter().filter(|f| f.is_current()).count();
381            let archived = k.facts.len() - active;
382            let cap = policy.knowledge.max_facts;
383            let fill_pct = (active * 100).checked_div(cap).unwrap_or(0);
384            out.push_str(&format!(
385                "knowledge   {active} active / {archived} archived (cap {cap}, {fill_pct}% full)\n\
386                 \x20           decay {}/day, stale >{}d, consolidate-sim {:.2}\n\
387                 \x20           GC: self-limiting on remember when >{cap}; eviction archives, never deletes\n",
388                policy.lifecycle.decay_rate,
389                policy.lifecycle.stale_days,
390                policy.lifecycle.similarity_threshold,
391            ));
392        }
393        None => out.push_str("knowledge   (no store yet)\n"),
394    }
395
396    // Archive files (restorable via recall rehydration).
397    let archives = crate::core::memory_lifecycle::list_archives();
398    out.push_str(&format!(
399        "archives    {} file(s); auto-rehydrated when recall misses\n",
400        archives.len()
401    ));
402
403    // Episodic layer (session episodes).
404    {
405        let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
406        let cap = policy.episodic.max_episodes;
407        out.push_str(&format!(
408            "episodic    {} episode(s) (cap {cap}, {} actions/episode max)\n",
409            store.episodes.len(),
410            policy.episodic.max_actions_per_episode,
411        ));
412    }
413
414    // Procedural layer (learned action sequences).
415    {
416        let store = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
417        out.push_str(&format!(
418            "procedural  {} procedure(s) (cap {}, learned at >={} repetitions)\n",
419            store.procedures.len(),
420            policy.procedural.max_procedures,
421            policy.procedural.min_repetitions,
422        ));
423    }
424
425    // Embeddings layer (semantic index over knowledge facts).
426    #[cfg(feature = "embeddings")]
427    {
428        let n = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(&hash)
429            .map_or(0, |idx| idx.entries.len());
430        out.push_str(&format!(
431            "embeddings  {n} vector(s); compacted against knowledge on remember\n"
432        ));
433    }
434    #[cfg(not(feature = "embeddings"))]
435    out.push_str("embeddings  (feature disabled in this build)\n");
436
437    out.push_str(
438        "\nLayer boundaries: session = working memory (now) | knowledge/episodic/procedural = long-term (ETL via consolidate) | providers = external (read-through)\n",
439    );
440    out
441}
442
443fn handle_health(project_root: &str) -> String {
444    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
445        return "No knowledge stored. Nothing to report.".to_string();
446    };
447
448    let total = knowledge.facts.len();
449    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
450    let archived = total - current.len();
451
452    let mut low_quality = 0u32;
453    let mut high_quality = 0u32;
454    let mut stale_candidates = 0u32;
455    let mut total_quality: f32 = 0.0;
456    let mut never_retrieved = 0u32;
457    let mut room_counts: std::collections::HashMap<String, (u32, f32)> =
458        std::collections::HashMap::new();
459
460    let now = chrono::Utc::now();
461    for f in &current {
462        let q = f.quality_score();
463        total_quality += q;
464        if q < 0.4 {
465            low_quality += 1;
466        } else if q >= 0.8 {
467            high_quality += 1;
468        }
469        if f.retrieval_count == 0 {
470            never_retrieved += 1;
471        }
472        let age_days = (now - f.created_at).num_days();
473        if age_days > 30 && f.retrieval_count == 0 {
474            stale_candidates += 1;
475        }
476
477        let entry = room_counts.entry(f.category.clone()).or_insert((0, 0.0));
478        entry.0 += 1;
479        entry.1 += q;
480    }
481
482    let avg_quality = if current.is_empty() {
483        0.0
484    } else {
485        total_quality / current.len() as f32
486    };
487
488    let mut out = String::from("=== Knowledge Health Report ===\n");
489    out.push_str(&format!(
490        "Total: {} facts ({} active, {} archived)\n",
491        total,
492        current.len(),
493        archived
494    ));
495    out.push_str(&format!("Avg Quality: {avg_quality:.2}\n"));
496    out.push_str(&format!(
497        "Distribution: {high_quality} high (>=0.8) | {low_quality} low (<0.4)\n"
498    ));
499    out.push_str(&format!(
500        "Stale (>30d, never retrieved): {stale_candidates}\n"
501    ));
502    out.push_str(&format!("Never retrieved: {never_retrieved}\n"));
503
504    if !room_counts.is_empty() {
505        out.push_str("\nRoom Balance:\n");
506        let mut rooms: Vec<_> = room_counts.into_iter().collect();
507        rooms.sort_by_key(|x| std::cmp::Reverse(x.1 .0));
508        for (cat, (count, total_q)) in &rooms {
509            let avg = if *count > 0 {
510                total_q / *count as f32
511            } else {
512                0.0
513            };
514            out.push_str(&format!("  {cat}: {count} facts, avg quality {avg:.2}\n"));
515        }
516    }
517
518    let policy = crate::core::config::Config::load()
519        .memory_policy_effective()
520        .unwrap_or_default();
521    out.push_str(&format!(
522        "\nPolicy: max {} facts, max {} patterns\n",
523        policy.knowledge.max_facts, policy.knowledge.max_patterns
524    ));
525
526    if current.len() > policy.knowledge.max_facts {
527        out.push_str(&format!(
528            "WARNING: Active facts ({}) exceed policy max ({})\n",
529            current.len(),
530            policy.knowledge.max_facts
531        ));
532    }
533
534    out
535}
536
537fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
538    let Some(cat) = category else {
539        return "Error: category is required for remove".to_string();
540    };
541    let Some(k) = key else {
542        return "Error: key is required for remove".to_string();
543    };
544    let policy = match crate::core::config::Config::load().memory_policy_effective() {
545        Ok(p) => p,
546        Err(e) => {
547            let path = crate::core::config::Config::path().map_or_else(
548                || "~/.lean-ctx/config.toml".to_string(),
549                |p| p.display().to_string(),
550            );
551            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
552        }
553    };
554    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
555    if knowledge.remove_fact(cat, k) {
556        let _ = knowledge.run_memory_lifecycle(&policy);
557
558        #[cfg(feature = "embeddings")]
559        {
560            if let Some(mut idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
561                &knowledge.project_hash,
562            ) {
563                idx.remove(cat, k);
564                crate::core::knowledge_embedding::compact_against_knowledge(
565                    &mut idx, &knowledge, &policy,
566                );
567                let _ = idx.save();
568            }
569        }
570
571        match knowledge.save() {
572            Ok(()) => format!("Removed [{cat}] {k}"),
573            Err(e) => format!("Removed but save failed: {e}"),
574        }
575    } else {
576        format!("No fact found: [{cat}] {k}")
577    }
578}
579
580fn handle_export(project_root: &str) -> String {
581    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
582        return "No knowledge to export.".to_string();
583    };
584    let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
585        Ok(d) => d,
586        Err(e) => return format!("Export failed: {e}"),
587    };
588
589    let export_dir = data_dir.join("exports").join("knowledge");
590    let ts = Utc::now().format("%Y%m%d-%H%M%S");
591    let filename = format!(
592        "knowledge-{}-{ts}.json",
593        short_hash(&knowledge.project_hash)
594    );
595    let path = export_dir.join(filename);
596
597    match serde_json::to_string_pretty(&knowledge) {
598        Ok(mut json) => {
599            json.push('\n');
600            match crate::config_io::write_atomic_with_backup(&path, &json) {
601                Ok(()) => format!(
602                    "Export saved: {} (active facts: {}, patterns: {}, history: {})",
603                    path.display(),
604                    knowledge.facts.iter().filter(|f| f.is_current()).count(),
605                    knowledge.patterns.len(),
606                    knowledge.history.len()
607                ),
608                Err(e) => format!("Export failed: {e}"),
609            }
610        }
611        Err(e) => format!("Export failed: {e}"),
612    }
613}
614
615fn handle_consolidate(project_root: &str) -> String {
616    let Some(session) = SessionState::load_latest() else {
617        return "No active session to consolidate.".to_string();
618    };
619    let policy = match crate::core::config::Config::load().memory_policy_effective() {
620        Ok(p) => p,
621        Err(e) => {
622            let path = crate::core::config::Config::path().map_or_else(
623                || "~/.lean-ctx/config.toml".to_string(),
624                |p| p.display().to_string(),
625            );
626            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
627        }
628    };
629
630    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
631    let mut consolidated = 0u32;
632
633    for finding in &session.findings {
634        let key_text = if let Some(ref file) = finding.file {
635            if let Some(line) = finding.line {
636                format!("{file}:{line}")
637            } else {
638                file.clone()
639            }
640        } else {
641            format!("finding-{consolidated}")
642        };
643
644        knowledge.remember(
645            "finding",
646            &key_text,
647            &finding.summary,
648            &session.id,
649            0.7,
650            &policy,
651        );
652        consolidated += 1;
653    }
654
655    for decision in &session.decisions {
656        let key_text = decision
657            .summary
658            .chars()
659            .take(50)
660            .collect::<String>()
661            .replace(' ', "-")
662            .to_lowercase();
663
664        knowledge.remember(
665            "decision",
666            &key_text,
667            &decision.summary,
668            &session.id,
669            0.85,
670            &policy,
671        );
672        consolidated += 1;
673    }
674
675    let task_desc = session
676        .task
677        .as_ref()
678        .map_or_else(|| "(no task)".into(), |t| t.description.clone());
679
680    let summary = format!(
681        "Session {}: {} — {} findings, {} decisions consolidated",
682        session.id,
683        task_desc,
684        session.findings.len(),
685        session.decisions.len()
686    );
687    knowledge.consolidate(&summary, vec![session.id.clone()], &policy);
688    let _ = knowledge.run_memory_lifecycle(&policy);
689
690    match knowledge.save() {
691        Ok(()) => format!(
692            "Consolidated {consolidated} items from session {} into project knowledge.\n\
693             Facts: {}, Patterns: {}, History: {}",
694            session.id,
695            knowledge.facts.len(),
696            knowledge.patterns.len(),
697            knowledge.history.len()
698        ),
699        Err(e) => format!("Consolidation done but save failed: {e}"),
700    }
701}
702
703fn handle_timeline(project_root: &str, category: Option<&str>) -> String {
704    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
705        return "No knowledge stored yet.".to_string();
706    };
707
708    let policy = match load_policy_or_error() {
709        Ok(p) => p,
710        Err(e) => return e,
711    };
712
713    let Some(cat) = category else {
714        return "Error: category is required for timeline".to_string();
715    };
716
717    let facts = knowledge.timeline(cat);
718    if facts.is_empty() {
719        return format!("No history for category '{cat}'.");
720    }
721
722    let mut ordered: Vec<&crate::core::knowledge::KnowledgeFact> = facts;
723    ordered.sort_by(|a, b| {
724        let a_start = a.valid_from.unwrap_or(a.created_at);
725        let b_start = b.valid_from.unwrap_or(b.created_at);
726        a_start
727            .cmp(&b_start)
728            .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
729            .then_with(|| a.key.cmp(&b.key))
730            .then_with(|| a.value.cmp(&b.value))
731    });
732
733    let total = ordered.len();
734    let limit = policy.knowledge.timeline_limit;
735    if ordered.len() > limit {
736        ordered = ordered[ordered.len() - limit..].to_vec();
737    }
738
739    let mut out = format!(
740        "Timeline [{cat}] (showing {}/{} entries):\n",
741        ordered.len(),
742        total
743    );
744    for f in &ordered {
745        let status = if f.is_current() {
746            "CURRENT"
747        } else {
748            "archived"
749        };
750        let valid_range = match (f.valid_from, f.valid_until) {
751            (Some(from), Some(until)) => format!(
752                "{} → {}",
753                from.format("%Y-%m-%d %H:%M"),
754                until.format("%Y-%m-%d %H:%M")
755            ),
756            (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
757            _ => "unknown".to_string(),
758        };
759        out.push_str(&format!(
760            "  {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
761            f.key,
762            f.value,
763            f.confidence * 100.0,
764            f.confirmation_count
765        ));
766    }
767    out
768}
769
770fn handle_rooms(project_root: &str) -> String {
771    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
772        return "No knowledge stored yet.".to_string();
773    };
774
775    let policy = match load_policy_or_error() {
776        Ok(p) => p,
777        Err(e) => return e,
778    };
779
780    let rooms = knowledge.list_rooms();
781    if rooms.is_empty() {
782        return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
783    }
784
785    let mut rooms = rooms;
786    rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
787    let total = rooms.len();
788    rooms.truncate(policy.knowledge.rooms_limit);
789
790    let mut out = format!(
791        "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
792        rooms.len(),
793        total,
794        short_hash(&knowledge.project_hash)
795    );
796    for (cat, count) in &rooms {
797        out.push_str(&format!("  [{cat}] {count} fact(s)\n"));
798    }
799    out
800}
801
802fn handle_cognition_loop(project_root: &str) -> String {
803    let cfg = crate::core::config::Config::load().autonomy;
804    if !cfg.cognition_loop_enabled {
805        return "Cognition loop is disabled (autonomy.cognition_loop_enabled=false).".to_string();
806    }
807    let max_steps = cfg.cognition_loop_max_steps;
808    let report = crate::core::cognition_loop::run_cognition_loop(project_root, max_steps);
809    format!("{report}")
810}
811
812fn handle_bridge_publish(project_root: &str, session_id: &str) -> String {
813    let knowledge = ProjectKnowledge::load_or_create(project_root);
814    let mut bridge =
815        crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
816    let count = bridge.publish(session_id, &knowledge.facts);
817    match bridge.save() {
818        Ok(()) => format!(
819            "Published {count} fact(s) to bridge (total: {}, agent: {session_id})",
820            bridge.shared_facts.len()
821        ),
822        Err(e) => format!("Published {count} fact(s) but save failed: {e}"),
823    }
824}
825
826fn handle_bridge_pull(project_root: &str, session_id: &str) -> String {
827    let knowledge = ProjectKnowledge::load_or_create(project_root);
828    let bridge =
829        crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
830    let entries = bridge.pull(session_id);
831    if entries.is_empty() {
832        return "No facts available from other agents.".to_string();
833    }
834
835    let policy = match load_policy_or_error() {
836        Ok(p) => p,
837        Err(e) => return e,
838    };
839
840    let mut target = knowledge;
841    let mut imported = 0u32;
842    for entry in &entries {
843        let fact = crate::core::knowledge_bridge::KnowledgeBridge::entry_to_fact(entry);
844        let existing = target
845            .facts
846            .iter()
847            .any(|f| f.is_current() && f.category == fact.category && f.key == fact.key);
848        if !existing {
849            target.remember(
850                &fact.category,
851                &fact.key,
852                &fact.value,
853                session_id,
854                fact.confidence,
855                &policy,
856            );
857            imported += 1;
858        }
859    }
860
861    if imported == 0 {
862        return format!(
863            "Bridge has {} fact(s) from other agents, but all already exist locally.",
864            entries.len()
865        );
866    }
867
868    match target.save() {
869        Ok(()) => format!(
870            "Pulled {imported}/{} fact(s) from bridge into local knowledge.",
871            entries.len()
872        ),
873        Err(e) => format!("Pulled {imported} fact(s) but save failed: {e}"),
874    }
875}
876
877fn handle_bridge_status(project_root: &str) -> String {
878    let knowledge = ProjectKnowledge::load_or_create(project_root);
879    let bridge =
880        crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
881    bridge.summary()
882}
883
884fn handle_wakeup(project_root: &str) -> String {
885    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
886        return "No knowledge for wake-up briefing.".to_string();
887    };
888    let aaak = knowledge.format_aaak();
889    if aaak.is_empty() {
890        return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
891    }
892    format!("WAKE-UP BRIEFING:\n{aaak}")
893}