Skip to main content

lean_ctx/core/value/
milestones.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Milestones: the rare moments lean-ctx speaks up on its own — a native
3//! desktop notification when a lifetime threshold is first crossed.
4//!
5//! Every milestone is computed from the *verified* chains ([`super::proof`]),
6//! never from display counters, and a tampered chain celebrates nothing. At
7//! most one notification per day; when several milestones are new at once the
8//! most significant is shown and the rest wait for later days. Of a ladder
9//! (1M → 10M → …) only the highest newly reached rung is shown — lower ones are
10//! marked as seen, so an upgrade with a large ledger means one notification,
11//! not a burst.
12
13use 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;
31/// Re-walking the chains is cheap but not free: once per half hour at most.
32const 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    /// Unix seconds of the last notification.
47    last_notified: i64,
48    /// Which process claimed the last write — two MCP servers crossing the
49    /// same threshold must not both notify.
50    claim: String,
51}
52
53/// One rung per ladder: the highest reached, plus every lower one to mark.
54struct 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
92/// Longest run of consecutive UTC days in `days` (`YYYY-MM-DD`).
93pub 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
111/// Every rung the proof has reached, most significant first.
112fn 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
137/// Decides against `state` (and updates it): the milestone to notify now,
138/// if any. Pure apart from `state`, so the policy is testable.
139fn 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
169/// Checks `proof` against the state in `dir` and claims the notification for
170/// this process. Returns what to show.
171fn 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    // Another server may have written in between; only the last writer shows.
180    (load_state(&path).claim == state.claim).then_some(milestone)
181}
182
183/// A desktop notification interrupts, so it is opt-in: `mode = milestones`
184/// (or `verbose`) and `notifications` not turned off.
185fn notifications_enabled(cfg: &ValueDisplayConfig) -> bool {
186    cfg.notifications
187        && matches!(
188            cfg.effective_mode(),
189            ValueDisplayMode::Milestones | ValueDisplayMode::Verbose
190        )
191}
192
193/// Called after tool calls (off the async runtime). Throttled; does nothing
194/// unless notifications are on.
195pub 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        // Next day: nothing new on the token ladder.
243        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        // A second server seeing the same ledger a day later has nothing new.
309        assert_eq!(check_in(dir.path(), &p, 3 * DAY), None);
310    }
311}