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