Skip to main content

lean_ctx/tools/ctx_knowledge/
mod.rs

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    /// True when produced by a preview run (no knowledge/archive/session writes).
51    pub dry_run: bool,
52}
53
54/// Explicit CLI / MCP `consolidate`: import the whole session, run the fact
55/// lifecycle and losslessly reclaim every store. Thin wrapper over the canonical
56/// [`consolidate_project_knowledge_with`].
57pub(crate) fn consolidate_project_knowledge(
58    project_root: &str,
59) -> Result<KnowledgeConsolidationReport, String> {
60    consolidate_project_knowledge_with(project_root, &ConsolidateOptions::manual())
61}
62
63/// Canonical consolidation engine (#995 Phase 4). Every driver — CLI/MCP, the
64/// scheduled post-dispatch pass ([`crate::core::consolidation_engine::consolidate_latest`]),
65/// and startup auto-consolidate — funnels through here, parameterised by
66/// [`ConsolidateOptions`], so session import, fact keys, lifecycle and the
67/// lossless per-store capacity reclaim behave identically. Session loads are
68/// project-scoped (cwd bug #2362), and `opts.dry_run` previews without mutating
69/// knowledge, archives or the session.
70pub(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    // Incremental (startup) mode advances a per-session watermark; when nothing
91    // is new, skip entirely so there is no history churn or watermark bump.
92    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    // Advance the watermark only after the knowledge write succeeded.
114    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
132/// The locked read-modify-write body of a real consolidation run.
133fn 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        // `consolidate` records the insight and losslessly reclaims history.
159        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    // Lossless capacity reclaim for the non-fact stores (facts settle inside the
169    // lifecycle). History is already bounded per consolidate; the explicit pass
170    // also compacts a pre-existing over-cap history when no session was imported.
171    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
210/// Preview a consolidation on a throwaway clone: identical math, zero writes to
211/// knowledge, archives or the session. Reuses the real lifecycle/import code so
212/// the counts match what a non-dry run would produce (#995 Phase 6).
213fn 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        // remember() is in-memory only, so importing into the clone is side
236        // effect free; it gives the exact promotion counts.
237        imported = import_session_into(&mut knowledge, s, opts, policy, watermark);
238    }
239
240    // Fact lifecycle preview: run the pure in-memory passes (no archive writes),
241    // then preview the capacity reclaim.
242    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
319/// Lossless history capacity reclaim. Returns the number of insights archived.
320fn 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
337/// Lossless pattern capacity reclaim (newest kept). Returns the archived count.
338fn 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
356/// Lossless procedure capacity reclaim. Returns `(remaining, target, archived)`.
357fn 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
386/// Report-only procedure counts when no reclaim is requested.
387fn 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
396/// `consolidate --all`: consolidate every stored project, with explicit options
397/// (e.g. [`ConsolidateOptions::into_dry_run`] for a preview).
398pub(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    // Eviction is lossless: if anything was (or would be) archived this run, point
456    // the user at the explicit restore path.
457    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/// Dispatches knowledge base actions (remember, recall, pattern, timeline, etc.).
501#[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    // Read-modify-write under the cross-process lock (#326/#594) so concurrent
643    // CLI/daemon/MCP feedback never clobbers each other.
644    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    // Read-modify-write under the cross-process lock (#326/#594).
726    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    // Read-modify-write under the cross-process lock (#326/#594).
817    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
857/// Per-layer lifecycle report (GL#445): item counts, effective policies, and
858/// the next enforcement action for every memory layer. Read-only.
859fn 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    // Knowledge layer (long-term facts, archive-only eviction).
869    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    // Archive files (restorable via recall rehydration).
888    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    // Episodic layer (session episodes).
895    {
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    // Procedural layer (learned action sequences).
906    {
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    // Embeddings layer (semantic index over knowledge facts).
917    #[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 &current {
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    // Read-modify-write under the cross-process lock (#326/#594). The embedding
1046    // index is a separate store, so it is synced afterwards from the committed
1047    // knowledge — mirroring handle_remember.
1048    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        // Serialize the embedding side-car under the same per-project lock as
1067        // the fact removal and compact against fresh on-disk knowledge, so a
1068        // concurrent `remember` cannot clobber it (issue #412).
1069        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
1131/// Dry-run consolidate: preview imports + reclaim with zero writes (#995).
1132fn 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
1142/// Explicit cross-store restore from archive (#995 Phase 6). `store` selects a
1143/// single store (all when `None`); `query` filters by substring; `limit` caps
1144/// the total restored (default [`DEFAULT_RESTORE_LIMIT`]).
1145fn 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        // Lossless: a run that archived items points at the restore path.
1466        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        // Hysteresis (#995): reclaim triggers only at/above the cap (100), then
1495        // settles at the headroom target (75). 100 -> keep 75, archive 25.
1496        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        // Lowest-retention procedures (smallest id/confidence) are the ones evicted.
1509        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        // The preview reports the reclaim that *would* happen…
1534        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        // …but the store on disk is byte-for-byte untouched.
1540        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}