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#[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 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 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 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
383fn 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 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 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 {
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 {
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 #[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 ¤t {
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 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 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
650pub 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
685pub 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
720fn 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 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
802fn 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
813fn 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}