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