1use chrono::Utc;
3
4#[cfg(feature = "embeddings")]
5use lean_ctx::core::embeddings::EmbeddingEngine;
6
7use lean_ctx::core::consolidation_engine::ConsolidateOptions;
8use lean_ctx::core::knowledge::ProjectKnowledge;
9use lean_ctx::core::memory_policy::MemoryPolicy;
10use lean_ctx::core::session::SessionState;
11mod changes;
12pub(crate) use changes::session_change_count;
13pub(crate) mod embeddings;
14pub(crate) use embeddings::*;
15mod linked;
16mod remember;
17pub use remember::*;
18mod restore;
19pub use restore::{DEFAULT_RESTORE_LIMIT, RestoreOptions, format_restore_report, run_restore};
20mod search;
21pub(crate) use search::*;
22
23mod consolidate;
24pub use consolidate::*;
25#[cfg(test)]
26mod engine_tests;
27#[cfg(test)]
28mod hive_guard_tests;
29#[cfg(test)]
30mod tests;
31
32#[allow(clippy::too_many_arguments)]
34pub fn handle(
35 project_root: &str,
36 action: &str,
37 category: Option<&str>,
38 key: Option<&str>,
39 value: Option<&str>,
40 query: Option<&str>,
41 session_id: &str,
42 pattern_type: Option<&str>,
43 examples: Option<Vec<String>>,
44 confidence: Option<f32>,
45 mode: Option<&str>,
46 as_of: Option<&str>,
47) -> String {
48 match handle_outcome(
49 project_root,
50 action,
51 category,
52 key,
53 value,
54 query,
55 session_id,
56 pattern_type,
57 examples,
58 confidence,
59 mode,
60 as_of,
61 ) {
62 Ok(text) | Err(text) => text,
63 }
64}
65
66#[allow(clippy::too_many_arguments)]
77pub fn handle_outcome(
78 project_root: &str,
79 action: &str,
80 category: Option<&str>,
81 key: Option<&str>,
82 value: Option<&str>,
83 query: Option<&str>,
84 session_id: &str,
85 pattern_type: Option<&str>,
86 examples: Option<Vec<String>>,
87 confidence: Option<f32>,
88 mode: Option<&str>,
89 as_of: Option<&str>,
90) -> Result<String, String> {
91 if let Err(error) = lean_ctx::core::knowledge::protection::current(project_root) {
92 return Err(format!("Error: {error}"));
93 }
94 lean_ctx::core::policy::runtime::REQUEST_PROJECT.sync_scope(
95 std::cell::RefCell::new(Some(std::path::PathBuf::from(project_root))),
96 || {
97 let declared = match action {
98 "recall" => Some(handle_recall(project_root, category, query, session_id, mode, as_of)),
99 "timeline" => Some(handle_timeline(project_root, category)),
100 "search" => Some(handle_search(query, session_id)),
101 _ => None,
102 };
103 let failed = matches!(declared, Some(Err(_)));
104 let result = match declared {
105 Some(Ok(text) | Err(text)) => text,
106 None => match action {
107 "policy" => handle_policy(value),
108 "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
109 "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
110 "feedback" => handle_feedback(project_root, category, key, value, session_id),
111 "relate" => crate::tools::ctx_knowledge_relations::handle_relate(
112 project_root,
113 category,
114 key,
115 value,
116 query,
117 session_id,
118 ),
119 "unrelate" => crate::tools::ctx_knowledge_relations::handle_unrelate(
120 project_root,
121 category,
122 key,
123 value,
124 query,
125 ),
126 "relations" => crate::tools::ctx_knowledge_relations::handle_relations(
127 project_root,
128 category,
129 key,
130 value,
131 query,
132 ),
133 "relations_diagram" => crate::tools::ctx_knowledge_relations::handle_relations_diagram(
134 project_root,
135 category,
136 key,
137 value,
138 query,
139 ),
140 "status" => handle_status(project_root),
141 "changes" => changes::handle_changes(project_root, query, session_id),
142 "repin" => changes::handle_repin(project_root, category, key),
143 "coverage" => changes::handle_coverage(project_root),
144 "health" => handle_health(project_root),
145 "lifecycle_report" => handle_lifecycle_report(project_root),
146 "remove" => handle_remove(project_root, category, key),
147 "export" => handle_export(project_root),
148 "consolidate" => handle_consolidate(project_root),
149 "consolidate_preview" => handle_consolidate_preview(project_root),
150 "restore" => handle_restore(project_root, category, query, None),
151 "rooms" => handle_rooms(project_root),
152 "wakeup" => handle_wakeup(project_root),
153 "embeddings_status" => handle_embeddings_status(project_root),
154 "embeddings_reset" => handle_embeddings_reset(project_root),
155 "embeddings_reindex" => handle_embeddings_reindex(project_root),
156 "judge" => handle_judge(project_root, category, key, value, query),
157 "cognition_loop" => "ctx_knowledge action=cognition_loop is retired and has no effect: scheduled consolidation already runs the memory lifecycle (decay, merge, archival). Use action=consolidate to run it now.".to_string(),
159 "bridge_publish" => handle_bridge_publish(project_root, session_id),
160 "bridge_pull" => handle_bridge_pull(project_root, session_id),
161 "bridge_status" => handle_bridge_status(project_root),
162 _ => format!(
163 "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, bridge_publish, bridge_pull, bridge_status"
164 ),
165 },
166 };
167 let Ok(result) = lean_ctx::core::policy::content::protect_active(&result) else {
168 return Err("Error: knowledge output withheld by current policy".to_string());
169 };
170 match lean_ctx::core::context_admission::recovery::admit_recovered(&result, "knowledge") {
174 Ok(text) if failed => Err(text),
175 Ok(text) if action == "wakeup" => Ok(wakeup_delta(value, text)),
176 Ok(text) => Ok(text),
177 Err(error) => Err(format!("Error: {error}")),
178 }
179 },
180 )
181}
182
183fn handle_policy(value: Option<&str>) -> String {
184 let sub = value.unwrap_or("show").trim().to_lowercase();
185 let profile = lean_ctx::core::profiles::active_profile_name();
186
187 match sub.as_str() {
188 "show" => {
189 let policy = match load_policy_or_error() {
190 Ok(p) => p,
191 Err(e) => return e,
192 };
193
194 let cfg_path = lean_ctx::core::config::Config::path().map_or_else(
195 || "~/.lean-ctx/config.toml".to_string(),
196 |p| p.display().to_string(),
197 );
198
199 format!(
200 "Knowledge policy (effective, profile={profile}):\n\
201 - memory.knowledge facts: no limit (max_facts is ignored)\n\
202 - memory.knowledge.contradiction_threshold={}\n\
203 - memory.knowledge.recall_facts_limit={}\n\
204 - memory.knowledge.rooms_limit={}\n\
205 - memory.knowledge.timeline_limit={}\n\
206 - memory.knowledge.relations_limit={}\n\
207 - memory.lifecycle.decay_rate={}\n\
208 - memory.lifecycle.stale_days={}\n\
209 \nConfig: {cfg_path}",
210 policy.knowledge.contradiction_threshold,
211 policy.knowledge.recall_facts_limit,
212 policy.knowledge.rooms_limit,
213 policy.knowledge.timeline_limit,
214 policy.knowledge.relations_limit,
215 policy.lifecycle.decay_rate,
216 policy.lifecycle.stale_days
217 )
218 }
219 "validate" => match load_policy_or_error() {
220 Ok(_) => format!("OK: memory policy valid (profile={profile})"),
221 Err(e) => e,
222 },
223 _ => "Error: policy value must be show|validate".to_string(),
224 }
225}
226
227fn handle_feedback(
228 project_root: &str,
229 category: Option<&str>,
230 key: Option<&str>,
231 value: Option<&str>,
232 session_id: &str,
233) -> String {
234 let Some(cat) = category else {
235 return "Error: category is required for feedback".to_string();
236 };
237 let Some(k) = key else {
238 return "Error: key is required for feedback".to_string();
239 };
240 let dir = value.unwrap_or("up").trim().to_lowercase();
241 let is_up = matches!(dir.as_str(), "up" | "+1" | "+" | "true" | "1");
242 let is_down = matches!(dir.as_str(), "down" | "-1" | "-" | "false" | "0");
243 if !is_up && !is_down {
244 return "Error: feedback value must be up|down (+1|-1)".to_string();
245 }
246
247 let outcome = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
250 let Some(f) = knowledge
251 .facts
252 .iter_mut()
253 .find(|f| f.is_current() && f.category == cat && f.key == k)
254 else {
255 return Err(format!("No current fact found: [{cat}] {k}"));
256 };
257
258 if is_up {
259 f.feedback_up = f.feedback_up.saturating_add(1);
260 } else {
261 f.feedback_down = f.feedback_down.saturating_add(1);
262 }
263 f.last_feedback = Some(Utc::now());
264 Ok((
265 f.quality_score(),
266 f.feedback_up,
267 f.feedback_down,
268 f.confidence,
269 ))
270 });
271
272 let (quality, up, down, conf) = match outcome {
273 Ok((_, Ok(vals))) => vals,
274 Ok((_, Err(msg))) => return msg,
275 Err(e) => return format!("Error: feedback was not saved: {e}"),
276 };
277
278 lean_ctx::core::events::emit(lean_ctx::core::events::EventKind::KnowledgeUpdate {
279 category: cat.to_string(),
280 key: k.to_string(),
281 action: if is_up {
282 "feedback_up"
283 } else {
284 "feedback_down"
285 }
286 .to_string(),
287 });
288
289 format!(
290 "Feedback recorded ({dir}) for [{cat}] {k} (up={up}, down={down}, quality={quality:.2}, confidence={conf:.2}, session={session_id})"
291 )
292}
293
294pub(crate) fn handle_judge(
295 project_root: &str,
296 category: Option<&str>,
297 key: Option<&str>,
298 value: Option<&str>,
299 query: Option<&str>,
300) -> String {
301 let source = match (category, key) {
302 (Some(cat), Some(k)) => format!("{cat}/{k}"),
303 _ => {
304 if let Some(k) = key.or(category) {
305 if k.contains('/') {
306 k.to_string()
307 } else {
308 return "Error: judge requires key as 'category/key' (source fact)".to_string();
309 }
310 } else {
311 return "Error: judge requires category+key (source fact) and value (target 'category/key')"
312 .to_string();
313 }
314 }
315 };
316
317 let Some(target) = value else {
318 return "Error: judge requires value as target 'category/key'".to_string();
319 };
320 let target = target.trim().to_string();
321 if !target.contains('/') {
322 return "Error: target must be 'category/key' format".to_string();
323 }
324
325 let verdict = query.unwrap_or("compatible").trim().to_lowercase();
326 if !matches!(
327 verdict.as_str(),
328 "supersedes" | "compatible" | "unrelated" | "contradicts"
329 ) {
330 return format!(
331 "Error: verdict must be supersedes|compatible|unrelated|contradicts, got '{verdict}'"
332 );
333 }
334
335 let result = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
337 let src = current_fact_index(knowledge, &source)?;
338 let tgt = current_fact_index(knowledge, &target)?;
339 if src == tgt {
340 return Err("Error: a fact is not judged against itself".to_string());
341 }
342 let now = Utc::now();
343
344 if verdict == "supersedes" {
345 let tf = &mut knowledge.facts[tgt];
346 tf.valid_until = Some(now);
347 tf.valid_from = tf.valid_from.or(Some(tf.created_at));
348 let sf = &mut knowledge.facts[src];
350 if sf.review.as_ref().is_some_and(|r| {
351 r.reason == lean_ctx::core::knowledge::FactReviewReason::Contradiction
352 }) {
353 sf.clear_review();
354 }
355 }
356 if verdict == "contradicts" {
360 use lean_ctx::core::knowledge::{FactReviewReason, FactReviewState};
361 for i in [src, tgt] {
362 knowledge.facts[i].mark_review(
363 FactReviewState::Contradicted,
364 FactReviewReason::Contradiction,
365 now,
366 );
367 }
368 }
369
370 knowledge
371 .judged_pairs
372 .push(lean_ctx::core::knowledge::JudgedPair {
373 key_a: source.clone(),
374 key_b: target.clone(),
375 verdict: verdict.clone(),
376 judged_at: now,
377 });
378 Ok(())
379 });
380
381 match result {
382 Ok((_, Ok(()))) => {}
383 Ok((_, Err(msg))) => return msg,
384 Err(e) => return format!("Error: judge save failed: {e}"),
385 }
386
387 let action_desc = match verdict.as_str() {
388 "supersedes" => format!("{source} supersedes {target} (target archived)"),
389 "compatible" => format!("{source} ↔ {target} (compatible, suppressed from future similar)"),
390 "unrelated" => format!("{source} ≠ {target} (unrelated, suppressed from future similar)"),
391 "contradicts" => format!(
392 "{source} ⟂ {target} (contradicts: both marked contradicted until one supersedes the other)"
393 ),
394 _ => unreachable!(),
395 };
396
397 format!("Judged: {action_desc}")
398}
399
400fn current_fact_index(
403 knowledge: &lean_ctx::core::knowledge::ProjectKnowledge,
404 spec: &str,
405) -> Result<usize, String> {
406 let mut found = knowledge.facts.iter().enumerate().filter(|(_, f)| {
407 f.is_current()
408 && spec.len() == f.category.len() + 1 + f.key.len()
409 && spec.starts_with(f.category.as_str())
410 && spec[f.category.len()..].starts_with('/')
411 && spec.ends_with(f.key.as_str())
412 });
413 match (found.next(), found.next()) {
414 (Some((i, _)), None) => Ok(i),
415 (Some(_), Some(_)) => Err(format!("Error: '{spec}' names more than one current fact")),
416 (None, _) => Err(format!("Error: no current fact found for '{spec}'")),
417 }
418}
419
420fn handle_pattern(
421 project_root: &str,
422 pattern_type: Option<&str>,
423 value: Option<&str>,
424 examples: Option<Vec<String>>,
425 session_id: &str,
426) -> String {
427 let Some(pt) = pattern_type else {
428 return "Error: pattern_type is required".to_string();
429 };
430 let Some(desc) = value else {
431 return "Error: value (description) is required for pattern".to_string();
432 };
433 let exs = examples.unwrap_or_default();
434 let policy = match lean_ctx::core::config::Config::load().memory_policy_effective() {
435 Ok(p) => p,
436 Err(e) => {
437 let path = lean_ctx::core::config::Config::path().map_or_else(
438 || "~/.lean-ctx/config.toml".to_string(),
439 |p| p.display().to_string(),
440 );
441 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
442 }
443 };
444 match ProjectKnowledge::mutate_locked(project_root, |knowledge| {
446 knowledge.add_pattern(pt, desc, exs, session_id, &policy);
447 }) {
448 Ok(_) => format!("Pattern [{pt}] added: {desc}"),
449 Err(e) => format!("Pattern add failed: {e}"),
450 }
451}
452
453fn handle_status(project_root: &str) -> String {
454 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
455 return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
456 };
457
458 let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
459 let archived_facts = knowledge.facts.len() - current_facts;
460
461 let mut out = format!(
462 "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
463 current_facts,
464 archived_facts,
465 knowledge.patterns.len(),
466 knowledge.history.len()
467 );
468 out.push_str(&format!(
469 "Last updated: {}\n",
470 knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
471 ));
472
473 let rooms = knowledge.list_rooms();
474 if !rooms.is_empty() {
475 out.push_str("Rooms: ");
476 let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
477 out.push_str(&room_strs.join(", "));
478 out.push('\n');
479 }
480
481 out.push_str(&knowledge.format_summary());
482 out
483}
484
485fn handle_lifecycle_report(project_root: &str) -> String {
488 let policy = match load_policy_or_error() {
489 Ok(p) => p,
490 Err(e) => return e,
491 };
492
493 let mut out = String::from("=== Memory Lifecycle Report ===\n");
494
495 match ProjectKnowledge::load(project_root) {
497 Some(k) => {
498 let active = k.facts.iter().filter(|f| f.is_current()).count();
499 let archived = k.facts.len() - active;
500 out.push_str(&format!(
501 "knowledge {active} active / {archived} archived (no fact limit)\n\
502 \x20 decay {}/day, stale >{}d, consolidate-sim {:.2}\n\
503 \x20 GC: by quality only (decay, near-duplicate merge, low-confidence/stale); eviction archives, never deletes\n",
504 policy.lifecycle.decay_rate,
505 policy.lifecycle.stale_days,
506 policy.lifecycle.similarity_threshold,
507 ));
508 }
509 None => out.push_str("knowledge (no store yet)\n"),
510 }
511
512 let archives = lean_ctx::core::memory_lifecycle::list_archives(project_root);
514 out.push_str(&format!(
515 "archives {} file(s); auto-rehydrated when recall misses\n",
516 archives.len()
517 ));
518
519 #[cfg(feature = "embeddings")]
521 {
522 let hash = lean_ctx::core::project_hash::hash_project_root(project_root);
523 let n = lean_ctx::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(&hash)
524 .map_or(0, |idx| idx.entries.len());
525 out.push_str(&format!(
526 "embeddings {n} vector(s); compacted against knowledge on remember\n"
527 ));
528 }
529 #[cfg(not(feature = "embeddings"))]
530 out.push_str("embeddings (feature disabled in this build)\n");
531
532 out.push_str(
533 "\nLayer boundaries: session = working memory (now) | knowledge = long-term (ETL via consolidate) | providers = external (read-through)\n",
534 );
535 out
536}
537
538fn handle_health(project_root: &str) -> String {
539 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
540 return "No knowledge stored. Nothing to report.".to_string();
541 };
542
543 let total = knowledge.facts.len();
544 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
545 let archived = total - current.len();
546
547 let mut low_quality = 0u32;
548 let mut high_quality = 0u32;
549 let mut stale_candidates = 0u32;
550 let mut total_quality: f32 = 0.0;
551 let mut never_retrieved = 0u32;
552 let mut room_counts: std::collections::HashMap<String, (u32, f32)> =
553 std::collections::HashMap::new();
554
555 let now = chrono::Utc::now();
556 for f in ¤t {
557 let q = f.quality_score();
558 total_quality += q;
559 if q < 0.4 {
560 low_quality += 1;
561 } else if q >= 0.8 {
562 high_quality += 1;
563 }
564 if f.retrieval_count == 0 {
565 never_retrieved += 1;
566 }
567 let age_days = (now - f.created_at).num_days();
568 if age_days > 30 && f.retrieval_count == 0 {
569 stale_candidates += 1;
570 }
571
572 let entry = room_counts.entry(f.category.clone()).or_insert((0, 0.0));
573 entry.0 += 1;
574 entry.1 += q;
575 }
576
577 let avg_quality = if current.is_empty() {
578 0.0
579 } else {
580 total_quality / current.len() as f32
581 };
582
583 let mut out = String::from("=== Knowledge Health Report ===\n");
584 out.push_str(&format!(
585 "Total: {} facts ({} active, {} archived)\n",
586 total,
587 current.len(),
588 archived
589 ));
590 out.push_str(&format!("Avg Quality: {avg_quality:.2}\n"));
591 out.push_str(&format!(
592 "Distribution: {high_quality} high (>=0.8) | {low_quality} low (<0.4)\n"
593 ));
594 out.push_str(&format!(
595 "Stale (>30d, never retrieved): {stale_candidates}\n"
596 ));
597 out.push_str(&format!("Never retrieved: {never_retrieved}\n"));
598
599 if !room_counts.is_empty() {
600 out.push_str("\nRoom Balance:\n");
601 let mut rooms: Vec<_> = room_counts.into_iter().collect();
602 rooms.sort_by_key(|x| std::cmp::Reverse(x.1.0));
603 for (cat, (count, total_q)) in &rooms {
604 let avg = if *count > 0 {
605 total_q / *count as f32
606 } else {
607 0.0
608 };
609 out.push_str(&format!(" {cat}: {count} facts, avg quality {avg:.2}\n"));
610 }
611 }
612
613 let policy = lean_ctx::core::config::Config::load()
614 .memory_policy_effective()
615 .unwrap_or_default();
616 out.push_str(&format!(
617 "\nPolicy: no fact limit, max {} patterns\n",
618 policy.knowledge.max_patterns
619 ));
620
621 out
622}
623
624fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
625 let Some(cat) = category else {
626 return "Error: category is required for remove".to_string();
627 };
628 let Some(k) = key else {
629 return "Error: key is required for remove".to_string();
630 };
631 let policy = match lean_ctx::core::config::Config::load().memory_policy_effective() {
632 Ok(p) => p,
633 Err(e) => {
634 let path = lean_ctx::core::config::Config::path().map_or_else(
635 || "~/.lean-ctx/config.toml".to_string(),
636 |p| p.display().to_string(),
637 );
638 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
639 }
640 };
641 let (knowledge, removed) = match ProjectKnowledge::mutate_locked(project_root, |knowledge| {
645 knowledge.remove_fact(cat, k)
646 }) {
647 Ok(pair) => pair,
648 Err(e) => return format!("Error: removal was not saved: {e}"),
649 };
650
651 if !removed {
652 return format!("No fact found: [{cat}] {k}");
653 }
654
655 #[cfg(feature = "embeddings")]
656 {
657 ProjectKnowledge::with_project_lock(project_root, || {
661 if let Some(mut idx) =
662 lean_ctx::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
663 &knowledge.project_hash,
664 )
665 {
666 idx.remove(cat, k);
667 let fresh = ProjectKnowledge::load(project_root);
668 let kref = fresh.as_ref().unwrap_or(&knowledge);
669 lean_ctx::core::knowledge_embedding::compact_against_knowledge(
670 &mut idx, kref, &policy,
671 );
672 let _ = idx.save();
673 }
674 });
675 }
676 #[cfg(not(feature = "embeddings"))]
677 let _ = (&knowledge, &policy);
678
679 format!("Removed [{cat}] {k}")
680}
681
682fn handle_export(project_root: &str) -> String {
683 let result = lean_ctx::core::policy::runtime::with_explicit_project_source_view(
684 std::path::Path::new(project_root),
685 |authority| -> Result<String, String> {
686 let admission =
687 lean_ctx::core::knowledge::protection::KnowledgeAdmission::capture(project_root)?;
688 let Some(knowledge) = ProjectKnowledge::load_for_admission(project_root, &admission)?
689 else {
690 admission.authorize_publication(authority)?;
691 return Ok("No knowledge to export.".to_string());
692 };
693 if knowledge.facts.is_empty()
694 && knowledge.patterns.is_empty()
695 && knowledge.history.is_empty()
696 {
697 admission.authorize_publication(authority)?;
698 return Ok("No knowledge to export.".to_string());
699 }
700 let data_dir = lean_ctx::core::data_dir::lean_ctx_data_dir()?;
701 let export_dir = data_dir.join("exports").join("knowledge");
702 let ts = Utc::now().format("%Y%m%d-%H%M%S");
703 let filename = format!(
704 "knowledge-{}-{ts}.json",
705 short_hash(&knowledge.project_hash)
706 );
707 let path = export_dir.join(filename);
708 let mut json = serde_json::to_string_pretty(&knowledge)
709 .map_err(|_| "Export serialization failed")?;
710 json.push('\n');
711 admission.authorize_publication(authority)?;
712 lean_ctx::config_io::write_atomic_with_backup(&path, &json)?;
713 Ok(format!(
714 "Export saved: {} (active facts: {}, patterns: {}, history: {})",
715 path.display(),
716 knowledge.facts.iter().filter(|f| f.is_current()).count(),
717 knowledge.patterns.len(),
718 knowledge.history.len()
719 ))
720 },
721 );
722 match result {
723 Ok(Ok(message)) => message,
724 Ok(Err(error)) | Err(error) => format!("Export failed: {error}"),
725 }
726}
727
728fn admitted_snapshot(
729 project_root: &str,
730 admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
731) -> Result<Option<lean_ctx::core::knowledge::KnowledgeSnapshot>, String> {
732 let Some(knowledge) = ProjectKnowledge::load_for_admission(project_root, admission)? else {
733 return Ok(None);
734 };
735 let visible_nodes: std::collections::HashSet<(String, String)> = knowledge
736 .facts
737 .iter()
738 .map(|fact| (fact.category.clone(), fact.key.clone()))
739 .collect();
740 let relations =
741 lean_ctx::core::knowledge_relations::KnowledgeRelationGraph::load(&knowledge.project_hash)
742 .map(|graph| {
743 graph
744 .edges
745 .into_iter()
746 .filter(|edge| {
747 visible_nodes.contains(&(edge.from.category.clone(), edge.from.key.clone()))
748 && visible_nodes
749 .contains(&(edge.to.category.clone(), edge.to.key.clone()))
750 && serde_json::to_value(edge)
751 .is_ok_and(|value| admission.admits_archive_value(&value))
752 })
753 .collect()
754 })
755 .unwrap_or_default();
756 admission.authorize_gateway()?;
757 Ok(Some(
758 lean_ctx::core::knowledge::KnowledgeSnapshot::from_project(&knowledge, relations),
759 ))
760}
761
762pub fn handle_export_okf(project_root: &str, out: Option<&str>) -> String {
768 let result = lean_ctx::core::policy::runtime::with_explicit_project_source_view(
769 std::path::Path::new(project_root),
770 |authority| -> Result<String, String> {
771 let admission =
772 lean_ctx::core::knowledge::protection::KnowledgeAdmission::capture(project_root)?;
773 let Some(snapshot) = admitted_snapshot(project_root, &admission)? else {
774 admission.authorize_publication(authority)?;
775 return Ok("No knowledge to export.".to_string());
776 };
777 if snapshot.is_empty() {
778 admission.authorize_publication(authority)?;
779 return Ok("No knowledge to export.".to_string());
780 }
781 let dir = match out {
782 Some(p) => std::path::PathBuf::from(p),
783 None => lean_ctx::core::data_dir::lean_ctx_data_dir()?
784 .join("exports")
785 .join("okf")
786 .join(short_hash(&snapshot.project_hash)),
787 };
788 let bundle = lean_ctx::core::knowledge::okf::to_okf_bundle(&snapshot);
789 admission.authorize_publication(authority)?;
790 lean_ctx::core::knowledge::okf::write_okf_bundle(&dir, &bundle)?;
791 Ok(format!(
792 "OKF bundle exported: {} ({} concepts, {} patterns, {} relations)",
793 dir.display(),
794 snapshot.current_facts().len(),
795 snapshot.patterns.len(),
796 snapshot.relations.len()
797 ))
798 },
799 );
800 match result {
801 Ok(Ok(message)) => message,
802 Ok(Err(error)) | Err(error) => format!("Export failed: {error}"),
803 }
804}
805
806pub fn handle_import(
809 project_root: &str,
810 path: &str,
811 merge: lean_ctx::core::knowledge::ImportMerge,
812 session_id: &str,
813) -> String {
814 if std::path::Path::new(path).is_dir() {
815 return handle_import_okf(project_root, std::path::Path::new(path), merge, session_id);
816 }
817
818 let data = match std::fs::read_to_string(path) {
819 Ok(d) => d,
820 Err(e) => return format!("Failed to read {path}: {e}"),
821 };
822 let facts = match lean_ctx::core::knowledge::parse_import_data(&data) {
823 Ok(f) => f,
824 Err(e) => return format!("Parse error: {e}"),
825 };
826 match ProjectKnowledge::mutate_locked(project_root, |k| {
827 k.import_facts(facts, merge, session_id)
828 }) {
829 Ok((_, r)) => format!(
830 "Import complete: {} added, {} skipped, {} replaced",
831 r.added, r.skipped, r.replaced
832 ),
833 Err(e) => format!("Import failed: {e}"),
834 }
835}
836
837fn handle_import_okf(
841 project_root: &str,
842 dir: &std::path::Path,
843 merge: lean_ctx::core::knowledge::ImportMerge,
844 session_id: &str,
845) -> String {
846 let imp = match lean_ctx::core::knowledge::okf::from_okf_dir(dir) {
847 Ok(i) => i,
848 Err(e) => return format!("OKF import failed: {e}"),
849 };
850 let policy = match load_policy_or_error() {
851 Ok(p) => p,
852 Err(e) => return e,
853 };
854
855 let pattern_count = imp.patterns.len();
856 let (knowledge, result) = match ProjectKnowledge::mutate_locked(project_root, |k| {
857 let r = k.import_facts(imp.facts, merge, session_id);
858 for p in &imp.patterns {
859 k.add_pattern(
860 &p.pattern_type,
861 &p.description,
862 p.examples.clone(),
863 session_id,
864 &policy,
865 );
866 }
867 r
868 }) {
869 Ok(v) => v,
870 Err(e) => return format!("OKF import failed: {e}"),
871 };
872
873 let current: std::collections::HashSet<(String, String)> = knowledge
875 .facts
876 .iter()
877 .filter(|f| f.is_current())
878 .map(|f| (f.category.clone(), f.key.clone()))
879 .collect();
880 let mut relations_added = 0usize;
881 if !imp.edges.is_empty() {
882 let saved = lean_ctx::core::knowledge_relations::KnowledgeRelationGraph::mutate_locked(
883 project_root,
884 &knowledge.project_hash,
885 |graph| {
886 let mut added = 0usize;
887 for e in imp.edges {
888 let endpoints_current = current
889 .contains(&(e.from.category.clone(), e.from.key.clone()))
890 && current.contains(&(e.to.category.clone(), e.to.key.clone()));
891 if endpoints_current && graph.upsert_edge(e.from, e.to, e.kind, session_id) {
892 added += 1;
893 }
894 }
895 Some(added)
896 },
897 );
898 match saved {
899 Ok(added) => relations_added = added.unwrap_or_default(),
900 Err(err) => tracing::warn!("OKF import: relations save failed: {err}"),
901 }
902 }
903
904 let mut out = format!(
905 "OKF import: {} added, {} skipped, {} replaced, {pattern_count} patterns, {relations_added} relations",
906 result.added, result.skipped, result.replaced
907 );
908 let warnings = lean_ctx::core::knowledge::okf::lint_okf_bundle(dir);
909 if !warnings.is_empty() {
910 out.push_str(&format!("\nLint warnings ({}):", warnings.len()));
911 for w in warnings.iter().take(10) {
912 out.push_str(&format!("\n - {w}"));
913 }
914 }
915 out
916}
917
918fn handle_consolidate(project_root: &str) -> String {
919 match consolidate_project_knowledge(project_root) {
920 Ok(report) => format_consolidation_report(&report),
921 Err(e) => e,
922 }
923}
924
925fn handle_consolidate_preview(project_root: &str) -> String {
927 match consolidate_project_knowledge_with(
928 project_root,
929 &ConsolidateOptions::manual().into_dry_run(),
930 ) {
931 Ok(report) => format_consolidation_report(&report),
932 Err(e) => e,
933 }
934}
935
936fn handle_restore(
940 project_root: &str,
941 store: Option<&str>,
942 query: Option<&str>,
943 limit: Option<usize>,
944) -> String {
945 let store = match store {
946 Some(s) if lean_ctx::core::memory_archive::MemoryStore::is_retired(s) => {
947 return lean_ctx::core::memory_archive::RETIRED_PROCEDURES_STORE_NOTICE.to_string();
948 }
949 Some(s) => match lean_ctx::core::memory_archive::MemoryStore::parse(s) {
950 Some(ms) => Some(ms),
951 None => {
952 return format!("Unknown store: {s}. Use: facts, history, patterns");
953 }
954 },
955 None => None,
956 };
957 let opts = RestoreOptions::new(
958 store,
959 query.map(str::to_string),
960 limit.unwrap_or(DEFAULT_RESTORE_LIMIT),
961 );
962 match run_restore(project_root, &opts) {
963 Ok(report) => format_restore_report(&report),
964 Err(e) => e,
965 }
966}
967
968fn handle_timeline(project_root: &str, category: Option<&str>) -> Result<String, String> {
969 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
970 return Ok("No knowledge stored yet.".to_string());
971 };
972
973 let policy = load_policy_or_error()?;
974
975 let Some(cat) = category else {
976 return Err("Error: category is required for timeline".to_string());
977 };
978
979 let facts = knowledge.timeline(cat);
980 if facts.is_empty() {
981 return Ok(format!("No history for category '{cat}'."));
982 }
983
984 let mut ordered: Vec<&lean_ctx::core::knowledge::KnowledgeFact> = facts;
985 ordered.sort_by(|a, b| {
986 let a_start = a.valid_from.unwrap_or(a.created_at);
987 let b_start = b.valid_from.unwrap_or(b.created_at);
988 a_start
989 .cmp(&b_start)
990 .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
991 .then_with(|| a.key.cmp(&b.key))
992 .then_with(|| a.value.cmp(&b.value))
993 });
994
995 let total = ordered.len();
996 let limit = policy.knowledge.timeline_limit;
997 if ordered.len() > limit {
998 ordered = ordered[ordered.len() - limit..].to_vec();
999 }
1000 ordered.retain(|f| fact_served(project_root, &f.category, &f.key, &f.value));
1002
1003 let mut out = format!(
1004 "Timeline [{cat}] (showing {}/{} entries):\n",
1005 ordered.len(),
1006 total
1007 );
1008 for f in &ordered {
1009 let status = if f.is_current() {
1010 "CURRENT"
1011 } else {
1012 "archived"
1013 };
1014 let valid_range = match (f.valid_from, f.valid_until) {
1015 (Some(from), Some(until)) => format!(
1016 "{} → {}",
1017 from.format("%Y-%m-%d %H:%M"),
1018 until.format("%Y-%m-%d %H:%M")
1019 ),
1020 (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
1021 _ => "unknown".to_string(),
1022 };
1023 out.push_str(&format!(
1024 " {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
1025 f.key,
1026 f.as_delivered(f.value.clone()),
1027 f.confidence * 100.0,
1028 f.confirmation_count
1029 ));
1030 }
1031 Ok(out)
1032}
1033
1034fn handle_rooms(project_root: &str) -> String {
1035 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1036 return "No knowledge stored yet.".to_string();
1037 };
1038
1039 let policy = match load_policy_or_error() {
1040 Ok(p) => p,
1041 Err(e) => return e,
1042 };
1043
1044 let rooms = knowledge.list_rooms();
1045 if rooms.is_empty() {
1046 return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
1047 }
1048
1049 let mut rooms = rooms;
1050 rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1051 let total = rooms.len();
1052 rooms.truncate(policy.knowledge.rooms_limit);
1053
1054 let mut out = format!(
1055 "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
1056 rooms.len(),
1057 total,
1058 short_hash(&knowledge.project_hash)
1059 );
1060 for (cat, count) in &rooms {
1061 out.push_str(&format!(" [{cat}] {count} fact(s)\n"));
1062 }
1063 out
1064}
1065
1066fn handle_bridge_publish(project_root: &str, session_id: &str) -> String {
1067 let knowledge = ProjectKnowledge::load_or_create(project_root);
1068 let published = lean_ctx::core::knowledge_bridge::KnowledgeBridge::mutate_locked(
1069 &knowledge.project_hash,
1070 |bridge| {
1071 (
1072 bridge.publish(session_id, &knowledge.facts),
1073 bridge.shared_facts.len(),
1074 )
1075 },
1076 );
1077 match published {
1078 Ok((count, total)) => {
1079 format!("Published {count} fact(s) to bridge (total: {total}, agent: {session_id})")
1080 }
1081 Err(e) => format!("Error: context publication was not saved: {e}"),
1082 }
1083}
1084
1085fn handle_bridge_pull(project_root: &str, session_id: &str) -> String {
1086 let project_hash = lean_ctx::core::project_hash::hash_project_root(project_root);
1087 let bridge = lean_ctx::core::knowledge_bridge::KnowledgeBridge::load_or_create(&project_hash);
1088 let entries = bridge.pull(session_id);
1089 if entries.is_empty() {
1090 return "No facts available from other agents.".to_string();
1091 }
1092
1093 let policy = match load_policy_or_error() {
1094 Ok(p) => p,
1095 Err(e) => return e,
1096 };
1097
1098 let missing = |target: &ProjectKnowledge,
1099 entry: &lean_ctx::core::knowledge_bridge::BridgeEntry| {
1100 let fact = lean_ctx::core::knowledge_bridge::KnowledgeBridge::entry_to_fact(entry);
1101 let present = target
1102 .facts
1103 .iter()
1104 .any(|f| f.is_current() && f.category == fact.category && f.key == fact.key);
1105 (!present).then_some(fact)
1106 };
1107 let current = ProjectKnowledge::load_or_create(project_root);
1109 if entries
1110 .iter()
1111 .all(|entry| missing(¤t, entry).is_none())
1112 {
1113 return format!(
1114 "Bridge has {} fact(s) from other agents, but all already exist locally.",
1115 entries.len()
1116 );
1117 }
1118
1119 let imported = ProjectKnowledge::mutate_locked(project_root, |target| {
1123 let mut imported = 0u32;
1124 for entry in &entries {
1125 if let Some(fact) = missing(target, entry) {
1126 target.remember(
1127 &fact.category,
1128 &fact.key,
1129 &fact.value,
1130 session_id,
1131 fact.confidence,
1132 &policy,
1133 );
1134 imported += 1;
1135 }
1136 }
1137 imported
1138 });
1139
1140 match imported {
1141 Ok((_, 0)) => format!(
1142 "Bridge has {} fact(s) from other agents, but all already exist locally.",
1143 entries.len()
1144 ),
1145 Ok((_, imported)) => format!(
1146 "Pulled {imported}/{} fact(s) from bridge into local knowledge.",
1147 entries.len()
1148 ),
1149 Err(e) => format!("Error: context import was not saved: {e}"),
1150 }
1151}
1152
1153fn handle_bridge_status(project_root: &str) -> String {
1154 let knowledge = ProjectKnowledge::load_or_create(project_root);
1155 let bridge =
1156 lean_ctx::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1157 bridge.summary()
1158}
1159
1160fn handle_wakeup(project_root: &str) -> String {
1161 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1162 return "No knowledge for wake-up briefing.".to_string();
1163 };
1164 let aaak = knowledge.format_aaak();
1165 if aaak.is_empty() {
1166 return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
1167 }
1168 format!("{WAKEUP_HEAD}{aaak}")
1169}
1170
1171const WAKEUP_HEAD: &str = "WAKE-UP BRIEFING:\n";
1172
1173fn wakeup_delta(held: Option<&str>, admitted: String) -> String {
1195 let holder = lean_ctx::core::archive::presented::shown_presenter_reference();
1196 wakeup_delta_for(holder.as_deref(), held, admitted)
1197}
1198
1199const WAKEUP_HASH_LINE: &str = "\n[memory hash ";
1200
1201fn wakeup_hash(briefing: &str) -> String {
1202 blake3::hash(briefing.as_bytes()).to_hex()[..16].to_string()
1203}
1204
1205fn wakeup_delta_for(holder: Option<&str>, held: Option<&str>, admitted: String) -> String {
1206 let Some(holder) = holder else {
1207 return admitted;
1209 };
1210 if !admitted.starts_with(WAKEUP_HEAD)
1211 || admitted.contains(lean_ctx::core::sanitize::FENCE_REFUSAL)
1212 {
1213 return admitted;
1214 }
1215 let hash = wakeup_hash(&admitted);
1216 if held.map(str::trim) == Some(hash.as_str()) && wakeup_released(holder, &hash) {
1217 return format!(
1218 "{WAKEUP_HEAD}[project memory unchanged since the briefing with hash {hash}]"
1219 );
1220 }
1221 wakeup_full(&admitted, &hash)
1222}
1223
1224fn wakeup_full(admitted: &str, hash: &str) -> String {
1226 format!(
1227 "{admitted}{WAKEUP_HASH_LINE}{hash} — while this briefing is still in your context, \
1228 ctx_knowledge(action=\"wakeup\", value=\"{hash}\") answers \"unchanged\" instead of repeating it]"
1229 )
1230}
1231
1232type WakeupReleases = std::sync::Arc<tokio::sync::RwLock<Vec<(String, String)>>>;
1233
1234fn wakeup_releases() -> &'static WakeupReleases {
1237 static RELEASED: std::sync::OnceLock<WakeupReleases> = std::sync::OnceLock::new();
1238 RELEASED.get_or_init(WakeupReleases::default)
1239}
1240
1241fn wakeup_released(holder: &str, hash: &str) -> bool {
1244 crate::server::bounded_lock::read(wakeup_releases(), "ctx_knowledge wakeup release read")
1245 .is_some_and(|released| {
1246 released
1247 .iter()
1248 .any(|(presenter, released_hash)| presenter == holder && released_hash == hash)
1249 })
1250}
1251
1252pub(crate) fn note_wakeup_release(delivered_blocks: &[&str]) {
1262 let Some(holder) = lean_ctx::core::archive::presented::shown_presenter_reference() else {
1263 return;
1264 };
1265 note_wakeup_release_for(&holder, delivered_blocks);
1266}
1267
1268fn note_wakeup_release_for(holder: &str, delivered_blocks: &[&str]) {
1269 const REMEMBERED: usize = 64;
1270 for block in delivered_blocks {
1271 let Some(answer) = block.find(WAKEUP_HEAD).map(|start| &block[start..]) else {
1274 continue;
1275 };
1276 let Some((briefing, _)) = answer.rsplit_once(WAKEUP_HASH_LINE) else {
1277 continue;
1278 };
1279 let hash = wakeup_hash(briefing);
1280 if !answer.starts_with(&wakeup_full(briefing, &hash)) {
1281 continue;
1282 }
1283 let entry = (holder.to_string(), hash);
1284 let Some(mut released) = crate::server::bounded_lock::write(
1286 wakeup_releases(),
1287 "ctx_knowledge wakeup release write",
1288 ) else {
1289 return;
1290 };
1291 if !released.contains(&entry) {
1292 if released.len() == REMEMBERED {
1293 released.remove(0);
1294 }
1295 released.push(entry);
1296 }
1297 }
1298}