lean_ctx/core/session_summary/
mod.rs1pub 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
26pub fn build_candidate(session: &SessionState) -> SummaryCandidate {
29 generate::build_candidate(session)
30}
31
32pub 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
47pub 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
73pub 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 let c = build_candidate(&session_with_work(5));
106 assert!(maybe_record_periodic(&root, c).is_none());
107
108 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}