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}