Skip to main content

lean_ctx/
cloud_sync.rs

1use crate::core::config::Config;
2
3pub fn cloud_background_tasks() {
4    let mut config = Config::load();
5    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
6
7    let already_contributed = config
8        .cloud
9        .last_contribute
10        .as_deref()
11        .map(|d| d == today)
12        .unwrap_or(false);
13    let already_synced = config
14        .cloud
15        .last_sync
16        .as_deref()
17        .map(|d| d == today)
18        .unwrap_or(false);
19    let already_gain_synced = config
20        .cloud
21        .last_gain_sync
22        .as_deref()
23        .map(|d| d == today)
24        .unwrap_or(false);
25    let already_pulled = config
26        .cloud
27        .last_model_pull
28        .as_deref()
29        .map(|d| d == today)
30        .unwrap_or(false);
31
32    if config.cloud.contribute_enabled && !already_contributed {
33        let entries = collect_contribute_entries();
34        if !entries.is_empty() && crate::cloud_client::contribute(&entries).is_ok() {
35            config.cloud.last_contribute = Some(today.clone());
36        }
37    }
38
39    if crate::cloud_client::is_logged_in() {
40        if !already_synced {
41            let store = crate::core::stats::load();
42            let entries = build_sync_entries(&store);
43            if !entries.is_empty() && crate::cloud_client::sync_stats(&entries).is_ok() {
44                config.cloud.last_sync = Some(today.clone());
45            }
46        }
47
48        if !already_gain_synced {
49            let engine = crate::core::gain::GainEngine::load();
50            let summary = engine.summary(None);
51            let trend = match summary.score.trend {
52                crate::core::gain::gain_score::Trend::Rising => "rising",
53                crate::core::gain::gain_score::Trend::Stable => "stable",
54                crate::core::gain::gain_score::Trend::Declining => "declining",
55            };
56            let entry = serde_json::json!({
57                "recorded_at": format!("{today}T00:00:00Z"),
58                "total": summary.score.total as f64,
59                "compression": summary.score.compression as f64,
60                "cost_efficiency": summary.score.cost_efficiency as f64,
61                "quality": summary.score.quality as f64,
62                "consistency": summary.score.consistency as f64,
63                "trend": trend,
64                "avoided_usd": summary.avoided_usd,
65                "tool_spend_usd": summary.tool_spend_usd,
66                "model_key": summary.model.model_key,
67            });
68            if crate::cloud_client::push_gain(&[entry]).is_ok() {
69                config.cloud.last_gain_sync = Some(today.clone());
70            }
71        }
72
73        if !already_pulled {
74            if let Ok(data) = crate::cloud_client::pull_cloud_models() {
75                let _ = crate::cloud_client::save_cloud_models(&data);
76                config.cloud.last_model_pull = Some(today.clone());
77            }
78        }
79    }
80
81    let _ = config.save();
82}
83
84pub fn build_sync_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
85    let mut entries = Vec::new();
86    let cep = &store.cep;
87    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
88
89    let mut cep_cache_by_day: std::collections::HashMap<String, (u64, u64)> =
90        std::collections::HashMap::new();
91    for s in &cep.scores {
92        if let Some(date) = s.timestamp.get(..10) {
93            let entry = cep_cache_by_day.entry(date.to_string()).or_default();
94            let calls = s.tool_calls.max(1);
95            let hits = (calls as f64 * s.cache_hit_rate as f64 / 100.0).round() as u64;
96            entry.0 += calls;
97            entry.1 += hits;
98        }
99    }
100
101    let mut mcp_saved_total = 0u64;
102    for (cmd, s) in &store.commands {
103        if cmd.starts_with("ctx_") {
104            mcp_saved_total += s.input_tokens.saturating_sub(s.output_tokens);
105        }
106    }
107    let global_saved = store
108        .total_input_tokens
109        .saturating_sub(store.total_output_tokens)
110        .max(1);
111    let mcp_ratio = mcp_saved_total as f64 / global_saved as f64;
112
113    for day in &store.daily {
114        let tokens_original = day.input_tokens;
115        let tokens_compressed = day.output_tokens;
116        let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
117        let (day_calls, day_hits) = cep_cache_by_day.get(&day.date).copied().unwrap_or((0, 0));
118        let day_mcp_saved = (tokens_saved as f64 * mcp_ratio).round() as u64;
119        let day_hook_saved = tokens_saved.saturating_sub(day_mcp_saved);
120        entries.push(serde_json::json!({
121            "date": day.date,
122            "tokens_original": tokens_original,
123            "tokens_compressed": tokens_compressed,
124            "tokens_saved": tokens_saved,
125            "mcp_tokens_saved": day_mcp_saved,
126            "hook_tokens_saved": day_hook_saved,
127            "tool_calls": day.commands,
128            "cache_hits": day_hits,
129            "cache_misses": day_calls.saturating_sub(day_hits),
130        }));
131    }
132
133    let has_today = entries.iter().any(|e| e["date"].as_str() == Some(&today));
134    if !has_today && (cep.total_tokens_original > 0 || store.total_commands > 0) {
135        let today_saved = cep
136            .total_tokens_original
137            .saturating_sub(cep.total_tokens_compressed);
138        let today_mcp = (today_saved as f64 * mcp_ratio).round() as u64;
139        entries.push(serde_json::json!({
140            "date": today,
141            "tokens_original": cep.total_tokens_original,
142            "tokens_compressed": cep.total_tokens_compressed,
143            "tokens_saved": today_saved,
144            "mcp_tokens_saved": today_mcp,
145            "hook_tokens_saved": today_saved.saturating_sub(today_mcp),
146            "tool_calls": store.total_commands,
147            "cache_hits": cep.total_cache_hits,
148            "cache_misses": cep.total_cache_reads.saturating_sub(cep.total_cache_hits),
149        }));
150    }
151
152    entries
153}
154
155pub fn collect_contribute_entries() -> Vec<serde_json::Value> {
156    let mut entries = Vec::new();
157
158    if let Some(home) = dirs::home_dir() {
159        let mode_stats_path = crate::core::data_dir::lean_ctx_data_dir()
160            .unwrap_or_else(|_| home.join(".lean-ctx"))
161            .join("mode_stats.json");
162        if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
163            if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
164                if let Some(history) = predictor["history"].as_object() {
165                    for (_key, outcomes) in history {
166                        if let Some(arr) = outcomes.as_array() {
167                            for outcome in arr.iter().rev().take(3) {
168                                let ext = outcome["ext"].as_str().unwrap_or("unknown");
169                                let mode = outcome["mode"].as_str().unwrap_or("full");
170                                let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
171                                let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
172                                let ratio = if t_in > 0 {
173                                    1.0 - t_out as f64 / t_in as f64
174                                } else {
175                                    0.0
176                                };
177                                let bucket = match t_in {
178                                    0..=500 => "0-500",
179                                    501..=2000 => "500-2k",
180                                    2001..=10000 => "2k-10k",
181                                    _ => "10k+",
182                                };
183                                entries.push(serde_json::json!({
184                                    "file_ext": format!(".{ext}"),
185                                    "size_bucket": bucket,
186                                    "best_mode": mode,
187                                    "compression_ratio": (ratio * 100.0).round() / 100.0,
188                                }));
189                                if entries.len() >= 200 {
190                                    return entries;
191                                }
192                            }
193                        }
194                    }
195                }
196            }
197        }
198    }
199
200    if entries.is_empty() {
201        let stats_data = crate::core::stats::format_gain_json();
202        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
203            let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
204            let compressed = parsed["cep"]["total_tokens_compressed"]
205                .as_u64()
206                .unwrap_or(0);
207            let ratio = if original > 0 {
208                1.0 - compressed as f64 / original as f64
209            } else {
210                0.0
211            };
212            if let Some(modes) = parsed["cep"]["modes"].as_object() {
213                let read_modes = [
214                    "full",
215                    "map",
216                    "signatures",
217                    "auto",
218                    "aggressive",
219                    "entropy",
220                    "diff",
221                    "lines",
222                    "task",
223                    "reference",
224                ];
225                for (mode, count) in modes {
226                    if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
227                        continue;
228                    }
229                    entries.push(serde_json::json!({
230                        "file_ext": "mixed",
231                        "size_bucket": "mixed",
232                        "best_mode": mode,
233                        "compression_ratio": (ratio * 100.0).round() / 100.0,
234                    }));
235                }
236            }
237        }
238    }
239
240    entries
241}