1use 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
24const TURN_STATE_TTL: Duration = Duration::from_hours(24 * 7);
26const 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct TurnRecap {
66 pub turns: u32,
67 pub saved: u64,
68 pub security: SecurityCounts,
69}
70
71pub 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 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
131pub 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
141pub 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
175pub 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 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 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
207pub 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
256pub 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#[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 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
301pub 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;