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 for day in &store.daily {
102 let tokens_original = day.input_tokens;
103 let tokens_compressed = day.output_tokens;
104 let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
105 let (day_calls, day_hits) = cep_cache_by_day.get(&day.date).copied().unwrap_or((0, 0));
106 entries.push(serde_json::json!({
107 "date": day.date,
108 "tokens_original": tokens_original,
109 "tokens_compressed": tokens_compressed,
110 "tokens_saved": tokens_saved,
111 "tool_calls": day.commands,
112 "cache_hits": day_hits,
113 "cache_misses": day_calls.saturating_sub(day_hits),
114 }));
115 }
116
117 let has_today = entries.iter().any(|e| e["date"].as_str() == Some(&today));
118 if !has_today && (cep.total_tokens_original > 0 || store.total_commands > 0) {
119 entries.push(serde_json::json!({
120 "date": today,
121 "tokens_original": cep.total_tokens_original,
122 "tokens_compressed": cep.total_tokens_compressed,
123 "tokens_saved": cep.total_tokens_original.saturating_sub(cep.total_tokens_compressed),
124 "tool_calls": store.total_commands,
125 "cache_hits": cep.total_cache_hits,
126 "cache_misses": cep.total_cache_reads.saturating_sub(cep.total_cache_hits),
127 }));
128 }
129
130 entries
131}
132
133pub fn collect_contribute_entries() -> Vec<serde_json::Value> {
134 let mut entries = Vec::new();
135
136 if let Some(home) = dirs::home_dir() {
137 let mode_stats_path = crate::core::data_dir::lean_ctx_data_dir()
138 .unwrap_or_else(|_| home.join(".lean-ctx"))
139 .join("mode_stats.json");
140 if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
141 if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
142 if let Some(history) = predictor["history"].as_object() {
143 for (_key, outcomes) in history {
144 if let Some(arr) = outcomes.as_array() {
145 for outcome in arr.iter().rev().take(3) {
146 let ext = outcome["ext"].as_str().unwrap_or("unknown");
147 let mode = outcome["mode"].as_str().unwrap_or("full");
148 let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
149 let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
150 let ratio = if t_in > 0 {
151 1.0 - t_out as f64 / t_in as f64
152 } else {
153 0.0
154 };
155 let bucket = match t_in {
156 0..=500 => "0-500",
157 501..=2000 => "500-2k",
158 2001..=10000 => "2k-10k",
159 _ => "10k+",
160 };
161 entries.push(serde_json::json!({
162 "file_ext": format!(".{ext}"),
163 "size_bucket": bucket,
164 "best_mode": mode,
165 "compression_ratio": (ratio * 100.0).round() / 100.0,
166 }));
167 if entries.len() >= 200 {
168 return entries;
169 }
170 }
171 }
172 }
173 }
174 }
175 }
176 }
177
178 if entries.is_empty() {
179 let stats_data = crate::core::stats::format_gain_json();
180 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
181 let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
182 let compressed = parsed["cep"]["total_tokens_compressed"]
183 .as_u64()
184 .unwrap_or(0);
185 let ratio = if original > 0 {
186 1.0 - compressed as f64 / original as f64
187 } else {
188 0.0
189 };
190 if let Some(modes) = parsed["cep"]["modes"].as_object() {
191 let read_modes = [
192 "full",
193 "map",
194 "signatures",
195 "auto",
196 "aggressive",
197 "entropy",
198 "diff",
199 "lines",
200 "task",
201 "reference",
202 ];
203 for (mode, count) in modes {
204 if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
205 continue;
206 }
207 entries.push(serde_json::json!({
208 "file_ext": "mixed",
209 "size_bucket": "mixed",
210 "best_mode": mode,
211 "compression_ratio": (ratio * 100.0).round() / 100.0,
212 }));
213 }
214 }
215 }
216 }
217
218 entries
219}