lean_ctx/core/
compact_directive.rs1use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Serialize};
14
15const TTL_SECS: u64 = 1800;
17
18const MIN_INTERVAL_SECS: u64 = 300;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct CompactDirective {
24 pub created_unix: u64,
25 pub keep_recent_turns: usize,
27 pub summary: String,
29 pub boundary_fingerprint: Option<String>,
33 pub original_handle: Option<String>,
36}
37
38fn now_unix() -> u64 {
39 SystemTime::now()
40 .duration_since(UNIX_EPOCH)
41 .map(|d| d.as_secs())
42 .unwrap_or(0)
43}
44
45fn store_path() -> Option<std::path::PathBuf> {
46 crate::core::data_dir::lean_ctx_data_dir()
47 .ok()
48 .map(|d| d.join("compact_directive.json"))
49}
50
51pub fn create(keep_recent_turns: usize, summary: String) -> Result<(), String> {
55 if let Some(existing) = load_active()
56 && now_unix().saturating_sub(existing.created_unix) < MIN_INTERVAL_SECS
57 {
58 return Err(format!(
59 "a compaction from {}s ago is still active — one compact per {}s \
60 (anti-obsession guard); use ctx_session action=\"restore\" first if needed",
61 now_unix().saturating_sub(existing.created_unix),
62 MIN_INTERVAL_SECS
63 ));
64 }
65 let directive = CompactDirective {
66 created_unix: now_unix(),
67 keep_recent_turns,
68 summary,
69 boundary_fingerprint: None,
70 original_handle: None,
71 };
72 save(&directive)
73}
74
75pub fn save(directive: &CompactDirective) -> Result<(), String> {
76 let mut admitted = directive.clone();
77 admitted.summary =
78 crate::core::session::admission::admit_storable_tool_text(&directive.summary)?;
79 write_admitted(&admitted)
80}
81
82fn write_admitted(directive: &CompactDirective) -> Result<(), String> {
83 let path = store_path().ok_or_else(|| "no data dir".to_string())?;
84 let json = serde_json::to_string(directive).map_err(|e| e.to_string())?;
85 std::fs::write(path, json).map_err(|e| e.to_string())
86}
87
88pub fn load_active() -> Option<CompactDirective> {
90 let path = store_path()?;
91 let raw = std::fs::read_to_string(&path).ok()?;
92 let directive: CompactDirective = serde_json::from_str(&raw).ok()?;
93 if now_unix().saturating_sub(directive.created_unix) > TTL_SECS {
94 let _ = std::fs::remove_file(&path);
95 return None;
96 }
97 let original_summary = directive.summary.clone();
98 let Ok(admitted_summary) =
99 crate::core::session::admission::admit_storable_tool_text(&original_summary)
100 else {
101 let _ = std::fs::remove_file(path);
102 return None;
103 };
104 let mut admitted = directive;
105 admitted.summary = admitted_summary;
106 if admitted.summary != original_summary {
107 write_admitted(&admitted).ok()?;
108 }
109 Some(admitted)
110}
111
112pub fn clear() -> bool {
115 store_path().is_some_and(|path| std::fs::remove_file(path).is_ok())
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn directive_roundtrip_rate_limit_and_clear() {
124 let _lock = crate::core::data_dir::test_env_lock();
125 let _dir = crate::core::data_dir::isolated_data_dir();
126 assert!(load_active().is_none());
127
128 create(6, "summary one".into()).expect("first create succeeds");
129 let d = load_active().expect("directive active");
130 assert_eq!(d.keep_recent_turns, 6);
131 assert!(d.boundary_fingerprint.is_none());
132
133 let err = create(4, "summary two".into()).expect_err("rate-limited");
135 assert!(err.contains("anti-obsession"), "{err}");
136
137 assert!(clear());
138 assert!(load_active().is_none());
139 create(4, "summary three".into()).expect("create after clear");
141 clear();
142 }
143
144 #[test]
145 fn expired_directive_is_ignored_and_removed() {
146 let _lock = crate::core::data_dir::test_env_lock();
147 let _dir = crate::core::data_dir::isolated_data_dir();
148 let stale = CompactDirective {
149 created_unix: now_unix() - TTL_SECS - 10,
150 keep_recent_turns: 6,
151 summary: "old".into(),
152 boundary_fingerprint: None,
153 original_handle: None,
154 };
155 save(&stale).unwrap();
156 assert!(load_active().is_none(), "expired directive must not apply");
157 assert!(load_active().is_none());
158 }
159}