1use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16
17use chrono::NaiveDate;
18use serde::{Deserialize, Serialize};
19
20use super::proof::Proof;
21use crate::core::config::{ValueDisplayConfig, ValueDisplayMode};
22
23const TOKEN_LADDER: [(u64, &str); 4] = [
24 (1_000_000, "1M"),
25 (10_000_000, "10M"),
26 (100_000_000, "100M"),
27 (1_000_000_000, "1B"),
28];
29const STREAK_LADDER: [u32; 3] = [7, 30, 100];
30const MIN_GAP_SECS: i64 = 24 * 60 * 60;
31const CHECK_EVERY_SECS: u64 = 30 * 60;
33
34static LAST_CHECK: AtomicU64 = AtomicU64::new(0);
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Milestone {
38 pub id: String,
39 pub body: String,
40}
41
42#[derive(Debug, Default, Serialize, Deserialize)]
43#[serde(default)]
44struct State {
45 shown: BTreeSet<String>,
46 last_notified: i64,
48 claim: String,
51}
52
53struct Rung {
55 notify: Milestone,
56 lower: Vec<String>,
57}
58
59fn token_rung(net_saved: u64) -> Option<Rung> {
60 let reached: Vec<_> = TOKEN_LADDER
61 .iter()
62 .filter(|(n, _)| net_saved >= *n)
63 .collect();
64 let (_, top) = reached.last()?;
65 Some(Rung {
66 notify: Milestone {
67 id: format!("tokens_{top}"),
68 body: format!("{top} tokens kept out of your model's context so far."),
69 },
70 lower: reached[..reached.len() - 1]
71 .iter()
72 .map(|(_, label)| format!("tokens_{label}"))
73 .collect(),
74 })
75}
76
77fn streak_rung(longest: u32) -> Option<Rung> {
78 let reached: Vec<_> = STREAK_LADDER.iter().filter(|n| longest >= **n).collect();
79 let top = **reached.last()?;
80 Some(Rung {
81 notify: Milestone {
82 id: format!("streak_{top}"),
83 body: format!("{top} days in a row with lean-ctx."),
84 },
85 lower: reached[..reached.len() - 1]
86 .iter()
87 .map(|n| format!("streak_{n}"))
88 .collect(),
89 })
90}
91
92pub fn longest_streak(days: &BTreeSet<String>) -> u32 {
94 let mut longest = 0;
95 let mut run = 0;
96 let mut prev: Option<NaiveDate> = None;
97 for day in days {
98 let Ok(date) = NaiveDate::parse_from_str(day, "%Y-%m-%d") else {
99 continue;
100 };
101 run = match prev {
102 Some(p) if p.succ_opt() == Some(date) => run + 1,
103 _ => 1,
104 };
105 longest = longest.max(run);
106 prev = Some(date);
107 }
108 longest
109}
110
111fn reached(proof: &Proof) -> Vec<Rung> {
113 let mut out = Vec::new();
114 out.extend(token_rung(proof.net_saved()));
115 if proof.security.secrets_redacted > 0 {
116 out.push(Rung {
117 notify: Milestone {
118 id: "first_secret".into(),
119 body: "A secret was kept out of your model's context for the first time.".into(),
120 },
121 lower: Vec::new(),
122 });
123 }
124 if proof.security.shell_blocked > 0 {
125 out.push(Rung {
126 notify: Milestone {
127 id: "first_block".into(),
128 body: "lean-ctx blocked a risky command for the first time.".into(),
129 },
130 lower: Vec::new(),
131 });
132 }
133 out.extend(streak_rung(longest_streak(&proof.active_days)));
134 out
135}
136
137fn decide(proof: &Proof, state: &mut State, now: i64) -> Option<Milestone> {
140 if proof.tampered() {
141 return None;
142 }
143 let rungs = reached(proof);
144 let pending: Vec<&Rung> = rungs
145 .iter()
146 .filter(|r| !state.shown.contains(&r.notify.id))
147 .collect();
148 if pending.is_empty() || now - state.last_notified < MIN_GAP_SECS {
149 return None;
150 }
151 let rung = pending[0];
152 state.shown.insert(rung.notify.id.clone());
153 state.shown.extend(rung.lower.iter().cloned());
154 state.last_notified = now;
155 Some(rung.notify.clone())
156}
157
158fn state_path(dir: &Path) -> PathBuf {
159 dir.join("milestones.json")
160}
161
162fn load_state(path: &Path) -> State {
163 std::fs::read(path)
164 .ok()
165 .and_then(|b| serde_json::from_slice(&b).ok())
166 .unwrap_or_default()
167}
168
169fn check_in(dir: &Path, proof: &Proof, now: i64) -> Option<Milestone> {
172 let path = state_path(dir);
173 let mut state = load_state(&path);
174 let milestone = decide(proof, &mut state, now)?;
175 state.claim = format!("{}-{now}-{}", std::process::id(), milestone.id);
176 let json = serde_json::to_vec(&state).ok()?;
177 let _ = std::fs::create_dir_all(dir);
178 crate::core::atomic_fs::try_atomic_write(&path, &json, None).ok()?;
179 (load_state(&path).claim == state.claim).then_some(milestone)
181}
182
183fn notifications_enabled(cfg: &ValueDisplayConfig) -> bool {
186 cfg.notifications
187 && matches!(
188 cfg.effective_mode(),
189 ValueDisplayMode::Milestones | ValueDisplayMode::Verbose
190 )
191}
192
193pub fn maybe_notify() {
196 let now = chrono::Utc::now().timestamp();
197 let now_u = u64::try_from(now).unwrap_or(0);
198 let last = LAST_CHECK.load(Ordering::Relaxed);
199 if now_u.saturating_sub(last) < CHECK_EVERY_SECS
200 || LAST_CHECK
201 .compare_exchange(last, now_u, Ordering::Relaxed, Ordering::Relaxed)
202 .is_err()
203 {
204 return;
205 }
206 if !notifications_enabled(&crate::core::config::Config::load_arc().value_display) {
207 return;
208 }
209 let Some(dir) = super::snapshot::value_dir() else {
210 return;
211 };
212 let proof = super::proof::build(None, true);
213 if let Some(m) = check_in(&dir, &proof, now) {
214 super::notify::send(
215 "lean-ctx",
216 &format!("{}\nProof: lean-ctx value --all", m.body),
217 );
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn proof(net: u64, secrets: u64, days: &[&str]) -> Proof {
226 let mut p = super::super::proof::build_from(None, None, None, None);
227 p.tokens_saved = net;
228 p.security.secrets_redacted = secrets;
229 p.active_days = days.iter().map(ToString::to_string).collect();
230 p
231 }
232
233 const DAY: i64 = 24 * 60 * 60;
234
235 #[test]
236 fn only_the_highest_new_rung_is_shown_and_lower_ones_are_marked() {
237 let mut state = State::default();
238 let m = decide(&proof(42_000_000, 0, &[]), &mut state, DAY).unwrap();
239 assert_eq!(m.id, "tokens_10M");
240 assert!(m.body.starts_with("10M tokens"));
241 assert!(state.shown.contains("tokens_1M"));
242 assert_eq!(
244 decide(&proof(42_000_000, 0, &[]), &mut state, 3 * DAY),
245 None
246 );
247 }
248
249 #[test]
250 fn at_most_one_notification_per_day() {
251 let mut state = State::default();
252 let p = proof(2_000_000, 1, &[]);
253 assert_eq!(decide(&p, &mut state, DAY).unwrap().id, "tokens_1M");
254 assert_eq!(decide(&p, &mut state, DAY + 60), None);
255 assert_eq!(decide(&p, &mut state, 2 * DAY).unwrap().id, "first_secret");
256 }
257
258 #[test]
259 fn a_tampered_chain_celebrates_nothing() {
260 let mut p = proof(5_000_000, 0, &[]);
261 p.ledger.intact = false;
262 assert_eq!(decide(&p, &mut State::default(), DAY), None);
263 }
264
265 #[test]
266 fn streaks_count_consecutive_utc_days() {
267 let days: BTreeSet<String> = [
268 "2026-09-01",
269 "2026-09-02",
270 "2026-09-03",
271 "2026-09-05",
272 "2026-09-06",
273 ]
274 .iter()
275 .map(ToString::to_string)
276 .collect();
277 assert_eq!(longest_streak(&days), 3);
278 let week: Vec<String> = (1..=8).map(|d| format!("2026-08-{d:02}")).collect();
279 let refs: Vec<&str> = week.iter().map(String::as_str).collect();
280 let m = decide(&proof(0, 0, &refs), &mut State::default(), DAY).unwrap();
281 assert_eq!(m.id, "streak_7");
282 }
283
284 #[test]
285 fn notifications_are_opt_in_via_the_milestones_mode() {
286 let mut cfg = ValueDisplayConfig::default();
287 assert!(!notifications_enabled(&cfg), "default minimal stays silent");
288 cfg.mode = ValueDisplayMode::Milestones;
289 assert!(notifications_enabled(&cfg));
290 cfg.mode = ValueDisplayMode::Verbose;
291 assert!(notifications_enabled(&cfg));
292 cfg.notifications = false;
293 assert!(!notifications_enabled(&cfg));
294 }
295
296 #[test]
297 fn nothing_reached_means_nothing_written() {
298 let dir = tempfile::tempdir().unwrap();
299 assert_eq!(check_in(dir.path(), &proof(10, 0, &[]), DAY), None);
300 assert!(!state_path(dir.path()).exists());
301 }
302
303 #[test]
304 fn a_milestone_is_claimed_once_across_processes() {
305 let dir = tempfile::tempdir().unwrap();
306 let p = proof(1_500_000, 0, &[]);
307 assert!(check_in(dir.path(), &p, DAY).is_some());
308 assert_eq!(check_in(dir.path(), &p, 3 * DAY), None);
310 }
311}