Skip to main content

lean_ctx/core/session_summary/
mod.rs

1//! AI session summaries (#292): periodically distil the working session into a
2//! compact, semantically-recallable digest.
3//!
4//! Pipeline: [`generate::build_candidate`] (under the session lock, cheap, owned)
5//! → [`maybe_record_periodic`] (off the hot path: cadence check + persist) →
6//! [`recall::recall`] (semantic when embeddings are warm, else lexical).
7//!
8//! Deterministic and local-first: no LLM is required to produce or recall a
9//! summary.
10
11pub mod generate;
12pub mod recall;
13pub mod record;
14pub mod store;
15
16pub use recall::{recall, RecallHit};
17pub use record::{SummaryCandidate, SummaryRecord};
18
19use crate::core::session::SessionState;
20use store::SummaryStore;
21
22fn config() -> crate::core::config::SummariesConfig {
23    crate::core::config::Config::load().summaries
24}
25
26/// Build a lock-free candidate from the live session. Call while holding the
27/// session lock; persist the result off the hot path with [`maybe_record_periodic`].
28pub fn build_candidate(session: &SessionState) -> SummaryCandidate {
29    generate::build_candidate(session)
30}
31
32/// Record `candidate` iff enabled and the turn cadence is due. Returns the title
33/// of the recorded summary, or `None` if skipped.
34pub fn maybe_record_periodic(project_root: &str, candidate: SummaryCandidate) -> Option<String> {
35    let cfg = config();
36    if !cfg.enabled || !candidate.has_content {
37        return None;
38    }
39    let store = SummaryStore::load_or_create(project_root);
40    if candidate.tool_calls < store.last_recorded_calls + u64::from(cfg.every_n_turns) {
41        return None;
42    }
43    let mut store = store;
44    record_into(&mut store, candidate, cfg.max_kept as usize)
45}
46
47/// Force-record a summary now (explicit action), ignoring the turn cadence.
48pub fn record_now(project_root: &str, candidate: SummaryCandidate) -> Result<String, String> {
49    if !candidate.has_content {
50        return Err("session has nothing to summarize yet".to_string());
51    }
52    let cfg = config();
53    let mut store = SummaryStore::load_or_create(project_root);
54    record_into(&mut store, candidate, cfg.max_kept as usize)
55        .ok_or_else(|| "failed to persist summary".to_string())
56}
57
58fn record_into(
59    store: &mut SummaryStore,
60    candidate: SummaryCandidate,
61    max_kept: usize,
62) -> Option<String> {
63    let calls = candidate.tool_calls;
64    let seq = store.next_seq();
65    let rec = candidate.into_record(seq);
66    let title = rec.title.clone();
67    store.last_recorded_calls = calls;
68    store.push(rec, max_kept);
69    store.save().ok()?;
70    Some(title)
71}
72
73/// All stored summaries for a project (oldest first).
74pub fn list(project_root: &str) -> Vec<SummaryRecord> {
75    SummaryStore::load_or_create(project_root).summaries
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::core::session::SessionState;
82
83    fn isolated() -> (tempfile::TempDir, String) {
84        let tmp = tempfile::tempdir().unwrap();
85        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path().join("data"));
86        let root = tmp.path().join("proj").to_string_lossy().to_string();
87        (tmp, root)
88    }
89
90    fn session_with_work(calls: u32) -> SessionState {
91        let mut s = SessionState::new();
92        s.set_task("Implement traversal edges", None);
93        s.add_decision("Use Hebbian decay for co-access weights", None);
94        s.touch_file("src/core/cooccurrence.rs", None, "full", 1200);
95        s.stats.total_tool_calls = calls;
96        s
97    }
98
99    #[test]
100    fn cadence_gates_then_records() {
101        let _g = crate::core::data_dir::test_env_lock();
102        let (_tmp, root) = isolated();
103
104        // Below cadence (default every_n_turns=25): skipped.
105        let c = build_candidate(&session_with_work(5));
106        assert!(maybe_record_periodic(&root, c).is_none());
107
108        // At/over cadence: recorded.
109        let c = build_candidate(&session_with_work(30));
110        assert!(maybe_record_periodic(&root, c).is_some());
111        assert_eq!(list(&root).len(), 1);
112
113        std::env::remove_var("LEAN_CTX_DATA_DIR");
114    }
115
116    #[test]
117    fn record_now_and_lexical_recall() {
118        let _g = crate::core::data_dir::test_env_lock();
119        let (_tmp, root) = isolated();
120
121        let c = build_candidate(&session_with_work(3));
122        record_now(&root, c).unwrap();
123
124        let hits = recall(&root, "traversal edges cooccurrence", 5);
125        assert!(!hits.is_empty(), "should recall the summary lexically");
126        assert!(hits[0].record.title.contains("traversal"));
127
128        std::env::remove_var("LEAN_CTX_DATA_DIR");
129    }
130}