1use chrono::{DateTime, Utc};
2
3#[cfg(feature = "embeddings")]
4use crate::core::embeddings::EmbeddingEngine;
5
6use crate::core::consolidation_engine::{ConsolidateOptions, ImportCounts, import_session_into};
7use crate::core::knowledge::ProjectKnowledge;
8use crate::core::memory_archive::MemoryStore;
9use crate::core::memory_capacity::{reclaim_preview, reclaim_store, reclaim_target};
10use crate::core::memory_lifecycle::LifecycleReport;
11use crate::core::memory_policy::MemoryPolicy;
12use crate::core::procedural_memory::{ProceduralStore, retention_cmp};
13use crate::core::session::SessionState;
14pub(crate) mod embeddings;
15pub(crate) use embeddings::*;
16mod remember;
17pub(crate) use remember::*;
18mod restore;
19pub(crate) use restore::{
20 DEFAULT_RESTORE_LIMIT, RestoreOptions, format_restore_report, run_restore,
21};
22mod search;
23pub(crate) use search::*;
24
25fn load_policy_or_error() -> Result<MemoryPolicy, String> {
26 super::knowledge_shared::load_policy_or_error()
27}
28
29#[derive(Debug, Default)]
30pub(crate) struct KnowledgeConsolidationReport {
31 pub session_id: Option<String>,
32 pub session_items: usize,
33 pub imported_decisions: usize,
34 pub imported_findings: usize,
35 pub facts: usize,
36 pub active_facts: usize,
37 pub archived_facts: usize,
38 pub fact_capacity_target: usize,
39 pub fact_capacity_archived: usize,
40 pub patterns: usize,
41 pub patterns_capacity_target: usize,
42 pub patterns_compacted: usize,
43 pub history: usize,
44 pub history_capacity_target: usize,
45 pub history_compacted: usize,
46 pub procedures: usize,
47 pub procedure_capacity_target: usize,
48 pub procedures_compacted: usize,
49 pub lifecycle: LifecycleReport,
50 pub dry_run: bool,
52}
53
54pub(crate) fn consolidate_project_knowledge(
58 project_root: &str,
59) -> Result<KnowledgeConsolidationReport, String> {
60 consolidate_project_knowledge_with(project_root, &ConsolidateOptions::manual())
61}
62
63pub(crate) fn consolidate_project_knowledge_with(
71 project_root: &str,
72 opts: &ConsolidateOptions,
73) -> Result<KnowledgeConsolidationReport, String> {
74 let policy = load_policy_or_error()?;
75 let session = if opts.import_session {
76 SessionState::load_latest_for_project_root(project_root)
77 } else {
78 None
79 };
80
81 if opts.dry_run {
82 return Ok(dry_run_report(
83 project_root,
84 session.as_ref(),
85 opts,
86 &policy,
87 ));
88 }
89
90 let watermark = if opts.incremental {
93 session.as_ref().and_then(|s| s.last_consolidate_ts)
94 } else {
95 None
96 };
97 if opts.incremental
98 && let Some(s) = session.as_ref()
99 && !has_new_session_items(s, watermark)
100 {
101 return Ok(KnowledgeConsolidationReport {
102 session_id: Some(s.id.clone()),
103 ..Default::default()
104 });
105 }
106
107 let (_knowledge, report) = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
108 run_consolidation_locked(knowledge, session.as_ref(), opts, &policy, watermark)
109 })
110 .map_err(|e| format!("Consolidation done but save failed: {e}"))?;
111 let report = report?;
112
113 if opts.incremental
115 && let Some(mut s) = session
116 {
117 s.last_consolidate_ts = Some(Utc::now());
118 let _ = s.save();
119 }
120
121 if opts.emit_event {
122 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
123 category: "memory".to_string(),
124 key: "consolidation".to_string(),
125 action: "run".to_string(),
126 });
127 }
128
129 Ok(report)
130}
131
132fn run_consolidation_locked(
134 knowledge: &mut ProjectKnowledge,
135 session: Option<&SessionState>,
136 opts: &ConsolidateOptions,
137 policy: &MemoryPolicy,
138 watermark: Option<DateTime<Utc>>,
139) -> Result<KnowledgeConsolidationReport, String> {
140 let mut imported = ImportCounts::default();
141 let mut session_id = None;
142 let mut history_compacted = 0usize;
143
144 if opts.import_session
145 && let Some(s) = session
146 {
147 session_id = Some(s.id.clone());
148 imported = import_session_into(knowledge, s, opts, policy, watermark);
149
150 let task_desc = s
151 .task
152 .as_ref()
153 .map_or_else(|| "(no task)".into(), |t| t.description.clone());
154 let summary = format!(
155 "Session {}: {} — {} findings, {} decisions consolidated",
156 s.id, task_desc, imported.findings, imported.decisions
157 );
158 history_compacted += knowledge.consolidate(&summary, vec![s.id.clone()], policy);
160 }
161
162 let lifecycle = if opts.run_lifecycle {
163 knowledge.run_memory_lifecycle(policy)
164 } else {
165 LifecycleReport::default()
166 };
167
168 let mut patterns_compacted = 0usize;
172 if opts.reclaim_stores {
173 patterns_compacted = reclaim_patterns(knowledge, policy);
174 history_compacted += reclaim_history(knowledge, policy);
175 }
176 let (procedures, procedure_capacity_target, procedures_compacted) = if opts.reclaim_stores {
177 reclaim_procedures(&knowledge.project_hash, policy)?
178 } else {
179 procedure_counts(&knowledge.project_hash, policy)
180 };
181
182 let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
183 let archived_facts = knowledge.facts.len().saturating_sub(active_facts);
184 let headroom = policy.lifecycle.reclaim_headroom_pct;
185
186 Ok(KnowledgeConsolidationReport {
187 session_id,
188 session_items: imported.total(),
189 imported_decisions: imported.decisions,
190 imported_findings: imported.findings,
191 facts: knowledge.facts.len(),
192 active_facts,
193 archived_facts,
194 fact_capacity_target: reclaim_target(policy.knowledge.max_facts, headroom),
195 fact_capacity_archived: lifecycle.capacity_archived,
196 patterns: knowledge.patterns.len(),
197 patterns_capacity_target: reclaim_target(policy.knowledge.max_patterns, headroom),
198 patterns_compacted,
199 history: knowledge.history.len(),
200 history_capacity_target: reclaim_target(policy.knowledge.max_history, headroom),
201 history_compacted,
202 procedures,
203 procedure_capacity_target,
204 procedures_compacted,
205 lifecycle,
206 dry_run: false,
207 })
208}
209
210fn dry_run_report(
214 project_root: &str,
215 session: Option<&SessionState>,
216 opts: &ConsolidateOptions,
217 policy: &MemoryPolicy,
218) -> KnowledgeConsolidationReport {
219 let mut knowledge =
220 ProjectKnowledge::load(project_root).unwrap_or_else(|| ProjectKnowledge::new(project_root));
221 let headroom = policy.lifecycle.reclaim_headroom_pct;
222 let enabled = policy.lifecycle.reclaim_enabled;
223
224 let mut imported = ImportCounts::default();
225 let mut session_id = None;
226 if opts.import_session
227 && let Some(s) = session
228 {
229 session_id = Some(s.id.clone());
230 let watermark = if opts.incremental {
231 s.last_consolidate_ts
232 } else {
233 None
234 };
235 imported = import_session_into(&mut knowledge, s, opts, policy, watermark);
238 }
239
240 let lifecycle = if opts.run_lifecycle {
243 let cfg = crate::core::memory_lifecycle::LifecycleConfig::from_policy(policy);
244 let decayed =
245 crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &cfg);
246 let consolidated = crate::core::memory_lifecycle::consolidate_similar(
247 &mut knowledge.facts,
248 cfg.consolidation_similarity,
249 );
250 let (quality, _) = crate::core::memory_lifecycle::compact(&mut knowledge.facts, &cfg);
251 let capacity_archived =
252 reclaim_preview(knowledge.facts.len(), cfg.max_facts, headroom, enabled);
253 LifecycleReport {
254 decayed_count: decayed,
255 consolidated_count: consolidated,
256 archived_count: quality + capacity_archived,
257 compacted_count: quality + capacity_archived,
258 capacity_archived,
259 remaining_facts: knowledge.facts.len().saturating_sub(capacity_archived),
260 }
261 } else {
262 LifecycleReport::default()
263 };
264
265 let history_compacted = reclaim_preview(
266 knowledge.history.len(),
267 policy.knowledge.max_history,
268 headroom,
269 enabled,
270 );
271 let patterns_compacted = reclaim_preview(
272 knowledge.patterns.len(),
273 policy.knowledge.max_patterns,
274 headroom,
275 enabled,
276 );
277 let procedures_len =
278 ProceduralStore::load(&knowledge.project_hash).map_or(0, |s| s.procedures.len());
279 let procedures_compacted = reclaim_preview(
280 procedures_len,
281 policy.procedural.max_procedures,
282 headroom,
283 enabled,
284 );
285
286 let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
287 let archived_facts = knowledge.facts.len().saturating_sub(active_facts);
288
289 KnowledgeConsolidationReport {
290 session_id,
291 session_items: imported.total(),
292 imported_decisions: imported.decisions,
293 imported_findings: imported.findings,
294 facts: knowledge.facts.len(),
295 active_facts,
296 archived_facts,
297 fact_capacity_target: reclaim_target(policy.knowledge.max_facts, headroom),
298 fact_capacity_archived: lifecycle.capacity_archived,
299 patterns: knowledge.patterns.len(),
300 patterns_capacity_target: reclaim_target(policy.knowledge.max_patterns, headroom),
301 patterns_compacted,
302 history: knowledge.history.len(),
303 history_capacity_target: reclaim_target(policy.knowledge.max_history, headroom),
304 history_compacted,
305 procedures: procedures_len,
306 procedure_capacity_target: reclaim_target(policy.procedural.max_procedures, headroom),
307 procedures_compacted,
308 lifecycle,
309 dry_run: true,
310 }
311}
312
313fn has_new_session_items(session: &SessionState, watermark: Option<DateTime<Utc>>) -> bool {
314 let is_new = |ts: DateTime<Utc>| watermark.is_none_or(|w| ts > w);
315 session.findings.iter().any(|f| is_new(f.timestamp))
316 || session.decisions.iter().any(|d| is_new(d.timestamp))
317}
318
319fn reclaim_history(knowledge: &mut ProjectKnowledge, policy: &MemoryPolicy) -> usize {
321 reclaim_store(
322 MemoryStore::History,
323 Some(&knowledge.project_hash),
324 &mut knowledge.history,
325 policy.knowledge.max_history,
326 policy.lifecycle.reclaim_headroom_pct,
327 policy.lifecycle.reclaim_enabled,
328 |a, b| {
329 b.timestamp
330 .cmp(&a.timestamp)
331 .then_with(|| b.summary.cmp(&a.summary))
332 },
333 )
334 .len()
335}
336
337fn reclaim_patterns(knowledge: &mut ProjectKnowledge, policy: &MemoryPolicy) -> usize {
339 reclaim_store(
340 MemoryStore::Patterns,
341 Some(&knowledge.project_hash),
342 &mut knowledge.patterns,
343 policy.knowledge.max_patterns,
344 policy.lifecycle.reclaim_headroom_pct,
345 policy.lifecycle.reclaim_enabled,
346 |a, b| {
347 b.created_at
348 .cmp(&a.created_at)
349 .then_with(|| a.pattern_type.cmp(&b.pattern_type))
350 .then_with(|| a.description.cmp(&b.description))
351 },
352 )
353 .len()
354}
355
356fn reclaim_procedures(
358 project_hash: &str,
359 policy: &MemoryPolicy,
360) -> Result<(usize, usize, usize), String> {
361 let target = reclaim_target(
362 policy.procedural.max_procedures,
363 policy.lifecycle.reclaim_headroom_pct,
364 );
365 let Some(mut store) = ProceduralStore::load(project_hash) else {
366 return Ok((0, target, 0));
367 };
368 let archived = reclaim_store(
369 MemoryStore::Procedures,
370 Some(project_hash),
371 &mut store.procedures,
372 policy.procedural.max_procedures,
373 policy.lifecycle.reclaim_headroom_pct,
374 policy.lifecycle.reclaim_enabled,
375 retention_cmp,
376 );
377 let compacted = archived.len();
378 if compacted > 0 {
379 store
380 .save()
381 .map_err(|e| format!("Procedure capacity compact failed: {e}"))?;
382 }
383 Ok((store.procedures.len(), target, compacted))
384}
385
386fn procedure_counts(project_hash: &str, policy: &MemoryPolicy) -> (usize, usize, usize) {
388 let target = reclaim_target(
389 policy.procedural.max_procedures,
390 policy.lifecycle.reclaim_headroom_pct,
391 );
392 let len = ProceduralStore::load(project_hash).map_or(0, |s| s.procedures.len());
393 (len, target, 0)
394}
395
396pub(crate) fn consolidate_all_project_knowledge_with(
399 opts: &ConsolidateOptions,
400) -> Result<Vec<(String, KnowledgeConsolidationReport)>, String> {
401 let roots = ProjectKnowledge::list_project_roots()?;
402 let mut reports = Vec::with_capacity(roots.len());
403 for root in roots {
404 let report = consolidate_project_knowledge_with(&root, opts)
405 .map_err(|e| format!("Consolidation failed for {}: {e}", project_label(&root)))?;
406 reports.push((root, report));
407 }
408 Ok(reports)
409}
410
411pub(crate) fn format_consolidation_report(report: &KnowledgeConsolidationReport) -> String {
412 let session_line = match report.session_id.as_deref() {
413 Some(session_id) => {
414 format!(
415 "Session import: {session_id} ({} item(s))",
416 report.session_items
417 )
418 }
419 None => "Session import: none (no active session)".to_string(),
420 };
421
422 let banner = if report.dry_run {
423 "DRY RUN — preview only, no changes written\n"
424 } else {
425 ""
426 };
427
428 let body = format!(
429 "{banner}{session_line}\n\
430 Facts: {} active, {} archived, {} total (target <= {}, archived-to-target {})\n\
431 Patterns: {} (target <= {}, compacted {}), History: {} (target <= {}, compacted {})\n\
432 Procedures: {} (target <= {}, compacted {})\n\
433 Lifecycle: decayed {}, consolidated {}, archived {}, compacted {}, remaining {}",
434 report.active_facts,
435 report.archived_facts,
436 report.facts,
437 report.fact_capacity_target,
438 report.fact_capacity_archived,
439 report.patterns,
440 report.patterns_capacity_target,
441 report.patterns_compacted,
442 report.history,
443 report.history_capacity_target,
444 report.history_compacted,
445 report.procedures,
446 report.procedure_capacity_target,
447 report.procedures_compacted,
448 report.lifecycle.decayed_count,
449 report.lifecycle.consolidated_count,
450 report.lifecycle.archived_count,
451 report.lifecycle.compacted_count,
452 report.lifecycle.remaining_facts
453 );
454
455 let archived_total = report.fact_capacity_archived
458 + report.patterns_compacted
459 + report.history_compacted
460 + report.procedures_compacted;
461 if archived_total > 0 {
462 let verb = if report.dry_run {
463 "would archive"
464 } else {
465 "archived"
466 };
467 format!(
468 "{body}\n{verb} {archived_total} item(s) — restore with: lean-ctx knowledge restore"
469 )
470 } else {
471 body
472 }
473}
474
475pub(crate) fn format_all_consolidation_reports(
476 reports: &[(String, KnowledgeConsolidationReport)],
477) -> String {
478 if reports.is_empty() {
479 return "No project knowledge stores found.".to_string();
480 }
481
482 let mut out = format!("Projects consolidated: {}", reports.len());
483 for (project_root, report) in reports {
484 out.push_str("\n\nProject: ");
485 out.push_str(project_label(project_root));
486 out.push('\n');
487 out.push_str(&format_consolidation_report(report));
488 }
489 out
490}
491
492fn project_label(project_root: &str) -> &str {
493 if project_root.trim().is_empty() {
494 "(empty project root)"
495 } else {
496 project_root
497 }
498}
499
500#[allow(clippy::too_many_arguments)]
502pub fn handle(
503 project_root: &str,
504 action: &str,
505 category: Option<&str>,
506 key: Option<&str>,
507 value: Option<&str>,
508 query: Option<&str>,
509 session_id: &str,
510 pattern_type: Option<&str>,
511 examples: Option<Vec<String>>,
512 confidence: Option<f32>,
513 mode: Option<&str>,
514 as_of: Option<&str>,
515) -> String {
516 match action {
517 "policy" => handle_policy(value),
518 "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
519 "recall" => handle_recall(project_root, category, query, session_id, mode, as_of),
520 "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
521 "feedback" => handle_feedback(project_root, category, key, value, session_id),
522 "relate" => crate::tools::ctx_knowledge_relations::handle_relate(
523 project_root,
524 category,
525 key,
526 value,
527 query,
528 session_id,
529 ),
530 "unrelate" => crate::tools::ctx_knowledge_relations::handle_unrelate(
531 project_root,
532 category,
533 key,
534 value,
535 query,
536 ),
537 "relations" => crate::tools::ctx_knowledge_relations::handle_relations(
538 project_root,
539 category,
540 key,
541 value,
542 query,
543 ),
544 "relations_diagram" => crate::tools::ctx_knowledge_relations::handle_relations_diagram(
545 project_root,
546 category,
547 key,
548 value,
549 query,
550 ),
551 "status" => handle_status(project_root),
552 "health" => handle_health(project_root),
553 "lifecycle_report" => handle_lifecycle_report(project_root),
554 "remove" => handle_remove(project_root, category, key),
555 "export" => handle_export(project_root),
556 "consolidate" => handle_consolidate(project_root),
557 "consolidate_preview" => handle_consolidate_preview(project_root),
558 "restore" => handle_restore(project_root, category, query, None),
559 "timeline" => handle_timeline(project_root, category),
560 "rooms" => handle_rooms(project_root),
561 "search" => handle_search(query),
562 "wakeup" => handle_wakeup(project_root),
563 "embeddings_status" => handle_embeddings_status(project_root),
564 "embeddings_reset" => handle_embeddings_reset(project_root),
565 "embeddings_reindex" => handle_embeddings_reindex(project_root),
566 "judge" => handle_judge(project_root, category, key, value, query),
567 "cognition_loop" => handle_cognition_loop(project_root),
568 "bridge_publish" => handle_bridge_publish(project_root, session_id),
569 "bridge_pull" => handle_bridge_pull(project_root, session_id),
570 "bridge_status" => handle_bridge_status(project_root),
571 _ => format!(
572 "Unknown action: {action}. Use: policy, remember, recall, pattern, feedback, judge, relate, unrelate, relations, relations_diagram, status, health, lifecycle_report, remove, export, consolidate, consolidate_preview, restore, timeline, rooms, search, wakeup, embeddings_status, embeddings_reset, embeddings_reindex, cognition_loop, bridge_publish, bridge_pull, bridge_status"
573 ),
574 }
575}
576
577fn handle_policy(value: Option<&str>) -> String {
578 let sub = value.unwrap_or("show").trim().to_lowercase();
579 let profile = crate::core::profiles::active_profile_name();
580
581 match sub.as_str() {
582 "show" => {
583 let policy = match load_policy_or_error() {
584 Ok(p) => p,
585 Err(e) => return e,
586 };
587
588 let cfg_path = crate::core::config::Config::path().map_or_else(
589 || "~/.lean-ctx/config.toml".to_string(),
590 |p| p.display().to_string(),
591 );
592
593 format!(
594 "Knowledge policy (effective, profile={profile}):\n\
595 - memory.knowledge.max_facts={}\n\
596 - memory.knowledge.contradiction_threshold={}\n\
597 - memory.knowledge.recall_facts_limit={}\n\
598 - memory.knowledge.rooms_limit={}\n\
599 - memory.knowledge.timeline_limit={}\n\
600 - memory.knowledge.relations_limit={}\n\
601 - memory.lifecycle.decay_rate={}\n\
602 - memory.lifecycle.stale_days={}\n\
603 \nConfig: {cfg_path}",
604 policy.knowledge.max_facts,
605 policy.knowledge.contradiction_threshold,
606 policy.knowledge.recall_facts_limit,
607 policy.knowledge.rooms_limit,
608 policy.knowledge.timeline_limit,
609 policy.knowledge.relations_limit,
610 policy.lifecycle.decay_rate,
611 policy.lifecycle.stale_days
612 )
613 }
614 "validate" => match load_policy_or_error() {
615 Ok(_) => format!("OK: memory policy valid (profile={profile})"),
616 Err(e) => e,
617 },
618 _ => "Error: policy value must be show|validate".to_string(),
619 }
620}
621
622fn handle_feedback(
623 project_root: &str,
624 category: Option<&str>,
625 key: Option<&str>,
626 value: Option<&str>,
627 session_id: &str,
628) -> String {
629 let Some(cat) = category else {
630 return "Error: category is required for feedback".to_string();
631 };
632 let Some(k) = key else {
633 return "Error: key is required for feedback".to_string();
634 };
635 let dir = value.unwrap_or("up").trim().to_lowercase();
636 let is_up = matches!(dir.as_str(), "up" | "+1" | "+" | "true" | "1");
637 let is_down = matches!(dir.as_str(), "down" | "-1" | "-" | "false" | "0");
638 if !is_up && !is_down {
639 return "Error: feedback value must be up|down (+1|-1)".to_string();
640 }
641
642 let outcome = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
645 let Some(f) = knowledge
646 .facts
647 .iter_mut()
648 .find(|f| f.is_current() && f.category == cat && f.key == k)
649 else {
650 return Err(format!("No current fact found: [{cat}] {k}"));
651 };
652
653 if is_up {
654 f.feedback_up = f.feedback_up.saturating_add(1);
655 } else {
656 f.feedback_down = f.feedback_down.saturating_add(1);
657 }
658 f.last_feedback = Some(Utc::now());
659 Ok((
660 f.quality_score(),
661 f.feedback_up,
662 f.feedback_down,
663 f.confidence,
664 ))
665 });
666
667 let (quality, up, down, conf) = match outcome {
668 Ok((_, Ok(vals))) => vals,
669 Ok((_, Err(msg))) => return msg,
670 Err(e) => return format!("Feedback recorded but save failed: {e}"),
671 };
672
673 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
674 category: cat.to_string(),
675 key: k.to_string(),
676 action: if is_up {
677 "feedback_up"
678 } else {
679 "feedback_down"
680 }
681 .to_string(),
682 });
683
684 format!(
685 "Feedback recorded ({dir}) for [{cat}] {k} (up={up}, down={down}, quality={quality:.2}, confidence={conf:.2}, session={session_id})"
686 )
687}
688
689fn handle_judge(
690 project_root: &str,
691 category: Option<&str>,
692 key: Option<&str>,
693 value: Option<&str>,
694 query: Option<&str>,
695) -> String {
696 let source = match (category, key) {
697 (Some(cat), Some(k)) => format!("{cat}/{k}"),
698 _ => {
699 if let Some(k) = key.or(category) {
700 if k.contains('/') {
701 k.to_string()
702 } else {
703 return "Error: judge requires key as 'category/key' (source fact)".to_string();
704 }
705 } else {
706 return "Error: judge requires category+key (source fact) and value (target 'category/key')"
707 .to_string();
708 }
709 }
710 };
711
712 let Some(target) = value else {
713 return "Error: judge requires value as target 'category/key'".to_string();
714 };
715 let target = target.trim().to_string();
716 if !target.contains('/') {
717 return "Error: target must be 'category/key' format".to_string();
718 }
719
720 let verdict = query.unwrap_or("compatible").trim().to_lowercase();
721 if !matches!(verdict.as_str(), "supersedes" | "compatible" | "unrelated") {
722 return format!("Error: verdict must be supersedes|compatible|unrelated, got '{verdict}'");
723 }
724
725 let result = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
727 let source_exists = {
728 let parts: Vec<&str> = source.splitn(2, '/').collect();
729 parts.len() == 2
730 && knowledge
731 .facts
732 .iter()
733 .any(|f| f.category == parts[0] && f.key == parts[1] && f.is_current())
734 };
735 if !source_exists {
736 return Err(format!("Error: no current fact found for '{source}'"));
737 }
738
739 let target_parts: Vec<&str> = target.splitn(2, '/').collect();
740 if target_parts.len() != 2 {
741 return Err(format!("Error: invalid target format '{target}'"));
742 }
743 let (tcat, tkey) = (target_parts[0], target_parts[1]);
744
745 let target_exists = knowledge
746 .facts
747 .iter()
748 .any(|f| f.category == tcat && f.key == tkey && f.is_current());
749 if !target_exists {
750 return Err(format!("Error: no current fact found for '{target}'"));
751 }
752
753 if verdict == "supersedes" {
754 let now = Utc::now();
755 if let Some(tf) = knowledge
756 .facts
757 .iter_mut()
758 .find(|f| f.category == tcat && f.key == tkey && f.is_current())
759 {
760 tf.valid_until = Some(now);
761 tf.valid_from = tf.valid_from.or(Some(tf.created_at));
762 }
763 }
764
765 knowledge
766 .judged_pairs
767 .push(crate::core::knowledge::JudgedPair {
768 key_a: source.clone(),
769 key_b: target.clone(),
770 verdict: verdict.clone(),
771 judged_at: Utc::now(),
772 });
773 Ok(())
774 });
775
776 match result {
777 Ok((_, Ok(()))) => {}
778 Ok((_, Err(msg))) => return msg,
779 Err(e) => return format!("Error: judge save failed: {e}"),
780 }
781
782 let action_desc = match verdict.as_str() {
783 "supersedes" => format!("{source} supersedes {target} (target archived)"),
784 "compatible" => format!("{source} ↔ {target} (compatible, suppressed from future similar)"),
785 "unrelated" => format!("{source} ≠ {target} (unrelated, suppressed from future similar)"),
786 _ => unreachable!(),
787 };
788
789 format!("Judged: {action_desc}")
790}
791
792fn handle_pattern(
793 project_root: &str,
794 pattern_type: Option<&str>,
795 value: Option<&str>,
796 examples: Option<Vec<String>>,
797 session_id: &str,
798) -> String {
799 let Some(pt) = pattern_type else {
800 return "Error: pattern_type is required".to_string();
801 };
802 let Some(desc) = value else {
803 return "Error: value (description) is required for pattern".to_string();
804 };
805 let exs = examples.unwrap_or_default();
806 let policy = match crate::core::config::Config::load().memory_policy_effective() {
807 Ok(p) => p,
808 Err(e) => {
809 let path = crate::core::config::Config::path().map_or_else(
810 || "~/.lean-ctx/config.toml".to_string(),
811 |p| p.display().to_string(),
812 );
813 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
814 }
815 };
816 match ProjectKnowledge::mutate_locked(project_root, |knowledge| {
818 knowledge.add_pattern(pt, desc, exs, session_id, &policy);
819 }) {
820 Ok(_) => format!("Pattern [{pt}] added: {desc}"),
821 Err(e) => format!("Pattern add failed: {e}"),
822 }
823}
824
825fn handle_status(project_root: &str) -> String {
826 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
827 return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
828 };
829
830 let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
831 let archived_facts = knowledge.facts.len() - current_facts;
832
833 let mut out = format!(
834 "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
835 current_facts,
836 archived_facts,
837 knowledge.patterns.len(),
838 knowledge.history.len()
839 );
840 out.push_str(&format!(
841 "Last updated: {}\n",
842 knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
843 ));
844
845 let rooms = knowledge.list_rooms();
846 if !rooms.is_empty() {
847 out.push_str("Rooms: ");
848 let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
849 out.push_str(&room_strs.join(", "));
850 out.push('\n');
851 }
852
853 out.push_str(&knowledge.format_summary());
854 out
855}
856
857fn handle_lifecycle_report(project_root: &str) -> String {
860 let policy = match load_policy_or_error() {
861 Ok(p) => p,
862 Err(e) => return e,
863 };
864 let hash = crate::core::project_hash::hash_project_root(project_root);
865
866 let mut out = String::from("=== Memory Lifecycle Report ===\n");
867
868 match ProjectKnowledge::load(project_root) {
870 Some(k) => {
871 let active = k.facts.iter().filter(|f| f.is_current()).count();
872 let archived = k.facts.len() - active;
873 let cap = policy.knowledge.max_facts;
874 let fill_pct = (active * 100).checked_div(cap).unwrap_or(0);
875 out.push_str(&format!(
876 "knowledge {active} active / {archived} archived (cap {cap}, {fill_pct}% full)\n\
877 \x20 decay {}/day, stale >{}d, consolidate-sim {:.2}\n\
878 \x20 GC: self-limiting on remember when >{cap}; eviction archives, never deletes\n",
879 policy.lifecycle.decay_rate,
880 policy.lifecycle.stale_days,
881 policy.lifecycle.similarity_threshold,
882 ));
883 }
884 None => out.push_str("knowledge (no store yet)\n"),
885 }
886
887 let archives = crate::core::memory_lifecycle::list_archives();
889 out.push_str(&format!(
890 "archives {} file(s); auto-rehydrated when recall misses\n",
891 archives.len()
892 ));
893
894 {
896 let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
897 let cap = policy.episodic.max_episodes;
898 out.push_str(&format!(
899 "episodic {} episode(s) (cap {cap}, {} actions/episode max)\n",
900 store.episodes.len(),
901 policy.episodic.max_actions_per_episode,
902 ));
903 }
904
905 {
907 let store = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
908 out.push_str(&format!(
909 "procedural {} procedure(s) (cap {}, learned at >={} repetitions)\n",
910 store.procedures.len(),
911 policy.procedural.max_procedures,
912 policy.procedural.min_repetitions,
913 ));
914 }
915
916 #[cfg(feature = "embeddings")]
918 {
919 let n = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(&hash)
920 .map_or(0, |idx| idx.entries.len());
921 out.push_str(&format!(
922 "embeddings {n} vector(s); compacted against knowledge on remember\n"
923 ));
924 }
925 #[cfg(not(feature = "embeddings"))]
926 out.push_str("embeddings (feature disabled in this build)\n");
927
928 out.push_str(
929 "\nLayer boundaries: session = working memory (now) | knowledge/episodic/procedural = long-term (ETL via consolidate) | providers = external (read-through)\n",
930 );
931 out
932}
933
934fn handle_health(project_root: &str) -> String {
935 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
936 return "No knowledge stored. Nothing to report.".to_string();
937 };
938
939 let total = knowledge.facts.len();
940 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
941 let archived = total - current.len();
942
943 let mut low_quality = 0u32;
944 let mut high_quality = 0u32;
945 let mut stale_candidates = 0u32;
946 let mut total_quality: f32 = 0.0;
947 let mut never_retrieved = 0u32;
948 let mut room_counts: std::collections::HashMap<String, (u32, f32)> =
949 std::collections::HashMap::new();
950
951 let now = chrono::Utc::now();
952 for f in ¤t {
953 let q = f.quality_score();
954 total_quality += q;
955 if q < 0.4 {
956 low_quality += 1;
957 } else if q >= 0.8 {
958 high_quality += 1;
959 }
960 if f.retrieval_count == 0 {
961 never_retrieved += 1;
962 }
963 let age_days = (now - f.created_at).num_days();
964 if age_days > 30 && f.retrieval_count == 0 {
965 stale_candidates += 1;
966 }
967
968 let entry = room_counts.entry(f.category.clone()).or_insert((0, 0.0));
969 entry.0 += 1;
970 entry.1 += q;
971 }
972
973 let avg_quality = if current.is_empty() {
974 0.0
975 } else {
976 total_quality / current.len() as f32
977 };
978
979 let mut out = String::from("=== Knowledge Health Report ===\n");
980 out.push_str(&format!(
981 "Total: {} facts ({} active, {} archived)\n",
982 total,
983 current.len(),
984 archived
985 ));
986 out.push_str(&format!("Avg Quality: {avg_quality:.2}\n"));
987 out.push_str(&format!(
988 "Distribution: {high_quality} high (>=0.8) | {low_quality} low (<0.4)\n"
989 ));
990 out.push_str(&format!(
991 "Stale (>30d, never retrieved): {stale_candidates}\n"
992 ));
993 out.push_str(&format!("Never retrieved: {never_retrieved}\n"));
994
995 if !room_counts.is_empty() {
996 out.push_str("\nRoom Balance:\n");
997 let mut rooms: Vec<_> = room_counts.into_iter().collect();
998 rooms.sort_by_key(|x| std::cmp::Reverse(x.1.0));
999 for (cat, (count, total_q)) in &rooms {
1000 let avg = if *count > 0 {
1001 total_q / *count as f32
1002 } else {
1003 0.0
1004 };
1005 out.push_str(&format!(" {cat}: {count} facts, avg quality {avg:.2}\n"));
1006 }
1007 }
1008
1009 let policy = crate::core::config::Config::load()
1010 .memory_policy_effective()
1011 .unwrap_or_default();
1012 out.push_str(&format!(
1013 "\nPolicy: max {} facts, max {} patterns\n",
1014 policy.knowledge.max_facts, policy.knowledge.max_patterns
1015 ));
1016
1017 if current.len() > policy.knowledge.max_facts {
1018 out.push_str(&format!(
1019 "WARNING: Active facts ({}) exceed policy max ({})\n",
1020 current.len(),
1021 policy.knowledge.max_facts
1022 ));
1023 }
1024
1025 out
1026}
1027
1028fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
1029 let Some(cat) = category else {
1030 return "Error: category is required for remove".to_string();
1031 };
1032 let Some(k) = key else {
1033 return "Error: key is required for remove".to_string();
1034 };
1035 let policy = match crate::core::config::Config::load().memory_policy_effective() {
1036 Ok(p) => p,
1037 Err(e) => {
1038 let path = crate::core::config::Config::path().map_or_else(
1039 || "~/.lean-ctx/config.toml".to_string(),
1040 |p| p.display().to_string(),
1041 );
1042 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
1043 }
1044 };
1045 let (knowledge, removed) = match ProjectKnowledge::mutate_locked(project_root, |knowledge| {
1049 if knowledge.remove_fact(cat, k) {
1050 let _ = knowledge.run_memory_lifecycle(&policy);
1051 true
1052 } else {
1053 false
1054 }
1055 }) {
1056 Ok(pair) => pair,
1057 Err(e) => return format!("Removed but save failed: {e}"),
1058 };
1059
1060 if !removed {
1061 return format!("No fact found: [{cat}] {k}");
1062 }
1063
1064 #[cfg(feature = "embeddings")]
1065 {
1066 ProjectKnowledge::with_project_lock(project_root, || {
1070 if let Some(mut idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
1071 &knowledge.project_hash,
1072 ) {
1073 idx.remove(cat, k);
1074 let fresh = ProjectKnowledge::load(project_root);
1075 let kref = fresh.as_ref().unwrap_or(&knowledge);
1076 crate::core::knowledge_embedding::compact_against_knowledge(
1077 &mut idx, kref, &policy,
1078 );
1079 let _ = idx.save();
1080 }
1081 });
1082 }
1083 #[cfg(not(feature = "embeddings"))]
1084 let _ = &knowledge;
1085
1086 format!("Removed [{cat}] {k}")
1087}
1088
1089fn handle_export(project_root: &str) -> String {
1090 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1091 return "No knowledge to export.".to_string();
1092 };
1093 let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
1094 Ok(d) => d,
1095 Err(e) => return format!("Export failed: {e}"),
1096 };
1097
1098 let export_dir = data_dir.join("exports").join("knowledge");
1099 let ts = Utc::now().format("%Y%m%d-%H%M%S");
1100 let filename = format!(
1101 "knowledge-{}-{ts}.json",
1102 short_hash(&knowledge.project_hash)
1103 );
1104 let path = export_dir.join(filename);
1105
1106 match serde_json::to_string_pretty(&knowledge) {
1107 Ok(mut json) => {
1108 json.push('\n');
1109 match crate::config_io::write_atomic_with_backup(&path, &json) {
1110 Ok(()) => format!(
1111 "Export saved: {} (active facts: {}, patterns: {}, history: {})",
1112 path.display(),
1113 knowledge.facts.iter().filter(|f| f.is_current()).count(),
1114 knowledge.patterns.len(),
1115 knowledge.history.len()
1116 ),
1117 Err(e) => format!("Export failed: {e}"),
1118 }
1119 }
1120 Err(e) => format!("Export failed: {e}"),
1121 }
1122}
1123
1124fn handle_consolidate(project_root: &str) -> String {
1125 match consolidate_project_knowledge(project_root) {
1126 Ok(report) => format_consolidation_report(&report),
1127 Err(e) => e,
1128 }
1129}
1130
1131fn handle_consolidate_preview(project_root: &str) -> String {
1133 match consolidate_project_knowledge_with(
1134 project_root,
1135 &ConsolidateOptions::manual().into_dry_run(),
1136 ) {
1137 Ok(report) => format_consolidation_report(&report),
1138 Err(e) => e,
1139 }
1140}
1141
1142fn handle_restore(
1146 project_root: &str,
1147 store: Option<&str>,
1148 query: Option<&str>,
1149 limit: Option<usize>,
1150) -> String {
1151 let store = match store {
1152 Some(s) => match crate::core::memory_archive::MemoryStore::parse(s) {
1153 Some(ms) => Some(ms),
1154 None => {
1155 return format!("Unknown store: {s}. Use: facts, history, procedures, patterns");
1156 }
1157 },
1158 None => None,
1159 };
1160 let opts = RestoreOptions::new(
1161 store,
1162 query.map(str::to_string),
1163 limit.unwrap_or(DEFAULT_RESTORE_LIMIT),
1164 );
1165 match run_restore(project_root, &opts) {
1166 Ok(report) => format_restore_report(&report),
1167 Err(e) => e,
1168 }
1169}
1170
1171fn handle_timeline(project_root: &str, category: Option<&str>) -> String {
1172 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1173 return "No knowledge stored yet.".to_string();
1174 };
1175
1176 let policy = match load_policy_or_error() {
1177 Ok(p) => p,
1178 Err(e) => return e,
1179 };
1180
1181 let Some(cat) = category else {
1182 return "Error: category is required for timeline".to_string();
1183 };
1184
1185 let facts = knowledge.timeline(cat);
1186 if facts.is_empty() {
1187 return format!("No history for category '{cat}'.");
1188 }
1189
1190 let mut ordered: Vec<&crate::core::knowledge::KnowledgeFact> = facts;
1191 ordered.sort_by(|a, b| {
1192 let a_start = a.valid_from.unwrap_or(a.created_at);
1193 let b_start = b.valid_from.unwrap_or(b.created_at);
1194 a_start
1195 .cmp(&b_start)
1196 .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
1197 .then_with(|| a.key.cmp(&b.key))
1198 .then_with(|| a.value.cmp(&b.value))
1199 });
1200
1201 let total = ordered.len();
1202 let limit = policy.knowledge.timeline_limit;
1203 if ordered.len() > limit {
1204 ordered = ordered[ordered.len() - limit..].to_vec();
1205 }
1206
1207 let mut out = format!(
1208 "Timeline [{cat}] (showing {}/{} entries):\n",
1209 ordered.len(),
1210 total
1211 );
1212 for f in &ordered {
1213 let status = if f.is_current() {
1214 "CURRENT"
1215 } else {
1216 "archived"
1217 };
1218 let valid_range = match (f.valid_from, f.valid_until) {
1219 (Some(from), Some(until)) => format!(
1220 "{} → {}",
1221 from.format("%Y-%m-%d %H:%M"),
1222 until.format("%Y-%m-%d %H:%M")
1223 ),
1224 (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
1225 _ => "unknown".to_string(),
1226 };
1227 out.push_str(&format!(
1228 " {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
1229 f.key,
1230 f.value,
1231 f.confidence * 100.0,
1232 f.confirmation_count
1233 ));
1234 }
1235 out
1236}
1237
1238fn handle_rooms(project_root: &str) -> String {
1239 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1240 return "No knowledge stored yet.".to_string();
1241 };
1242
1243 let policy = match load_policy_or_error() {
1244 Ok(p) => p,
1245 Err(e) => return e,
1246 };
1247
1248 let rooms = knowledge.list_rooms();
1249 if rooms.is_empty() {
1250 return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
1251 }
1252
1253 let mut rooms = rooms;
1254 rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1255 let total = rooms.len();
1256 rooms.truncate(policy.knowledge.rooms_limit);
1257
1258 let mut out = format!(
1259 "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
1260 rooms.len(),
1261 total,
1262 short_hash(&knowledge.project_hash)
1263 );
1264 for (cat, count) in &rooms {
1265 out.push_str(&format!(" [{cat}] {count} fact(s)\n"));
1266 }
1267 out
1268}
1269
1270fn handle_cognition_loop(project_root: &str) -> String {
1271 let cfg = crate::core::config::Config::load().autonomy;
1272 if !cfg.cognition_loop_enabled {
1273 return "Cognition loop is disabled (autonomy.cognition_loop_enabled=false).".to_string();
1274 }
1275 let max_steps = cfg.cognition_loop_max_steps;
1276 let report = crate::core::cognition_loop::run_cognition_loop(project_root, max_steps);
1277 format!("{report}")
1278}
1279
1280fn handle_bridge_publish(project_root: &str, session_id: &str) -> String {
1281 let knowledge = ProjectKnowledge::load_or_create(project_root);
1282 let mut bridge =
1283 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1284 let count = bridge.publish(session_id, &knowledge.facts);
1285 match bridge.save() {
1286 Ok(()) => format!(
1287 "Published {count} fact(s) to bridge (total: {}, agent: {session_id})",
1288 bridge.shared_facts.len()
1289 ),
1290 Err(e) => format!("Published {count} fact(s) but save failed: {e}"),
1291 }
1292}
1293
1294fn handle_bridge_pull(project_root: &str, session_id: &str) -> String {
1295 let knowledge = ProjectKnowledge::load_or_create(project_root);
1296 let bridge =
1297 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1298 let entries = bridge.pull(session_id);
1299 if entries.is_empty() {
1300 return "No facts available from other agents.".to_string();
1301 }
1302
1303 let policy = match load_policy_or_error() {
1304 Ok(p) => p,
1305 Err(e) => return e,
1306 };
1307
1308 let mut target = knowledge;
1309 let mut imported = 0u32;
1310 for entry in &entries {
1311 let fact = crate::core::knowledge_bridge::KnowledgeBridge::entry_to_fact(entry);
1312 let existing = target
1313 .facts
1314 .iter()
1315 .any(|f| f.is_current() && f.category == fact.category && f.key == fact.key);
1316 if !existing {
1317 target.remember(
1318 &fact.category,
1319 &fact.key,
1320 &fact.value,
1321 session_id,
1322 fact.confidence,
1323 &policy,
1324 );
1325 imported += 1;
1326 }
1327 }
1328
1329 if imported == 0 {
1330 return format!(
1331 "Bridge has {} fact(s) from other agents, but all already exist locally.",
1332 entries.len()
1333 );
1334 }
1335
1336 match target.save() {
1337 Ok(()) => format!(
1338 "Pulled {imported}/{} fact(s) from bridge into local knowledge.",
1339 entries.len()
1340 ),
1341 Err(e) => format!("Pulled {imported} fact(s) but save failed: {e}"),
1342 }
1343}
1344
1345fn handle_bridge_status(project_root: &str) -> String {
1346 let knowledge = ProjectKnowledge::load_or_create(project_root);
1347 let bridge =
1348 crate::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1349 bridge.summary()
1350}
1351
1352fn handle_wakeup(project_root: &str) -> String {
1353 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1354 return "No knowledge for wake-up briefing.".to_string();
1355 };
1356 let aaak = knowledge.format_aaak();
1357 if aaak.is_empty() {
1358 return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
1359 }
1360 format!("WAKE-UP BRIEFING:\n{aaak}")
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365 use super::*;
1366 use crate::core::procedural_memory::Procedure;
1367
1368 struct CurrentDirGuard {
1369 previous: std::path::PathBuf,
1370 _lock: std::sync::MutexGuard<'static, ()>,
1371 }
1372
1373 impl CurrentDirGuard {
1374 fn enter(dir: &std::path::Path) -> Self {
1375 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1376 let lock = LOCK.get_or_init(|| std::sync::Mutex::new(()));
1377 let guard = lock
1378 .lock()
1379 .unwrap_or_else(std::sync::PoisonError::into_inner);
1380 let previous = std::env::current_dir().unwrap();
1381 std::env::set_current_dir(dir).unwrap();
1382 Self {
1383 previous,
1384 _lock: guard,
1385 }
1386 }
1387 }
1388
1389 impl Drop for CurrentDirGuard {
1390 fn drop(&mut self) {
1391 std::env::set_current_dir(&self.previous).unwrap();
1392 }
1393 }
1394
1395 struct DataDirGuard;
1396
1397 impl DataDirGuard {
1398 fn set(path: &std::path::Path) -> Self {
1399 crate::test_env::set_var("LEAN_CTX_DATA_DIR", path);
1400 Self
1401 }
1402 }
1403
1404 impl Drop for DataDirGuard {
1405 fn drop(&mut self) {
1406 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1407 }
1408 }
1409
1410 fn report(session_id: Option<String>, session_items: usize) -> KnowledgeConsolidationReport {
1411 KnowledgeConsolidationReport {
1412 session_id,
1413 session_items,
1414 imported_decisions: session_items / 2,
1415 imported_findings: session_items - session_items / 2,
1416 facts: 7,
1417 active_facts: 5,
1418 archived_facts: 2,
1419 fact_capacity_target: 6,
1420 fact_capacity_archived: 1,
1421 patterns: 2,
1422 patterns_capacity_target: 6,
1423 patterns_compacted: 0,
1424 history: 3,
1425 history_capacity_target: 6,
1426 history_compacted: 1,
1427 procedures: 4,
1428 procedure_capacity_target: 6,
1429 procedures_compacted: 2,
1430 lifecycle: LifecycleReport {
1431 decayed_count: 1,
1432 consolidated_count: 2,
1433 archived_count: 3,
1434 compacted_count: 4,
1435 capacity_archived: 1,
1436 remaining_facts: 5,
1437 },
1438 dry_run: false,
1439 }
1440 }
1441
1442 #[test]
1443 fn consolidation_report_marks_no_session_import() {
1444 let out = format_consolidation_report(&report(None, 0));
1445
1446 assert!(out.contains("Session import: none (no active session)"));
1447 assert!(out.contains("Lifecycle: decayed 1, consolidated 2"));
1448 }
1449
1450 #[test]
1451 fn consolidation_report_includes_session_and_lifecycle_stats() {
1452 let out = format_consolidation_report(&report(Some("s1".to_string()), 6));
1453
1454 assert!(out.contains("Session import: s1 (6 item(s))"));
1455 assert!(
1456 out.contains(
1457 "Facts: 5 active, 2 archived, 7 total (target <= 6, archived-to-target 1)"
1458 )
1459 );
1460 assert!(out.contains(
1461 "Patterns: 2 (target <= 6, compacted 0), History: 3 (target <= 6, compacted 1)"
1462 ));
1463 assert!(out.contains("Procedures: 4 (target <= 6, compacted 2)"));
1464 assert!(out.contains("archived 3, compacted 4, remaining 5"));
1465 assert!(out.contains("restore with: lean-ctx knowledge restore"));
1467 }
1468
1469 fn test_procedure(id: usize, confidence: f32) -> Procedure {
1470 Procedure {
1471 id: format!("p-{id}"),
1472 name: format!("workflow-{id}"),
1473 description: "test workflow".to_string(),
1474 steps: Vec::new(),
1475 activation_keywords: Vec::new(),
1476 confidence,
1477 times_used: id as u32,
1478 times_succeeded: id as u32,
1479 last_used: Utc::now(),
1480 project_specific: true,
1481 created_at: Utc::now(),
1482 }
1483 }
1484
1485 #[test]
1486 fn consolidation_compacts_procedures_above_target() {
1487 let _env_lock = crate::core::data_dir::test_env_lock();
1488 let data_dir = tempfile::tempdir().unwrap();
1489 let _data_dir = DataDirGuard::set(data_dir.path());
1490 let project = tempfile::tempdir().unwrap();
1491 let root = project.path().to_string_lossy().to_string();
1492 let project_hash = ProjectKnowledge::new(&root).project_hash;
1493 let mut store = ProceduralStore::new(&project_hash);
1494 for i in 0..100 {
1497 store.procedures.push(test_procedure(i, i as f32 / 100.0));
1498 }
1499 store.save().unwrap();
1500
1501 let report = consolidate_project_knowledge(&root).unwrap();
1502 let reloaded = ProceduralStore::load(&project_hash).unwrap();
1503
1504 assert_eq!(report.procedures, 75);
1505 assert_eq!(report.procedure_capacity_target, 75);
1506 assert_eq!(report.procedures_compacted, 25);
1507 assert_eq!(reloaded.procedures.len(), 75);
1508 assert!(!reloaded.procedures.iter().any(|p| p.id == "p-0"));
1510 assert!(!reloaded.procedures.iter().any(|p| p.id == "p-24"));
1511 assert!(reloaded.procedures.iter().any(|p| p.id == "p-99"));
1512 }
1513
1514 #[test]
1515 fn consolidate_dry_run_previews_without_mutating() {
1516 let _env_lock = crate::core::data_dir::test_env_lock();
1517 let data_dir = tempfile::tempdir().unwrap();
1518 let _data_dir = DataDirGuard::set(data_dir.path());
1519 let project = tempfile::tempdir().unwrap();
1520 let root = project.path().to_string_lossy().to_string();
1521 let project_hash = ProjectKnowledge::new(&root).project_hash;
1522
1523 let mut store = ProceduralStore::new(&project_hash);
1524 for i in 0..100 {
1525 store.procedures.push(test_procedure(i, i as f32 / 100.0));
1526 }
1527 store.save().unwrap();
1528
1529 let report =
1530 consolidate_project_knowledge_with(&root, &ConsolidateOptions::manual().into_dry_run())
1531 .unwrap();
1532
1533 assert!(report.dry_run);
1535 assert_eq!(report.procedures, 100);
1536 assert_eq!(report.procedures_compacted, 25);
1537 assert!(format_consolidation_report(&report).contains("DRY RUN"));
1538
1539 let reloaded = ProceduralStore::load(&project_hash).unwrap();
1541 assert_eq!(reloaded.procedures.len(), 100);
1542 }
1543
1544 #[test]
1545 fn consolidation_does_not_capacity_compact_at_twenty_five_percent_free() {
1546 let _env_lock = crate::core::data_dir::test_env_lock();
1547 let data_dir = tempfile::tempdir().unwrap();
1548 let _data_dir = DataDirGuard::set(data_dir.path());
1549 let project = tempfile::tempdir().unwrap();
1550 let root = project.path().to_string_lossy().to_string();
1551 let policy = MemoryPolicy::default();
1552 let mut knowledge = ProjectKnowledge::new(&root);
1553
1554 for i in 0..150 {
1555 knowledge.remember(
1556 &format!("category-{i}"),
1557 &format!("k{i}"),
1558 &format!("unique stable fact value {i}"),
1559 "s1",
1560 0.8,
1561 &policy,
1562 );
1563 }
1564 for i in 0..75 {
1565 knowledge
1566 .history
1567 .push(crate::core::knowledge::ConsolidatedInsight {
1568 summary: format!("summary {i}"),
1569 from_sessions: vec![format!("s{i}")],
1570 timestamp: Utc::now(),
1571 });
1572 }
1573 knowledge.save().unwrap();
1574
1575 let mut procedures = ProceduralStore::new(&knowledge.project_hash);
1576 for i in 0..75 {
1577 procedures
1578 .procedures
1579 .push(test_procedure(i, i as f32 / 100.0));
1580 }
1581 procedures.save().unwrap();
1582
1583 let report = consolidate_project_knowledge(&root).unwrap();
1584 let reloaded = ProjectKnowledge::load(&root).unwrap();
1585 let reloaded_procedures = ProceduralStore::load(&knowledge.project_hash).unwrap();
1586
1587 assert_eq!(report.fact_capacity_archived, 0);
1588 assert_eq!(report.history_compacted, 0);
1589 assert_eq!(report.procedures_compacted, 0);
1590 assert_eq!(reloaded.facts.len(), 150);
1591 assert_eq!(reloaded.history.len(), 75);
1592 assert_eq!(reloaded_procedures.procedures.len(), 75);
1593 }
1594
1595 #[test]
1596 fn consolidation_loads_session_for_requested_project_root() {
1597 let _env_lock = crate::core::data_dir::test_env_lock();
1598 let data_dir = tempfile::tempdir().unwrap();
1599 let _data_dir = DataDirGuard::set(data_dir.path());
1600 let cwd_project = tempfile::tempdir().unwrap();
1601 let target_project = tempfile::tempdir().unwrap();
1602 let cwd_root = cwd_project.path().to_string_lossy().to_string();
1603 let target_root = target_project.path().to_string_lossy().to_string();
1604
1605 let mut cwd_session = SessionState::new();
1606 cwd_session.project_root = Some(cwd_root);
1607 cwd_session.add_finding(None, None, "wrong cwd finding");
1608 cwd_session.save().unwrap();
1609
1610 let mut target_session = SessionState::new();
1611 target_session.project_root = Some(target_root.clone());
1612 target_session.add_finding(None, None, "target project finding");
1613 target_session.save().unwrap();
1614
1615 let _cwd = CurrentDirGuard::enter(cwd_project.path());
1616 let report = consolidate_project_knowledge(&target_root).unwrap();
1617
1618 assert_eq!(
1619 report.session_id.as_deref(),
1620 Some(target_session.id.as_str())
1621 );
1622 assert_eq!(report.session_items, 1);
1623
1624 let knowledge = ProjectKnowledge::load(&target_root).unwrap();
1625 assert!(
1626 knowledge
1627 .facts
1628 .iter()
1629 .any(|f| f.value == "target project finding")
1630 );
1631 assert!(
1632 !knowledge
1633 .facts
1634 .iter()
1635 .any(|f| f.value == "wrong cwd finding")
1636 );
1637 }
1638
1639 #[test]
1640 fn consolidate_all_project_knowledge_runs_every_known_project() {
1641 let _env_lock = crate::core::data_dir::test_env_lock();
1642 let data_dir = tempfile::tempdir().unwrap();
1643 let _data_dir = DataDirGuard::set(data_dir.path());
1644 let project_a = tempfile::tempdir().unwrap();
1645 let project_b = tempfile::tempdir().unwrap();
1646 let root_a = project_a.path().to_string_lossy().to_string();
1647 let root_b = project_b.path().to_string_lossy().to_string();
1648 let policy = MemoryPolicy::default();
1649
1650 let mut knowledge_a = ProjectKnowledge::new(&root_a);
1651 knowledge_a.remember("finding", "a", "project a fact", "s1", 0.8, &policy);
1652 knowledge_a.save().unwrap();
1653
1654 let mut knowledge_b = ProjectKnowledge::new(&root_b);
1655 knowledge_b.remember("finding", "b", "project b fact", "s1", 0.8, &policy);
1656 knowledge_b.save().unwrap();
1657
1658 let reports =
1659 consolidate_all_project_knowledge_with(&ConsolidateOptions::manual()).unwrap();
1660 let roots: Vec<_> = reports.iter().map(|(root, _)| root.clone()).collect();
1661 let mut expected = vec![root_a, root_b];
1662 expected.sort();
1663
1664 assert_eq!(roots, expected);
1665 assert_eq!(reports.len(), 2);
1666 assert!(
1667 reports
1668 .iter()
1669 .all(|(_, report)| report.session_id.is_none())
1670 );
1671 }
1672
1673 #[test]
1674 fn all_consolidation_report_marks_empty_store_set() {
1675 let reports = Vec::new();
1676
1677 let out = format_all_consolidation_reports(&reports);
1678
1679 assert_eq!(out, "No project knowledge stores found.");
1680 }
1681}