Skip to main content

lean_ctx/core/value/
recap.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Turn and session recaps: when lean-ctx says something, and what.
3//!
4//! Hosts identify a conversation by their own session id (Claude's Stop and
5//! SessionStart payloads), which is not lean-ctx's session id. Per host
6//! session, [`TurnState`] counts turns and remembers the numbers at the last
7//! recap; the numbers themselves come from the project snapshot. A recap only
8//! ever describes the window since the previous one — never the whole lean-ctx
9//! session as if it happened in the last few turns.
10
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17use crate::core::config::{ValueDisplayConfig, ValueDisplayMode};
18use crate::core::security_events::SecurityCounts;
19use crate::core::wrapped::format_tokens;
20
21use super::format::{Style, security_phrases};
22use super::snapshot::{ValueSnapshot, read_path};
23
24/// Host-session turn files older than this are pruned.
25const TURN_STATE_TTL: Duration = Duration::from_hours(24 * 7);
26/// A last-session recap is only offered for a session active this recently.
27const RECAP_MAX_AGE: Duration = Duration::from_hours(24 * 7);
28const DIGEST_EVERY: chrono::TimeDelta = chrono::TimeDelta::days(7);
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct TurnState {
33    pub created_at: Option<DateTime<Utc>>,
34    /// The lean-ctx session the baseline below belongs to.
35    pub lean_session: String,
36    pub turns: u32,
37    pub last_recap_turn: u32,
38    pub base_saved: u64,
39    pub base_security: SecurityCounts,
40}
41
42impl TurnState {
43    /// A new host session, baselined at `snap` so nothing that happened before
44    /// it is attributed to its turns.
45    pub fn start(now: DateTime<Utc>, snap: Option<&ValueSnapshot>) -> Self {
46        let mut state = Self {
47            created_at: Some(now),
48            ..Self::default()
49        };
50        if let Some(snap) = snap {
51            state.rebase(snap);
52        }
53        state
54    }
55
56    fn rebase(&mut self, snap: &ValueSnapshot) {
57        self.lean_session.clone_from(&snap.session_id);
58        self.base_saved = snap.tokens_saved;
59        self.base_security = snap.security;
60    }
61}
62
63/// What happened in one recap window.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct TurnRecap {
66    pub turns: u32,
67    pub saved: u64,
68    pub security: SecurityCounts,
69}
70
71/// Counts one finished turn and decides whether it ends a recap window.
72///
73/// A window closes after `recap_every_turns` turns, and only when it saved at
74/// least `recap_min_tokens` or had a security event (`verbose`: anything
75/// measured). Otherwise it keeps growing, and the recap names the real length.
76pub fn advance(
77    state: &mut TurnState,
78    snap: Option<&ValueSnapshot>,
79    cfg: &ValueDisplayConfig,
80    verbose: bool,
81) -> Option<TurnRecap> {
82    state.turns = state.turns.saturating_add(1);
83    let snap = snap?;
84    if snap.session_id != state.lean_session {
85        // A lean-ctx session that began inside this host session counts from
86        // zero; one carried over from earlier counts from now on.
87        let began_here = matches!(
88            (snap.started_at, state.created_at),
89            (Some(started), Some(created)) if started >= created
90        );
91        state.rebase(snap);
92        if began_here {
93            state.base_saved = 0;
94            state.base_security = SecurityCounts::default();
95        }
96    }
97    let window = state.turns.saturating_sub(state.last_recap_turn);
98    if window < cfg.recap_every_turns.max(1) {
99        return None;
100    }
101    let saved = snap.tokens_saved.saturating_sub(state.base_saved);
102    let security = snap.security.since(&state.base_security);
103    let worth = !security.is_empty()
104        || if verbose {
105            saved > 0
106        } else {
107            saved > 0 && saved >= cfg.recap_min_tokens
108        };
109    if !worth {
110        return None;
111    }
112    state.last_recap_turn = state.turns;
113    state.rebase(snap);
114    Some(TurnRecap {
115        turns: window,
116        saved,
117        security,
118    })
119}
120
121fn line(style: Style, label: &str, saved: u64, security: &SecurityCounts) -> String {
122    let mut parts = Vec::new();
123    if saved > 0 {
124        parts.push(format!("{}{} tokens", style.minus(), format_tokens(saved)));
125    }
126    parts.extend(security_phrases(security));
127    let sep = style.sep();
128    format!("{} lean-ctx{sep}{label}: {}", style.mark(), parts.join(sep))
129}
130
131/// `◆ lean-ctx · last 10 turns: −312.0K tokens · 2 secrets kept out of context`
132pub fn turn_line(recap: &TurnRecap, style: Style) -> String {
133    let label = if recap.turns == 1 {
134        "last turn".to_string()
135    } else {
136        format!("last {} turns", recap.turns)
137    };
138    line(style, &label, recap.saved, &recap.security)
139}
140
141// ---------------------------------------------------------------------------
142// Files
143// ---------------------------------------------------------------------------
144
145pub fn turn_state_path(dir: &Path, host_session: &str) -> Option<PathBuf> {
146    let ok = !host_session.is_empty()
147        && host_session.len() <= 128
148        && !host_session.starts_with('.')
149        && host_session
150            .chars()
151            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
152    ok.then(|| dir.join("turns").join(format!("{host_session}.json")))
153}
154
155fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
156    serde_json::from_slice(&std::fs::read(path).ok()?).ok()
157}
158
159fn write_json<T: Serialize>(path: &Path, value: &T) {
160    let Ok(json) = serde_json::to_vec(value) else {
161        return;
162    };
163    if let Some(parent) = path.parent() {
164        let _ = std::fs::create_dir_all(parent);
165    }
166    if let Err(error) = crate::core::atomic_fs::try_atomic_write(path, &json, None) {
167        tracing::debug!("lean-ctx: value recap state write failed: {error}");
168    }
169}
170
171pub fn load_turn_state(dir: &Path, host_session: &str) -> Option<TurnState> {
172    read_json(&turn_state_path(dir, host_session)?)
173}
174
175/// The Stop-hook entry point: counts the turn and returns the recap line when
176/// this turn closes a window worth mentioning.
177pub fn on_turn_end(
178    dir: &Path,
179    host_session: &str,
180    snap: Option<&ValueSnapshot>,
181    cfg: &ValueDisplayConfig,
182    now: DateTime<Utc>,
183    style: Style,
184) -> Option<String> {
185    let path = turn_state_path(dir, host_session)?;
186    // No state means the hook was installed mid-conversation: baseline now,
187    // so earlier work is never claimed for these turns.
188    let mut state = read_json(&path).unwrap_or_else(|| TurnState::start(now, snap));
189    let verbose = cfg.effective_mode() == ValueDisplayMode::Verbose;
190    let recap = advance(&mut state, snap, cfg, verbose);
191    write_json(&path, &state);
192    recap.map(|r| turn_line(&r, style))
193}
194
195#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(default)]
197pub struct RecapState {
198    /// The lean-ctx session that already got its "last session" recap.
199    pub last_session_recapped: String,
200    pub last_digest_at: Option<DateTime<Utc>>,
201}
202
203fn recap_state_path(dir: &Path) -> PathBuf {
204    dir.join("recap_state.json")
205}
206
207/// The SessionStart entry point. Starts the host session's turn count and, on
208/// a fresh start (not a resume or compaction), returns either the weekly
209/// digest (at most once per 7 days) or a one-time recap of the project's last
210/// lean-ctx session.
211pub fn on_session_start(
212    dir: &Path,
213    host_session: &str,
214    source: &str,
215    snap: Option<&ValueSnapshot>,
216    cfg: &ValueDisplayConfig,
217    now: DateTime<Utc>,
218    style: Style,
219) -> Option<String> {
220    prune_turn_states(dir);
221    if let Some(path) = turn_state_path(dir, host_session)
222        && !path.exists()
223    {
224        write_json(&path, &TurnState::start(now, snap));
225    }
226    if !matches!(source, "" | "startup") {
227        return None;
228    }
229    let state_path = recap_state_path(dir);
230    let mut state: RecapState = read_json(&state_path).unwrap_or_default();
231    let verbose = cfg.effective_mode() == ValueDisplayMode::Verbose;
232
233    let digest_due = state
234        .last_digest_at
235        .is_none_or(|at| now.signed_duration_since(at) >= DIGEST_EVERY);
236    if digest_due && let Some(digest) = weekly_digest(dir, now) {
237        state.last_digest_at = Some(now);
238        if let Some(snap) = snap {
239            state.last_session_recapped.clone_from(&snap.session_id);
240        }
241        write_json(&state_path, &state);
242        return Some(digest_line(&digest, style));
243    }
244
245    let snap = snap?;
246    let worth = !snap.security.is_empty()
247        || snap.tokens_saved > 0 && (verbose || snap.tokens_saved >= cfg.recap_min_tokens);
248    if !worth || snap.session_id == state.last_session_recapped || !snap.is_fresh(RECAP_MAX_AGE) {
249        return None;
250    }
251    state.last_session_recapped.clone_from(&snap.session_id);
252    write_json(&state_path, &state);
253    Some(session_line(snap, style))
254}
255
256/// `◆ lean-ctx · last session (62% of tool input): −1.4M tokens · 3 secrets kept out of context`
257pub fn session_line(snap: &ValueSnapshot, style: Style) -> String {
258    let label = match snap.saved_pct() {
259        Some(pct) if snap.tokens_saved > 0 => format!("last session ({pct:.0}% of tool input)"),
260        _ => "last session".to_string(),
261    };
262    line(style, &label, snap.tokens_saved, &snap.security)
263}
264
265/// Totals over every session snapshot updated in the last 7 days.
266#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
267pub struct Digest {
268    pub sessions: u64,
269    pub saved: u64,
270    pub security: SecurityCounts,
271}
272
273pub fn weekly_digest(dir: &Path, now: DateTime<Utc>) -> Option<Digest> {
274    let since = now - DIGEST_EVERY;
275    let window = DIGEST_EVERY.to_std().unwrap_or_default();
276    let mut digest = Digest::default();
277    for entry in std::fs::read_dir(dir.join("sessions")).ok()?.flatten() {
278        // The file time is a cheap pre-filter; the snapshot's own clock decides.
279        let recent = entry
280            .metadata()
281            .and_then(|m| m.modified())
282            .ok()
283            .and_then(|t| t.elapsed().ok())
284            .is_none_or(|age| age <= window);
285        if !recent {
286            continue;
287        }
288        let Some(snap) = read_path(&entry.path()) else {
289            continue;
290        };
291        if snap.updated_at.is_none_or(|at| at < since) || snap.is_empty() {
292            continue;
293        }
294        digest.sessions += 1;
295        digest.saved = digest.saved.saturating_add(snap.tokens_saved);
296        digest.security.merge(&snap.security);
297    }
298    (digest.sessions > 0).then_some(digest)
299}
300
301/// `◆ lean-ctx · this week (12 sessions): −8.4M tokens · 3 secrets kept out of context`
302pub fn digest_line(digest: &Digest, style: Style) -> String {
303    let label = if digest.sessions == 1 {
304        "this week (1 session)".to_string()
305    } else {
306        format!("this week ({} sessions)", digest.sessions)
307    };
308    line(style, &label, digest.saved, &digest.security)
309}
310
311fn prune_turn_states(dir: &Path) {
312    let Ok(entries) = std::fs::read_dir(dir.join("turns")) else {
313        return;
314    };
315    for entry in entries.flatten() {
316        let stale = entry
317            .metadata()
318            .and_then(|m| m.modified())
319            .ok()
320            .and_then(|t| t.elapsed().ok())
321            .is_some_and(|age| age > TURN_STATE_TTL);
322        if stale {
323            let _ = std::fs::remove_file(entry.path());
324        }
325    }
326}
327
328#[cfg(test)]
329#[path = "recap_tests.rs"]
330mod tests;