1use chrono::{DateTime, Utc};
8
9use crate::core::consolidation_engine::{ConsolidateOptions, ImportCounts, import_session_into};
10use crate::core::context_admission::stores::StoreAdmission;
11use crate::core::knowledge::ProjectKnowledge;
12use crate::core::memory_archive::MemoryStore;
13use crate::core::memory_capacity::{reclaim_preview, reclaim_store, reclaim_target};
14use crate::core::memory_lifecycle::LifecycleReport;
15use crate::core::memory_policy::MemoryPolicy;
16use crate::core::session::SessionState;
17
18pub fn load_policy_or_error() -> Result<MemoryPolicy, String> {
21 let cfg = crate::core::config::Config::load();
22 let path = crate::core::config::Config::path().map_or_else(
23 || "~/.lean-ctx/config.toml".to_string(),
24 |p| p.display().to_string(),
25 );
26
27 let mut policy = cfg
28 .memory_policy_effective()
29 .map_err(|e| format!("Error: invalid memory policy: {e}\nFix: edit {path}"))?;
30
31 let profile = crate::core::profiles::active_profile();
32 policy.apply_overrides(&profile.memory);
33 policy
34 .validate()
35 .map_err(|e| format!("Error: invalid memory policy: {e}\nFix: edit {path}"))?;
36
37 Ok(policy)
38}
39
40#[derive(Debug, Default)]
41pub struct KnowledgeConsolidationReport {
42 pub session_id: Option<String>,
43 pub session_items: usize,
44 pub imported_decisions: usize,
45 pub imported_findings: usize,
46 pub facts: usize,
47 pub active_facts: usize,
48 pub archived_facts: usize,
49 pub patterns: usize,
50 pub patterns_capacity_target: usize,
51 pub patterns_compacted: usize,
52 pub history: usize,
53 pub history_capacity_target: usize,
54 pub history_compacted: usize,
55 pub lifecycle: LifecycleReport,
56 pub dry_run: bool,
58}
59
60pub fn consolidate_project_knowledge(
64 project_root: &str,
65) -> Result<KnowledgeConsolidationReport, String> {
66 consolidate_project_knowledge_with(project_root, &ConsolidateOptions::manual())
67}
68
69pub fn consolidate_project_knowledge_with(
77 project_root: &str,
78 opts: &ConsolidateOptions,
79) -> Result<KnowledgeConsolidationReport, String> {
80 crate::core::policy::runtime::with_explicit_project_source_view(
81 std::path::Path::new(project_root),
82 |_| consolidate_project_knowledge_with_view(project_root, opts),
83 )?
84}
85
86fn consolidate_project_knowledge_with_view(
87 project_root: &str,
88 opts: &ConsolidateOptions,
89) -> Result<KnowledgeConsolidationReport, String> {
90 let admission = StoreAdmission::current();
91 let knowledge_admission =
92 crate::core::knowledge::protection::KnowledgeAdmission::capture(project_root)?;
93 let policy = load_policy_or_error()?;
94 let session = if opts.import_session {
98 crate::core::session::caller::with_for_project(project_root, Clone::clone)
99 } else {
100 None
101 };
102 if let Some(session) = session.as_ref().filter(|_| opts.import_session) {
103 if !session_import_is_admitted(session, &knowledge_admission) {
104 return Err("session knowledge import withheld by current policy".into());
105 }
106 }
107
108 if opts.dry_run {
109 let report = dry_run_report(
110 project_root,
111 session.as_ref(),
112 opts,
113 &policy,
114 &knowledge_admission,
115 )?;
116 if !admission.is_current() {
117 return Err("session admission policy changed; retry".into());
118 }
119 knowledge_admission.authorize_gateway()?;
120 return Ok(report);
121 }
122
123 let watermark = if opts.incremental {
126 session.as_ref().and_then(|s| s.last_consolidate_ts)
127 } else {
128 None
129 };
130 if opts.incremental
131 && let Some(s) = session.as_ref()
132 && !has_new_session_items(s, watermark)
133 {
134 knowledge_admission.authorize_gateway()?;
135 return Ok(KnowledgeConsolidationReport {
136 session_id: Some(s.id.clone()),
137 ..Default::default()
138 });
139 }
140
141 let (_knowledge, report) = ProjectKnowledge::mutate_locked_with_admission(
142 project_root,
143 &knowledge_admission,
144 |knowledge| {
145 run_consolidation_locked(
146 knowledge,
147 session.as_ref(),
148 opts,
149 &policy,
150 &knowledge_admission,
151 watermark,
152 )
153 },
154 )
155 .map_err(|e| format!("Consolidation done but save failed: {e}"))?;
156 let report = report?;
157 if !admission.is_current() {
158 return Err("session admission policy changed; retry".into());
159 }
160 knowledge_admission.authorize_gateway()?;
161
162 if opts.incremental
164 && let Some(mut s) = session
165 {
166 let now = Utc::now();
167 let advanced = crate::core::session::caller::with_mut(|own| {
171 (own.id == s.id).then(|| {
172 own.last_consolidate_ts = Some(now);
173 own.increment();
174 let _ = own.save();
175 })
176 })
177 .flatten();
178 if advanced.is_none() {
179 s.last_consolidate_ts = Some(now);
180 let _ = s.save();
181 }
182 }
183
184 if opts.emit_event {
185 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
186 category: "memory".to_string(),
187 key: "consolidation".to_string(),
188 action: "run".to_string(),
189 });
190 }
191
192 Ok(report)
193}
194
195fn session_import_is_admitted(
196 session: &SessionState,
197 admission: &crate::core::knowledge::protection::KnowledgeAdmission,
198) -> bool {
199 let task_description = session.task.as_ref().map(|task| task.description.as_str());
200 let lineage = serde_json::json!({
201 "session_id": &session.id,
202 "task_description": task_description,
203 });
204 if !admission.admits_archive_value(&lineage) {
205 return false;
206 }
207 crate::core::session::extract_session_facts(session)
208 .iter()
209 .all(|fact| {
210 serde_json::to_value(fact).is_ok_and(|value| admission.admits_archive_value(&value))
211 })
212}
213
214fn run_consolidation_locked(
216 knowledge: &mut ProjectKnowledge,
217 session: Option<&SessionState>,
218 opts: &ConsolidateOptions,
219 policy: &MemoryPolicy,
220 knowledge_admission: &crate::core::knowledge::protection::KnowledgeAdmission,
221 watermark: Option<DateTime<Utc>>,
222) -> Result<KnowledgeConsolidationReport, String> {
223 knowledge_admission.authorize_gateway()?;
224 let mut imported = ImportCounts::default();
225 let mut session_id = None;
226 let mut history_compacted = 0usize;
227
228 if opts.import_session
229 && let Some(s) = session
230 {
231 session_id = Some(s.id.clone());
232 imported = import_session_into(knowledge, s, opts, policy, watermark);
233
234 let task_desc = s
235 .task
236 .as_ref()
237 .map_or_else(|| "(no task)".into(), |t| t.description.clone());
238 let summary = format!(
239 "Session {}: {} — {} findings, {} decisions consolidated",
240 s.id, task_desc, imported.findings, imported.decisions
241 );
242 history_compacted += knowledge.consolidate(&summary, vec![s.id.clone()], policy)?;
244 }
245
246 let lifecycle = if opts.run_lifecycle {
247 knowledge.run_memory_lifecycle(policy)?
248 } else {
249 LifecycleReport::default()
250 };
251
252 let mut patterns_compacted = 0usize;
256 if opts.reclaim_stores {
257 patterns_compacted = reclaim_patterns(knowledge, policy)?;
258 history_compacted += reclaim_history(knowledge, policy)?;
259 }
260
261 let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
262 let archived_facts = knowledge.facts.len().saturating_sub(active_facts);
263 let headroom = policy.lifecycle.reclaim_headroom_pct;
264
265 knowledge_admission.authorize_gateway()?;
266 Ok(KnowledgeConsolidationReport {
267 session_id,
268 session_items: imported.total(),
269 imported_decisions: imported.decisions,
270 imported_findings: imported.findings,
271 facts: knowledge.facts.len(),
272 active_facts,
273 archived_facts,
274 patterns: knowledge.patterns.len(),
275 patterns_capacity_target: reclaim_target(policy.knowledge.max_patterns, headroom),
276 patterns_compacted,
277 history: knowledge.history.len(),
278 history_capacity_target: reclaim_target(policy.knowledge.max_history, headroom),
279 history_compacted,
280 lifecycle,
281 dry_run: false,
282 })
283}
284
285fn dry_run_report(
289 project_root: &str,
290 session: Option<&SessionState>,
291 opts: &ConsolidateOptions,
292 policy: &MemoryPolicy,
293 knowledge_admission: &crate::core::knowledge::protection::KnowledgeAdmission,
294) -> Result<KnowledgeConsolidationReport, String> {
295 let mut knowledge = ProjectKnowledge::load_for_admission(project_root, knowledge_admission)?
296 .unwrap_or_else(|| ProjectKnowledge::new(project_root));
297 let headroom = policy.lifecycle.reclaim_headroom_pct;
298 let enabled = policy.lifecycle.reclaim_enabled;
299
300 let mut imported = ImportCounts::default();
301 let mut session_id = None;
302 if opts.import_session
303 && let Some(s) = session
304 {
305 session_id = Some(s.id.clone());
306 let watermark = if opts.incremental {
307 s.last_consolidate_ts
308 } else {
309 None
310 };
311 imported = import_session_into(&mut knowledge, s, opts, policy, watermark);
314 }
315
316 let lifecycle = if opts.run_lifecycle {
320 let cfg = crate::core::memory_lifecycle::LifecycleConfig::from_policy(policy);
321 let decayed =
322 crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &cfg);
323 let consolidated = crate::core::memory_lifecycle::consolidate_similar(
324 &mut knowledge.facts,
325 cfg.consolidation_similarity,
326 );
327 let (quality, _) = crate::core::memory_lifecycle::compact(&mut knowledge.facts, &cfg);
328 LifecycleReport {
329 decayed_count: decayed,
330 consolidated_count: consolidated,
331 archived_count: quality,
332 compacted_count: quality,
333 remaining_facts: knowledge.facts.len(),
334 }
335 } else {
336 LifecycleReport::default()
337 };
338
339 let history_compacted = reclaim_preview(
340 knowledge.history.len(),
341 policy.knowledge.max_history,
342 headroom,
343 enabled,
344 );
345 let patterns_compacted = reclaim_preview(
346 knowledge.patterns.len(),
347 policy.knowledge.max_patterns,
348 headroom,
349 enabled,
350 );
351
352 let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
353 let archived_facts = knowledge.facts.len().saturating_sub(active_facts);
354
355 knowledge_admission.authorize_gateway()?;
356 Ok(KnowledgeConsolidationReport {
357 session_id,
358 session_items: imported.total(),
359 imported_decisions: imported.decisions,
360 imported_findings: imported.findings,
361 facts: knowledge.facts.len(),
362 active_facts,
363 archived_facts,
364 patterns: knowledge.patterns.len(),
365 patterns_capacity_target: reclaim_target(policy.knowledge.max_patterns, headroom),
366 patterns_compacted,
367 history: knowledge.history.len(),
368 history_capacity_target: reclaim_target(policy.knowledge.max_history, headroom),
369 history_compacted,
370 lifecycle,
371 dry_run: true,
372 })
373}
374
375fn has_new_session_items(session: &SessionState, watermark: Option<DateTime<Utc>>) -> bool {
376 let is_new = |ts: DateTime<Utc>| watermark.is_none_or(|w| ts > w);
377 session.findings.iter().any(|f| is_new(f.timestamp))
378 || session.decisions.iter().any(|d| is_new(d.timestamp))
379}
380
381fn reclaim_history(
383 knowledge: &mut ProjectKnowledge,
384 policy: &MemoryPolicy,
385) -> Result<usize, String> {
386 reclaim_store(
387 MemoryStore::History,
388 Some(&knowledge.project_hash),
389 &mut knowledge.history,
390 policy.knowledge.max_history,
391 policy.lifecycle.reclaim_headroom_pct,
392 policy.lifecycle.reclaim_enabled,
393 |a, b| {
394 b.timestamp
395 .cmp(&a.timestamp)
396 .then_with(|| b.summary.cmp(&a.summary))
397 },
398 )
399 .map(|archived| archived.len())
400}
401
402fn reclaim_patterns(
404 knowledge: &mut ProjectKnowledge,
405 policy: &MemoryPolicy,
406) -> Result<usize, String> {
407 reclaim_store(
408 MemoryStore::Patterns,
409 Some(&knowledge.project_hash),
410 &mut knowledge.patterns,
411 policy.knowledge.max_patterns,
412 policy.lifecycle.reclaim_headroom_pct,
413 policy.lifecycle.reclaim_enabled,
414 |a, b| {
415 b.created_at
416 .cmp(&a.created_at)
417 .then_with(|| a.pattern_type.cmp(&b.pattern_type))
418 .then_with(|| a.description.cmp(&b.description))
419 },
420 )
421 .map(|archived| archived.len())
422}
423
424pub fn consolidate_all_project_knowledge_with(
427 opts: &ConsolidateOptions,
428) -> Result<Vec<(String, KnowledgeConsolidationReport)>, String> {
429 let roots = ProjectKnowledge::list_project_roots()?;
430 let mut reports = Vec::with_capacity(roots.len());
431 for root in roots {
432 let report = consolidate_project_knowledge_with(&root, opts)
433 .map_err(|e| format!("Consolidation failed for {}: {e}", project_label(&root)))?;
434 reports.push((root, report));
435 }
436 Ok(reports)
437}
438
439pub fn format_consolidation_report(report: &KnowledgeConsolidationReport) -> String {
440 let session_line = match report.session_id.as_deref() {
441 Some(session_id) => {
442 format!(
443 "Session import: {session_id} ({} item(s))",
444 report.session_items
445 )
446 }
447 None => "Session import: none (no active session)".to_string(),
448 };
449
450 let banner = if report.dry_run {
451 "DRY RUN — preview only, no changes written\n"
452 } else {
453 ""
454 };
455
456 let body = format!(
457 "{banner}{session_line}\n\
458 Facts: {} active, {} archived, {} total (no limit)\n\
459 Patterns: {} (target <= {}, compacted {}), History: {} (target <= {}, compacted {})\n\
460 Lifecycle: decayed {}, consolidated {}, archived {}, compacted {}, remaining {}",
461 report.active_facts,
462 report.archived_facts,
463 report.facts,
464 report.patterns,
465 report.patterns_capacity_target,
466 report.patterns_compacted,
467 report.history,
468 report.history_capacity_target,
469 report.history_compacted,
470 report.lifecycle.decayed_count,
471 report.lifecycle.consolidated_count,
472 report.lifecycle.archived_count,
473 report.lifecycle.compacted_count,
474 report.lifecycle.remaining_facts
475 );
476
477 let archived_total = report.patterns_compacted + report.history_compacted;
480 if archived_total > 0 {
481 let verb = if report.dry_run {
482 "would archive"
483 } else {
484 "archived"
485 };
486 format!(
487 "{body}\n{verb} {archived_total} item(s) — restore with: lean-ctx knowledge restore"
488 )
489 } else {
490 body
491 }
492}
493
494pub fn format_all_consolidation_reports(
495 reports: &[(String, KnowledgeConsolidationReport)],
496) -> String {
497 if reports.is_empty() {
498 return "No project knowledge stores found.".to_string();
499 }
500
501 let mut out = format!("Projects consolidated: {}", reports.len());
502 for (project_root, report) in reports {
503 out.push_str("\n\nProject: ");
504 out.push_str(project_label(project_root));
505 out.push('\n');
506 out.push_str(&format_consolidation_report(report));
507 }
508 out
509}
510
511fn project_label(project_root: &str) -> &str {
512 if project_root.trim().is_empty() {
513 "(empty project root)"
514 } else {
515 project_root
516 }
517}