Skip to main content

lean_ctx/core/value/
snapshot.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Per-session value snapshot: the small file every display channel reads.
3//!
4//! Written by the MCP server after tool calls (throttled, atomic) to
5//! `<data_dir>/value/sessions/<id>.json`, `value/current.json` and, keyed by
6//! the session's project root, `value/projects/<hash>.json`. Readers (status
7//! line, prompt segment, hooks, IDE) never open the ledger on their hot path;
8//! `lean-ctx value` re-derives every number from the chains instead.
9
10use std::path::{Path, PathBuf};
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::core::security_events::SecurityCounts;
19
20pub const SCHEMA: u32 = 1;
21
22/// Minimum interval between two snapshot writes of one process.
23const WRITE_INTERVAL: Duration = Duration::from_secs(1);
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26#[serde(default)]
27pub struct ValueSnapshot {
28    pub schema: u32,
29    pub session_id: String,
30    pub project_root: Option<String>,
31    pub started_at: Option<DateTime<Utc>>,
32    pub updated_at: Option<DateTime<Utc>>,
33    pub tool_calls: u64,
34    /// Tokens lean-ctx's tools would have sent without compression.
35    pub tokens_input: u64,
36    /// Tokens kept out of the model's context (measured, per tool result).
37    pub tokens_saved: u64,
38    pub cache_hits: u64,
39    pub files_read: u64,
40    pub commands_run: u64,
41    pub security: SecurityCounts,
42    /// The agent process that started the writing MCP server (see [`super::host`]).
43    pub host_pid: Option<u32>,
44}
45
46impl ValueSnapshot {
47    pub fn from_session(session: &crate::core::session::SessionState) -> Self {
48        let s = &session.stats;
49        Self {
50            schema: SCHEMA,
51            session_id: session.id.clone(),
52            project_root: session.project_root.clone(),
53            started_at: Some(session.started_at),
54            updated_at: Some(Utc::now()),
55            tool_calls: u64::from(s.total_tool_calls),
56            tokens_input: s.total_tokens_input,
57            tokens_saved: s.total_tokens_saved,
58            cache_hits: u64::from(s.cache_hits),
59            files_read: u64::from(s.files_read),
60            commands_run: u64::from(s.commands_run),
61            security: s.security,
62            host_pid: super::host::host_pid(),
63        }
64    }
65
66    /// Share of tool input kept out of context, in percent (derived).
67    pub fn saved_pct(&self) -> Option<f64> {
68        (self.tokens_input > 0).then(|| self.tokens_saved as f64 * 100.0 / self.tokens_input as f64)
69    }
70
71    /// True when the snapshot was updated within `max_age`. Display channels
72    /// show nothing for a stale snapshot — never a misleading old number.
73    pub fn is_fresh(&self, max_age: Duration) -> bool {
74        let Some(updated) = self.updated_at else {
75            return false;
76        };
77        let age = Utc::now().signed_duration_since(updated);
78        age.to_std().map_or(true, |age| age <= max_age)
79    }
80
81    /// Nothing worth showing yet.
82    pub fn is_empty(&self) -> bool {
83        self.tokens_saved == 0 && self.security.is_empty()
84    }
85}
86
87pub fn value_dir() -> Option<PathBuf> {
88    crate::core::data_dir::lean_ctx_data_dir()
89        .ok()
90        .map(|dir| dir.join("value"))
91}
92
93fn sanitize_id(id: &str) -> Option<&str> {
94    let ok = !id.is_empty()
95        && id.len() <= 128
96        && id
97            .chars()
98            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
99        && !id.starts_with('.');
100    ok.then_some(id)
101}
102
103pub fn session_path(dir: &Path, id: &str) -> Option<PathBuf> {
104    sanitize_id(id).map(|id| dir.join("sessions").join(format!("{id}.json")))
105}
106
107pub fn current_path(dir: &Path) -> PathBuf {
108    dir.join("current.json")
109}
110
111/// Where the latest snapshot for project `root` lives. Hosts (a Claude status
112/// line, a Stop hook) know their working directory but not lean-ctx's session
113/// id; this pointer is how they find the numbers of the session serving them.
114pub fn project_path(dir: &Path, root: &str) -> PathBuf {
115    let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(root.as_bytes()));
116    dir.join("projects").join(format!("{}.json", &digest[..16]))
117}
118
119/// Writes `snap` as the session's snapshot, as `current.json` and, when the
120/// session has a project root, as that project's snapshot.
121pub fn write_to(dir: &Path, snap: &ValueSnapshot) -> Result<(), String> {
122    let path = session_path(dir, &snap.session_id).ok_or("invalid session id")?;
123    let json = serde_json::to_vec(snap).map_err(|e| e.to_string())?;
124    let project = snap
125        .project_root
126        .as_deref()
127        .filter(|root| !root.is_empty())
128        .map(|root| project_path(dir, root));
129    for target in [Some(&path), project.as_ref()].into_iter().flatten() {
130        if let Some(parent) = target.parent() {
131            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
132        }
133        crate::core::atomic_fs::try_atomic_write(target, &json, None).map_err(|e| e.to_string())?;
134    }
135    crate::core::atomic_fs::try_atomic_write(&current_path(dir), &json, None)
136        .map_err(|e| e.to_string())
137}
138
139/// Throttled write used by the MCP server: at most once per
140/// `WRITE_INTERVAL` (1 s), except that a changed security tally always writes so a
141/// blocked command or a kept-out secret shows up immediately.
142pub fn write_throttled(snap: &ValueSnapshot) {
143    static LAST: Mutex<Option<(Instant, SecurityCounts)>> = Mutex::new(None);
144    {
145        let Ok(mut last) = LAST.lock() else { return };
146        if let Some((at, security)) = *last
147            && at.elapsed() < WRITE_INTERVAL
148            && security == snap.security
149        {
150            return;
151        }
152        *last = Some((Instant::now(), snap.security));
153    }
154    let Some(dir) = value_dir() else { return };
155    if let Err(error) = write_to(&dir, snap) {
156        tracing::debug!("lean-ctx: value snapshot write failed: {error}");
157    }
158}
159
160pub fn read_path(path: &Path) -> Option<ValueSnapshot> {
161    let bytes = std::fs::read(path).ok()?;
162    serde_json::from_slice(&bytes).ok()
163}
164
165/// The snapshot of session `id`, or of the most recently active session.
166pub fn load(id: Option<&str>) -> Option<ValueSnapshot> {
167    let dir = value_dir()?;
168    match id {
169        Some(id) => read_path(&session_path(&dir, id)?),
170        None => read_path(&current_path(&dir)),
171    }
172}
173
174/// The latest snapshot of the project containing `cwd`: the nearest ancestor
175/// (canonical first, then as given) that has one.
176pub fn load_for_dir(cwd: &Path) -> Option<ValueSnapshot> {
177    load_for_dir_in(&value_dir()?, cwd)
178}
179
180pub fn load_for_dir_in(dir: &Path, cwd: &Path) -> Option<ValueSnapshot> {
181    let canonical = crate::core::pathutil::safe_canonicalize_or_self(cwd);
182    [canonical.as_path(), cwd]
183        .into_iter()
184        .flat_map(Path::ancestors)
185        .filter_map(Path::to_str)
186        .find_map(|root| {
187            read_path(&project_path(dir, root))
188                .filter(|snap| snap.project_root.as_deref() == Some(root))
189        })
190}
191
192/// The snapshot of the session serving the calling agent: a status line or hook
193/// is started by the same agent process that started its lean-ctx server, so
194/// that server's snapshot names one of the caller's ancestors as `host_pid`.
195/// Falls back to the project snapshot (whichever session wrote last) only while
196/// no server in the project records its host yet (all from older builds): once
197/// one does, a caller whose own session is not found yet (a server just
198/// restarted) shows nothing rather than a neighbour's numbers.
199pub fn load_for_caller_in(dir: &Path, cwd: &Path, max_age: Duration) -> Option<ValueSnapshot> {
200    load_own_or_legacy_in(dir, cwd, &super::host::ancestors(), max_age)
201}
202
203/// [`load_for_caller_in`] for an explicit ancestor chain.
204pub fn load_own_or_legacy_in(
205    dir: &Path,
206    cwd: &Path,
207    hosts: &[u32],
208    max_age: Duration,
209) -> Option<ValueSnapshot> {
210    let sessions = fresh_project_sessions(dir, cwd, max_age);
211    if let Some(own) = pick_by_host(&sessions, hosts) {
212        return Some(own);
213    }
214    if sessions.iter().any(|s| s.host_pid.is_some()) {
215        return None;
216    }
217    load_for_dir_in(dir, cwd).filter(|s| s.is_fresh(max_age))
218}
219
220/// Among fresh sessions of the project containing `cwd`, the one whose host is
221/// the nearest of `hosts` (nearest first); the newest when one host has several.
222pub fn load_for_hosts_in(
223    dir: &Path,
224    cwd: &Path,
225    hosts: &[u32],
226    max_age: Duration,
227) -> Option<ValueSnapshot> {
228    pick_by_host(&fresh_project_sessions(dir, cwd, max_age), hosts)
229}
230
231/// Whether any fresh session of the project records its host: from then on a
232/// caller that finds no session of its own must not borrow another's.
233pub fn host_recorded_in(dir: &Path, cwd: &Path, max_age: Duration) -> bool {
234    fresh_project_sessions(dir, cwd, max_age)
235        .iter()
236        .any(|s| s.host_pid.is_some())
237}
238
239fn pick_by_host(sessions: &[ValueSnapshot], hosts: &[u32]) -> Option<ValueSnapshot> {
240    sessions
241        .iter()
242        .filter_map(|s| Some((hosts.iter().position(|&h| Some(h) == s.host_pid)?, s)))
243        .min_by(|(ra, a), (rb, b)| ra.cmp(rb).then(b.updated_at.cmp(&a.updated_at)))
244        .map(|(_, s)| s.clone())
245}
246
247/// Every session of the project containing `cwd` written within `max_age`.
248fn fresh_project_sessions(dir: &Path, cwd: &Path, max_age: Duration) -> Vec<ValueSnapshot> {
249    let canonical = crate::core::pathutil::safe_canonicalize_or_self(cwd);
250    let in_project = |root: &str| canonical.starts_with(root) || cwd.starts_with(root);
251    let (Some(cutoff), Ok(entries)) = (
252        std::time::SystemTime::now().checked_sub(max_age),
253        std::fs::read_dir(dir.join("sessions")),
254    ) else {
255        return Vec::new();
256    };
257    entries
258        .filter_map(Result::ok)
259        // Cheap mtime filter first: the directory keeps every past session.
260        .filter(|e| {
261            e.metadata()
262                .and_then(|m| m.modified())
263                .is_ok_and(|t| t >= cutoff)
264        })
265        .filter_map(|e| read_path(&e.path()))
266        .filter(|s| s.is_fresh(max_age) && s.project_root.as_deref().is_some_and(in_project))
267        .collect()
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn snap(id: &str) -> ValueSnapshot {
275        ValueSnapshot {
276            schema: SCHEMA,
277            session_id: id.into(),
278            updated_at: Some(Utc::now()),
279            tokens_input: 1_000,
280            tokens_saved: 400,
281            ..ValueSnapshot::default()
282        }
283    }
284
285    #[test]
286    fn project_snapshot_is_found_from_any_subdirectory() {
287        let data = tempfile::tempdir().unwrap();
288        let project = tempfile::tempdir().unwrap();
289        let root = crate::core::pathutil::safe_canonicalize_or_self(project.path());
290        let nested = root.join("src/deep");
291        std::fs::create_dir_all(&nested).unwrap();
292        let mut s = snap("sess-p");
293        s.project_root = Some(root.to_string_lossy().into_owned());
294        write_to(data.path(), &s).unwrap();
295
296        assert_eq!(load_for_dir_in(data.path(), &nested), Some(s.clone()));
297        assert_eq!(load_for_dir_in(data.path(), &root), Some(s));
298        let elsewhere = tempfile::tempdir().unwrap();
299        assert_eq!(load_for_dir_in(data.path(), elsewhere.path()), None);
300    }
301
302    #[test]
303    fn each_agent_reads_its_own_session_not_the_last_writer() {
304        let data = tempfile::tempdir().unwrap();
305        let project = tempfile::tempdir().unwrap();
306        let root = crate::core::pathutil::safe_canonicalize_or_self(project.path());
307        let root_str = root.to_string_lossy().into_owned();
308        let mut ours = snap("ours");
309        ours.project_root = Some(root_str.clone());
310        ours.host_pid = Some(4_100);
311        ours.tokens_saved = 4_200;
312        let mut theirs = snap("theirs");
313        theirs.project_root = Some(root_str);
314        theirs.host_pid = Some(9_900);
315        theirs.tokens_saved = 357_500;
316        write_to(data.path(), &ours).unwrap();
317        write_to(data.path(), &theirs).unwrap(); // last writer owns the project file
318
319        let max = Duration::from_hours(12);
320        let found = load_for_hosts_in(data.path(), &root, &[77, 4_100, 1_000], max).unwrap();
321        assert_eq!(found.session_id, "ours");
322        // No ancestor matches (our server just restarted): nothing, never the
323        // neighbour the project file names, because servers record their host.
324        assert_eq!(load_for_hosts_in(data.path(), &root, &[5, 6], max), None);
325        assert_eq!(
326            load_for_dir_in(data.path(), &root).unwrap().session_id,
327            "theirs"
328        );
329        assert_eq!(
330            load_own_or_legacy_in(data.path(), &root, &[5, 6], max),
331            None
332        );
333        assert!(host_recorded_in(data.path(), &root, max));
334        let own = load_own_or_legacy_in(data.path(), &root, &[4_100], max).unwrap();
335        assert_eq!(own.session_id, "ours");
336        let elsewhere = tempfile::tempdir().unwrap();
337        assert_eq!(
338            load_for_hosts_in(data.path(), elsewhere.path(), &[4_100], max),
339            None
340        );
341    }
342
343    #[test]
344    fn servers_without_a_host_record_keep_the_project_fallback() {
345        let data = tempfile::tempdir().unwrap();
346        let project = tempfile::tempdir().unwrap();
347        let root = crate::core::pathutil::safe_canonicalize_or_self(project.path());
348        let mut legacy = snap("legacy");
349        legacy.project_root = Some(root.to_string_lossy().into_owned());
350        write_to(data.path(), &legacy).unwrap();
351        let max = Duration::from_hours(12);
352        assert!(!host_recorded_in(data.path(), &root, max));
353        let found = load_own_or_legacy_in(data.path(), &root, &[1_234], max).unwrap();
354        assert_eq!(found.session_id, "legacy");
355    }
356
357    #[test]
358    fn roundtrip_through_session_and_current_file() {
359        let dir = tempfile::tempdir().unwrap();
360        let s = snap("sess-1");
361        write_to(dir.path(), &s).unwrap();
362        assert_eq!(
363            read_path(&session_path(dir.path(), "sess-1").unwrap()),
364            Some(s.clone())
365        );
366        assert_eq!(read_path(&current_path(dir.path())), Some(s));
367    }
368
369    #[test]
370    fn rejects_path_traversal_ids() {
371        let dir = tempfile::tempdir().unwrap();
372        assert!(session_path(dir.path(), "../evil").is_none());
373        assert!(session_path(dir.path(), "a/b").is_none());
374        assert!(session_path(dir.path(), "").is_none());
375        assert!(write_to(dir.path(), &snap("../x")).is_err());
376    }
377
378    #[test]
379    fn staleness_is_detected() {
380        let mut s = snap("s");
381        assert!(s.is_fresh(Duration::from_mins(1)));
382        s.updated_at = Some(Utc::now() - chrono::Duration::hours(2));
383        assert!(!s.is_fresh(Duration::from_mins(1)));
384        s.updated_at = None;
385        assert!(!s.is_fresh(Duration::from_mins(1)));
386    }
387
388    #[test]
389    fn saved_pct_is_derived_from_measured_counts() {
390        assert_eq!(snap("s").saved_pct(), Some(40.0));
391        assert_eq!(ValueSnapshot::default().saved_pct(), None);
392    }
393
394    #[test]
395    fn unknown_fields_and_missing_fields_are_tolerated() {
396        let s: ValueSnapshot =
397            serde_json::from_str(r#"{"session_id":"x","future_field":1}"#).unwrap();
398        assert_eq!(s.session_id, "x");
399        assert!(s.is_empty());
400    }
401}