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