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#[allow(clippy::too_many_arguments)]
22pub fn handle(
23 project_root: &str,
24 action: &str,
25 category: Option<&str>,
26 key: Option<&str>,
27 value: Option<&str>,
28 query: Option<&str>,
29 session_id: &str,
30 pattern_type: Option<&str>,
31 examples: Option<Vec<String>>,
32 confidence: Option<f32>,
33 mode: Option<&str>,
34 as_of: Option<&str>,
35) -> String {
36 match action {
37 "policy" => handle_policy(value),
38 "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
39 "recall" => handle_recall(project_root, category, query, session_id, mode, as_of),
40 "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
41 "feedback" => handle_feedback(project_root, category, key, value, session_id),
42 "relate" => crate::tools::ctx_knowledge_relations::handle_relate(
43 project_root,
44 category,
45 key,
46 value,
47 query,
48 session_id,
49 ),
50 "unrelate" => crate::tools::ctx_knowledge_relations::handle_unrelate(
51 project_root,
52 category,
53 key,
54 value,
55 query,
56 ),
57 "relations" => crate::tools::ctx_knowledge_relations::handle_relations(
58 project_root,
59 category,
60 key,
61 value,
62 query,
63 ),
64 "relations_diagram" => crate::tools::ctx_knowledge_relations::handle_relations_diagram(
65 project_root,
66 category,
67 key,
68 value,
69 query,
70 ),
71 "status" => handle_status(project_root),
72 "health" => handle_health(project_root),
73 "lifecycle_report" => handle_lifecycle_report(project_root),
74 "remove" => handle_remove(project_root, category, key),
75 "export" => handle_export(project_root),
76 "consolidate" => handle_consolidate(project_root),
77 "timeline" => handle_timeline(project_root, category),
78 "rooms" => handle_rooms(project_root),
79 "search" => handle_search(query),
80 "wakeup" => handle_wakeup(project_root),
81 "embeddings_status" => handle_embeddings_status(project_root),
82 "embeddings_reset" => handle_embeddings_reset(project_root),
83 "embeddings_reindex" => handle_embeddings_reindex(project_root),
84 "judge" => handle_judge(project_root, category, key, value, query),
85 "cognition_loop" => handle_cognition_loop(project_root),
86 "bridge_publish" => handle_bridge_publish(project_root, session_id),
87 "bridge_pull" => handle_bridge_pull(project_root, session_id),
88 "bridge_status" => handle_bridge_status(project_root),
89 _ => format!(
90 "Unknown action: {action}. Use: policy, remember, recall, pattern, feedback, judge, relate, unrelate, relations, relations_diagram, status, health, lifecycle_report, remove, export, consolidate, timeline, rooms, search, wakeup, embeddings_status, embeddings_reset, embeddings_reindex, cognition_loop, bridge_publish, bridge_pull, bridge_status"
91 ),
92 }
93}
94
95fn handle_policy(value: Option<&str>) -> String {
96 let sub = value.unwrap_or("show").trim().to_lowercase();
97 let profile = crate::core::profiles::active_profile_name();
98
99 match sub.as_str() {
100 "show" => {
101 let policy = match load_policy_or_error() {
102 Ok(p) => p,
103 Err(e) => return e,
104 };
105
106 let cfg_path = crate::core::config::Config::path().map_or_else(
107 || "~/.lean-ctx/config.toml".to_string(),
108 |p| p.display().to_string(),
109 );
110
111 format!(
112 "Knowledge policy (effective, profile={profile}):\n\
113 - memory.knowledge.max_facts={}\n\
114 - memory.knowledge.contradiction_threshold={}\n\
115 - memory.knowledge.recall_facts_limit={}\n\
116 - memory.knowledge.rooms_limit={}\n\
117 - memory.knowledge.timeline_limit={}\n\
118 - memory.knowledge.relations_limit={}\n\
119 - memory.lifecycle.decay_rate={}\n\
120 - memory.lifecycle.stale_days={}\n\
121 \nConfig: {cfg_path}",
122 policy.knowledge.max_facts,
123 policy.knowledge.contradiction_threshold,
124 policy.knowledge.recall_facts_limit,
125 policy.knowledge.rooms_limit,
126 policy.knowledge.timeline_limit,
127 policy.knowledge.relations_limit,
128 policy.lifecycle.decay_rate,
129 policy.lifecycle.stale_days
130 )
131 }
132 "validate" => match load_policy_or_error() {
133 Ok(_) => format!("OK: memory policy valid (profile={profile})"),
134 Err(e) => e,
135 },
136 _ => "Error: policy value must be show|validate".to_string(),
137 }
138}
139
140fn handle_feedback(
141 project_root: &str,
142 category: Option<&str>,
143 key: Option<&str>,
144 value: Option<&str>,
145 session_id: &str,
146) -> String {
147 let Some(cat) = category else {
148 return "Error: category is required for feedback".to_string();
149 };
150 let Some(k) = key else {
151 return "Error: key is required for feedback".to_string();
152 };
153 let dir = value.unwrap_or("up").trim().to_lowercase();
154 let is_up = matches!(dir.as_str(), "up" | "+1" | "+" | "true" | "1");
155 let is_down = matches!(dir.as_str(), "down" | "-1" | "-" | "false" | "0");
156 if !is_up && !is_down {
157 return "Error: feedback value must be up|down (+1|-1)".to_string();
158 }
159
160 let 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 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 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
375fn 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 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 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 {
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 {
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 #[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 ¤t {
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 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 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 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}