Skip to main content

lean_ctx/core/
knowledge_consolidate.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Consolidation engine: session import, fact lifecycle (quality only — facts
3//! have no count limit), lossless capacity reclaim of the pattern and history
4//! stores, and the consolidation reports (#995). Moved down from
5//! `tools::ctx_knowledge`, which re-exports it.
6
7use 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
18/// The effective memory policy (config + active profile overrides), validated;
19/// `Err` is the user-facing error text naming the config file to fix.
20pub 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    /// True when produced by a preview run (no knowledge/archive/session writes).
57    pub dry_run: bool,
58}
59
60/// Explicit CLI / MCP `consolidate`: import the whole session, run the fact
61/// lifecycle and losslessly reclaim every store. Thin wrapper over the canonical
62/// [`consolidate_project_knowledge_with`].
63pub fn consolidate_project_knowledge(
64    project_root: &str,
65) -> Result<KnowledgeConsolidationReport, String> {
66    consolidate_project_knowledge_with(project_root, &ConsolidateOptions::manual())
67}
68
69/// Canonical consolidation engine (#995 Phase 4). Every driver — CLI/MCP, the
70/// scheduled post-dispatch pass ([`crate::core::consolidation_engine::consolidate_latest`]),
71/// and startup auto-consolidate — funnels through here, parameterised by
72/// [`ConsolidateOptions`], so session import, fact keys, lifecycle and the
73/// lossless per-store capacity reclaim behave identically. Session loads are
74/// project-scoped (cwd bug #2362), and `opts.dry_run` previews without mutating
75/// knowledge, archives or the session.
76pub 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    // Only the caller's own session is imported (I07/S03): the project's
95    // latest saved session may be another agent's. A driver without a caller
96    // (background, SDK, CLI without `--session`) imports no session.
97    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    // Incremental (startup) mode advances a per-session watermark; when nothing
124    // is new, skip entirely so there is no history churn or watermark bump.
125    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    // Advance the watermark only after the knowledge write succeeded.
163    if opts.incremental
164        && let Some(mut s) = session
165    {
166        let now = Utc::now();
167        // The caller's live session carries its own watermark; a copy saved
168        // here would be overwritten by the next save of the live instance.
169        // Saved now: shutdown consolidates after the final session save.
170        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
214/// The locked read-modify-write body of a real consolidation run.
215fn 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        // `consolidate` records the insight and losslessly reclaims history.
243        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    // Lossless capacity reclaim for the non-fact stores (facts settle inside the
253    // lifecycle). History is already bounded per consolidate; the explicit pass
254    // also compacts a pre-existing over-cap history when no session was imported.
255    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
285/// Preview a consolidation on a throwaway clone: identical math, zero writes to
286/// knowledge, archives or the session. Reuses the real lifecycle/import code so
287/// the counts match what a non-dry run would produce (#995 Phase 6).
288fn 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        // remember() is in-memory only, so importing into the clone is side
312        // effect free; it gives the exact promotion counts.
313        imported = import_session_into(&mut knowledge, s, opts, policy, watermark);
314    }
315
316    // Fact lifecycle preview: run the same pure in-memory passes as
317    // `run_lifecycle` (no archive writes). Facts have no count limit, so there
318    // is no capacity reclaim to preview, matching the real run.
319    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
381/// Lossless history capacity reclaim. Returns the number of insights archived.
382fn 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
402/// Lossless pattern capacity reclaim (newest kept). Returns the archived count.
403fn 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
424/// `consolidate --all`: consolidate every stored project, with explicit options
425/// (e.g. [`ConsolidateOptions::into_dry_run`] for a preview).
426pub 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    // Eviction is lossless: if anything was (or would be) archived this run, point
478    // the user at the explicit restore path.
479    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}