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 cep = &store.cep;
43            let entry = serde_json::json!({
44                "date": &today,
45                "tokens_original": cep.total_tokens_original,
46                "tokens_compressed": cep.total_tokens_compressed,
47                "tokens_saved": cep.total_tokens_original.saturating_sub(cep.total_tokens_compressed),
48                "tool_calls": store.total_commands,
49                "cache_hits": cep.total_cache_hits,
50                "cache_misses": cep.total_cache_reads.saturating_sub(cep.total_cache_hits),
51            });
52            if crate::cloud_client::sync_stats(&[entry]).is_ok() {
53                config.cloud.last_sync = Some(today.clone());
54            }
55        }
56
57        if !already_gain_synced {
58            let engine = crate::core::gain::GainEngine::load();
59            let summary = engine.summary(None);
60            let trend = match summary.score.trend {
61                crate::core::gain::gain_score::Trend::Rising => "rising",
62                crate::core::gain::gain_score::Trend::Stable => "stable",
63                crate::core::gain::gain_score::Trend::Declining => "declining",
64            };
65            let entry = serde_json::json!({
66                "recorded_at": format!("{today}T00:00:00Z"),
67                "total": summary.score.total as f64,
68                "compression": summary.score.compression as f64,
69                "cost_efficiency": summary.score.cost_efficiency as f64,
70                "quality": summary.score.quality as f64,
71                "consistency": summary.score.consistency as f64,
72                "trend": trend,
73                "avoided_usd": summary.avoided_usd,
74                "tool_spend_usd": summary.tool_spend_usd,
75                "model_key": summary.model.model_key,
76            });
77            if crate::cloud_client::push_gain(&[entry]).is_ok() {
78                config.cloud.last_gain_sync = Some(today.clone());
79            }
80        }
81
82        if !already_pulled {
83            if let Ok(data) = crate::cloud_client::pull_cloud_models() {
84                let _ = crate::cloud_client::save_cloud_models(&data);
85                config.cloud.last_model_pull = Some(today.clone());
86            }
87        }
88    }
89
90    let _ = config.save();
91}
92
93pub fn collect_contribute_entries() -> Vec<serde_json::Value> {
94    let mut entries = Vec::new();
95
96    if let Some(home) = dirs::home_dir() {
97        let mode_stats_path = crate::core::data_dir::lean_ctx_data_dir()
98            .unwrap_or_else(|_| home.join(".lean-ctx"))
99            .join("mode_stats.json");
100        if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
101            if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
102                if let Some(history) = predictor["history"].as_object() {
103                    for (_key, outcomes) in history {
104                        if let Some(arr) = outcomes.as_array() {
105                            for outcome in arr.iter().rev().take(3) {
106                                let ext = outcome["ext"].as_str().unwrap_or("unknown");
107                                let mode = outcome["mode"].as_str().unwrap_or("full");
108                                let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
109                                let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
110                                let ratio = if t_in > 0 {
111                                    1.0 - t_out as f64 / t_in as f64
112                                } else {
113                                    0.0
114                                };
115                                let bucket = match t_in {
116                                    0..=500 => "0-500",
117                                    501..=2000 => "500-2k",
118                                    2001..=10000 => "2k-10k",
119                                    _ => "10k+",
120                                };
121                                entries.push(serde_json::json!({
122                                    "file_ext": format!(".{ext}"),
123                                    "size_bucket": bucket,
124                                    "best_mode": mode,
125                                    "compression_ratio": (ratio * 100.0).round() / 100.0,
126                                }));
127                                if entries.len() >= 200 {
128                                    return entries;
129                                }
130                            }
131                        }
132                    }
133                }
134            }
135        }
136    }
137
138    if entries.is_empty() {
139        let stats_data = crate::core::stats::format_gain_json();
140        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
141            let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
142            let compressed = parsed["cep"]["total_tokens_compressed"]
143                .as_u64()
144                .unwrap_or(0);
145            let ratio = if original > 0 {
146                1.0 - compressed as f64 / original as f64
147            } else {
148                0.0
149            };
150            if let Some(modes) = parsed["cep"]["modes"].as_object() {
151                let read_modes = [
152                    "full",
153                    "map",
154                    "signatures",
155                    "auto",
156                    "aggressive",
157                    "entropy",
158                    "diff",
159                    "lines",
160                    "task",
161                    "reference",
162                ];
163                for (mode, count) in modes {
164                    if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
165                        continue;
166                    }
167                    entries.push(serde_json::json!({
168                        "file_ext": "mixed",
169                        "size_bucket": "mixed",
170                        "best_mode": mode,
171                        "compression_ratio": (ratio * 100.0).round() / 100.0,
172                    }));
173                }
174            }
175        }
176    }
177
178    entries
179}