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