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