1use crate::core::config::Config;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum AutoSyncOutcome {
6 Synced,
8 Gated,
10 NetworkFailure,
13}
14
15#[must_use]
18pub fn should_auto_sync(
19 auto_sync: bool,
20 logged_in: bool,
21 last_auto_sync: Option<&str>,
22 today: &str,
23) -> bool {
24 auto_sync && logged_in && last_auto_sync != Some(today)
25}
26
27#[must_use]
31pub fn should_auto_push_index(
32 auto_index: bool,
33 logged_in: bool,
34 local_index_exists: bool,
35 last_push_for_project: Option<&str>,
36 today: &str,
37) -> bool {
38 auto_index && logged_in && local_index_exists && last_push_for_project != Some(today)
39}
40
41#[must_use]
45pub fn classify_outcomes(results: &[Result<(), String>]) -> AutoSyncOutcome {
46 if results
47 .iter()
48 .any(|r| r.as_ref().is_err_and(|e| e.contains("402")))
49 {
50 return AutoSyncOutcome::Gated;
51 }
52 if !results.is_empty() && results.iter().all(Result::is_err) {
53 return AutoSyncOutcome::NetworkFailure;
54 }
55 AutoSyncOutcome::Synced
56}
57
58pub fn cloud_background_tasks() {
59 let mut config = Config::load_global();
62 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
63
64 let already_contributed = config
65 .cloud
66 .last_contribute
67 .as_deref()
68 .is_some_and(|d| d == today);
69 let already_synced = config
70 .cloud
71 .last_sync
72 .as_deref()
73 .is_some_and(|d| d == today);
74 let already_gain_synced = config
75 .cloud
76 .last_gain_sync
77 .as_deref()
78 .is_some_and(|d| d == today);
79 let already_pulled = config
80 .cloud
81 .last_model_pull
82 .as_deref()
83 .is_some_and(|d| d == today);
84
85 if config.cloud.contribute_enabled && !already_contributed {
86 let entries = collect_contribute_entries();
87 if !entries.is_empty() && crate::cloud_client::contribute(&entries).is_ok() {
88 config.cloud.last_contribute = Some(today.clone());
89 }
90 }
91
92 if crate::cloud_client::is_logged_in() {
93 if !already_synced {
94 let store = crate::core::stats::load();
95 let entries = build_sync_entries(&store);
96 if !entries.is_empty() && crate::cloud_client::sync_stats(&entries).is_ok() {
97 config.cloud.last_sync = Some(today.clone());
98 }
99 }
100
101 if !already_gain_synced {
102 let engine = crate::core::gain::GainEngine::load();
103 let summary = engine.summary(None);
104 let trend = match summary.score.trend {
105 crate::core::gain::gain_score::Trend::Rising => "rising",
106 crate::core::gain::gain_score::Trend::Stable => "stable",
107 crate::core::gain::gain_score::Trend::Declining => "declining",
108 };
109 let entry = serde_json::json!({
110 "recorded_at": format!("{today}T00:00:00Z"),
111 "total": summary.score.total as f64,
112 "compression": summary.score.compression as f64,
113 "cost_efficiency": summary.score.cost_efficiency as f64,
114 "quality": summary.score.quality as f64,
115 "consistency": summary.score.consistency as f64,
116 "trend": trend,
117 "avoided_usd": summary.avoided_usd,
118 "tool_spend_usd": summary.tool_spend_usd,
119 "model_key": summary.model.model_key,
120 });
121 if crate::cloud_client::push_gain(&[entry]).is_ok() {
122 config.cloud.last_gain_sync = Some(today.clone());
123 }
124 }
125
126 if !already_pulled && let Ok(data) = crate::cloud_client::pull_cloud_models() {
127 let _ = crate::cloud_client::save_cloud_models(&data);
128 config.cloud.last_model_pull = Some(today.clone());
129 }
130
131 if should_auto_sync(
136 config.cloud.auto_sync,
137 true,
138 config.cloud.last_auto_sync.as_deref(),
139 &today,
140 ) && auto_sync_personal_cloud() != AutoSyncOutcome::NetworkFailure
141 {
142 config.cloud.last_auto_sync = Some(today.clone());
143 }
144
145 if let Ok(root) = std::env::current_dir() {
149 let project_hash = crate::core::index_namespace::namespace_hash(&root);
150 if should_auto_push_index(
151 config.cloud.auto_index,
152 true,
153 crate::core::index_bundle::local_index_present(&root),
154 config
155 .cloud
156 .last_index_push
157 .get(&project_hash)
158 .map(String::as_str),
159 &today,
160 ) {
161 match crate::cloud_client::push_index_bundle(&root) {
162 Ok((hash, bytes)) => {
163 tracing::debug!(project = %hash, bytes, "auto-index: pushed");
164 config
165 .cloud
166 .last_index_push
167 .insert(project_hash, today.clone());
168 }
169 Err(e) if e.contains("Pro") || e.contains("Quota") => {
170 tracing::debug!(error = %e, "auto-index: gated, retry tomorrow");
171 config
172 .cloud
173 .last_index_push
174 .insert(project_hash, today.clone());
175 }
176 Err(e) => {
177 tracing::debug!(error = %e, "auto-index: push failed, slot stays open");
178 }
179 }
180 }
181 }
182 }
183
184 if let Err(e) = config.save() {
185 tracing::warn!("could not persist cloud background state: {e}");
186 }
187}
188
189fn auto_sync_personal_cloud() -> AutoSyncOutcome {
192 let store = crate::core::stats::load();
193 let mut results: Vec<Result<(), String>> = Vec::new();
194
195 let mut push = |label: &str, result: Result<String, String>| match result {
196 Ok(_) => {
197 tracing::debug!(surface = label, "auto-sync: pushed");
198 results.push(Ok(()));
199 }
200 Err(e) => {
201 tracing::debug!(surface = label, error = %e, "auto-sync: push failed");
202 results.push(Err(e));
203 }
204 };
205
206 let commands = collect_command_entries(&store);
207 if !commands.is_empty() {
208 push("commands", crate::cloud_client::push_commands(&commands));
209 }
210 let cep = collect_cep_entries(&store);
211 if !cep.is_empty() {
212 push("cep", crate::cloud_client::push_cep(&cep));
213 }
214 let knowledge = collect_knowledge_entries();
215 if !knowledge.is_empty() {
216 push("knowledge", crate::cloud_client::push_knowledge(&knowledge));
217 }
218 let gotchas = collect_gotcha_entries();
219 if !gotchas.is_empty() {
220 push("gotchas", crate::cloud_client::push_gotchas(&gotchas));
221 }
222 let buddy = crate::core::buddy::BuddyState::compute();
223 if let Ok(buddy_data) = serde_json::to_value(&buddy) {
224 push("buddy", crate::cloud_client::push_buddy(&buddy_data));
225 }
226 let feedback = collect_feedback_entries();
227 if !feedback.is_empty() {
228 push("feedback", crate::cloud_client::push_feedback(&feedback));
229 }
230
231 let outcome = classify_outcomes(&results);
232 tracing::info!(
233 ?outcome,
234 surfaces = results.len(),
235 "personal-cloud auto-sync done"
236 );
237 outcome
238}
239
240pub fn build_sync_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
241 let mut entries = Vec::new();
242 let cep = &store.cep;
243 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
244
245 let mut cep_cache_by_day: std::collections::HashMap<String, (u64, u64)> =
246 std::collections::HashMap::new();
247 for s in &cep.scores {
248 if let Some(date) = s.timestamp.get(..10) {
249 let entry = cep_cache_by_day.entry(date.to_string()).or_default();
250 let calls = s.tool_calls.max(1);
251 let hits = (calls as f64 * s.cache_hit_rate as f64 / 100.0).round() as u64;
252 entry.0 += calls;
253 entry.1 += hits;
254 }
255 }
256
257 let mut mcp_saved_total = 0u64;
258 for (cmd, s) in &store.commands {
259 if cmd.starts_with("ctx_") {
260 mcp_saved_total += s.input_tokens.saturating_sub(s.output_tokens);
261 }
262 }
263 let global_saved = store
264 .total_input_tokens
265 .saturating_sub(store.total_output_tokens)
266 .max(1);
267 let mcp_ratio = mcp_saved_total as f64 / global_saved as f64;
268
269 for day in &store.daily {
270 let tokens_original = day.input_tokens;
271 let tokens_compressed = day.output_tokens;
272 let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
273 let (day_calls, day_hits) = cep_cache_by_day.get(&day.date).copied().unwrap_or((0, 0));
274 let day_mcp_saved = (tokens_saved as f64 * mcp_ratio).round() as u64;
275 let day_hook_saved = tokens_saved.saturating_sub(day_mcp_saved);
276 entries.push(serde_json::json!({
277 "date": day.date,
278 "tokens_original": tokens_original,
279 "tokens_compressed": tokens_compressed,
280 "tokens_saved": tokens_saved,
281 "mcp_tokens_saved": day_mcp_saved,
282 "hook_tokens_saved": day_hook_saved,
283 "tool_calls": day.commands,
284 "cache_hits": day_hits,
285 "cache_misses": day_calls.saturating_sub(day_hits),
286 }));
287 }
288
289 let has_today = entries.iter().any(|e| e["date"].as_str() == Some(&today));
290 if !has_today && (cep.total_tokens_original > 0 || store.total_commands > 0) {
291 let today_saved = cep
292 .total_tokens_original
293 .saturating_sub(cep.total_tokens_compressed);
294 let today_mcp = (today_saved as f64 * mcp_ratio).round() as u64;
295 entries.push(serde_json::json!({
296 "date": today,
297 "tokens_original": cep.total_tokens_original,
298 "tokens_compressed": cep.total_tokens_compressed,
299 "tokens_saved": today_saved,
300 "mcp_tokens_saved": today_mcp,
301 "hook_tokens_saved": today_saved.saturating_sub(today_mcp),
302 "tool_calls": store.total_commands,
303 "cache_hits": cep.total_cache_hits,
304 "cache_misses": cep.total_cache_reads.saturating_sub(cep.total_cache_hits),
305 }));
306 }
307
308 entries
309}
310
311pub fn collect_knowledge_entries() -> Vec<serde_json::Value> {
316 let Ok(data_dir) = crate::core::paths::data_dir() else {
317 return Vec::new();
318 };
319 let knowledge_dir = data_dir.join("knowledge");
320 if !knowledge_dir.is_dir() {
321 return Vec::new();
322 }
323
324 let mut entries = Vec::new();
325
326 for project_entry in std::fs::read_dir(&knowledge_dir).into_iter().flatten() {
327 let Ok(project_entry) = project_entry else {
328 continue;
329 };
330 let project_path = project_entry.path();
331 if !project_path.is_dir() {
332 continue;
333 }
334
335 for file_entry in std::fs::read_dir(&project_path).into_iter().flatten() {
336 let Ok(file_entry) = file_entry else { continue };
337 let file_path = file_entry.path();
338 if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
339 continue;
340 }
341 let Ok(data) = std::fs::read_to_string(&file_path) else {
342 continue;
343 };
344 let parsed: serde_json::Value = match serde_json::from_str(&data) {
345 Ok(v) => v,
346 Err(_) => continue,
347 };
348
349 if let Some(facts) = parsed["facts"].as_array() {
350 for fact in facts {
351 let cat = fact["category"].as_str().unwrap_or("general");
352 let key = fact["key"].as_str().unwrap_or("");
353 let val = fact["value"]
354 .as_str()
355 .or_else(|| fact["description"].as_str())
356 .unwrap_or("");
357 if !key.is_empty() {
358 entries.push(serde_json::json!({
359 "category": cat,
360 "key": key,
361 "value": val,
362 }));
363 }
364 }
365 }
366
367 if let Some(gotchas) = parsed["gotchas"].as_array() {
368 for g in gotchas {
369 let pattern = g["pattern"].as_str().unwrap_or("");
370 let fix = g["fix"].as_str().unwrap_or("");
371 if !pattern.is_empty() {
372 entries.push(serde_json::json!({
373 "category": "gotcha",
374 "key": pattern,
375 "value": fix,
376 }));
377 }
378 }
379 }
380 }
381 }
382
383 entries
384}
385
386pub fn collect_command_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
387 store
388 .commands
389 .iter()
390 .map(|(name, stats)| {
391 let tokens_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
392 serde_json::json!({
393 "command": name,
394 "source": if name.starts_with("ctx_") { "mcp" } else { "hook" },
395 "count": stats.count,
396 "input_tokens": stats.input_tokens,
397 "output_tokens": stats.output_tokens,
398 "tokens_saved": tokens_saved,
399 })
400 })
401 .collect()
402}
403
404fn complexity_to_float(s: &str) -> f64 {
405 match s.to_lowercase().as_str() {
406 "trivial" => 0.1,
407 "simple" => 0.3,
408 "moderate" => 0.5,
409 "complex" => 0.7,
410 "architectural" => 0.9,
411 other => other.parse::<f64>().unwrap_or(0.5),
412 }
413}
414
415pub fn collect_cep_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
416 store
417 .cep
418 .scores
419 .iter()
420 .map(|s| {
421 serde_json::json!({
422 "recorded_at": s.timestamp,
423 "score": s.score as f64 / 100.0,
424 "cache_hit_rate": s.cache_hit_rate as f64 / 100.0,
425 "mode_diversity": s.mode_diversity as f64 / 100.0,
426 "compression_rate": s.compression_rate as f64 / 100.0,
427 "tool_calls": s.tool_calls,
428 "tokens_saved": s.tokens_saved,
429 "complexity": complexity_to_float(&s.complexity),
430 })
431 })
432 .collect()
433}
434
435pub fn collect_gotcha_entries() -> Vec<serde_json::Value> {
436 let mut all_gotchas = crate::core::gotcha_tracker::load_universal_gotchas();
437
438 if let Ok(knowledge_dir) = crate::core::paths::data_dir().map(|d| d.join("knowledge"))
439 && let Ok(entries) = std::fs::read_dir(&knowledge_dir)
440 {
441 for entry in entries.flatten() {
442 let gotcha_path = entry.path().join("gotchas.json");
443 if gotcha_path.exists()
444 && let Ok(content) = std::fs::read_to_string(&gotcha_path)
445 && let Ok(store) =
446 serde_json::from_str::<crate::core::gotcha_tracker::GotchaStore>(&content)
447 {
448 for g in store.gotchas {
449 if !all_gotchas
450 .iter()
451 .any(|existing| existing.trigger == g.trigger)
452 {
453 all_gotchas.push(g);
454 }
455 }
456 }
457 }
458 }
459
460 all_gotchas
461 .iter()
462 .map(|g| {
463 serde_json::json!({
464 "pattern": g.trigger,
465 "fix": g.resolution,
466 "severity": format!("{:?}", g.severity).to_lowercase(),
467 "category": format!("{:?}", g.category).to_lowercase(),
468 "occurrences": g.occurrences,
469 "prevented_count": g.prevented_count,
470 "confidence": g.confidence,
471 })
472 })
473 .collect()
474}
475
476pub fn collect_feedback_entries() -> Vec<serde_json::Value> {
477 let store = crate::core::feedback::FeedbackStore::load();
478 store
479 .learned_thresholds
480 .iter()
481 .map(|(lang, thresholds)| {
482 serde_json::json!({
483 "language": lang,
484 "entropy": thresholds.entropy,
485 "jaccard": thresholds.jaccard,
486 "sample_count": thresholds.sample_count,
487 "avg_efficiency": thresholds.avg_efficiency,
488 })
489 })
490 .collect()
491}
492
493pub fn collect_contribute_entries() -> Vec<serde_json::Value> {
494 let mut entries = Vec::new();
495
496 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
497 let mode_stats_path = data_dir.join("mode_stats.json");
498 if let Ok(data) = std::fs::read_to_string(&mode_stats_path)
499 && let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data)
500 && let Some(history) = predictor["history"].as_object()
501 {
502 for (_key, outcomes) in history {
503 if let Some(arr) = outcomes.as_array() {
504 for outcome in arr.iter().rev().take(3) {
505 let ext = outcome["ext"].as_str().unwrap_or("unknown");
506 let mode = outcome["mode"].as_str().unwrap_or("full");
507 let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
508 let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
509 let ratio = if t_in > 0 {
510 1.0 - t_out as f64 / t_in as f64
511 } else {
512 0.0
513 };
514 let bucket = match t_in {
515 0..=500 => "0-500",
516 501..=2000 => "500-2k",
517 2001..=10000 => "2k-10k",
518 _ => "10k+",
519 };
520 entries.push(serde_json::json!({
521 "file_ext": format!(".{ext}"),
522 "size_bucket": bucket,
523 "best_mode": mode,
524 "compression_ratio": (ratio * 100.0).round() / 100.0,
525 }));
526 if entries.len() >= 200 {
527 return entries;
528 }
529 }
530 }
531 }
532 }
533 }
534
535 if entries.is_empty() {
536 let stats_data = crate::core::stats::format_gain_json();
537 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
538 let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
539 let compressed = parsed["cep"]["total_tokens_compressed"]
540 .as_u64()
541 .unwrap_or(0);
542 let ratio = if original > 0 {
543 1.0 - compressed as f64 / original as f64
544 } else {
545 0.0
546 };
547 if let Some(modes) = parsed["cep"]["modes"].as_object() {
548 let read_modes = [
549 "full",
550 "map",
551 "signatures",
552 "auto",
553 "aggressive",
554 "entropy",
555 "diff",
556 "lines",
557 "task",
558 "reference",
559 ];
560 for (mode, count) in modes {
561 if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
562 continue;
563 }
564 entries.push(serde_json::json!({
565 "file_ext": "mixed",
566 "size_bucket": "mixed",
567 "best_mode": mode,
568 "compression_ratio": (ratio * 100.0).round() / 100.0,
569 }));
570 }
571 }
572 }
573 }
574
575 entries
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn auto_sync_requires_flag_login_and_unused_slot() {
584 assert!(!should_auto_sync(false, true, None, "2026-06-10"));
586 assert!(!should_auto_sync(true, false, None, "2026-06-10"));
588 assert!(should_auto_sync(true, true, None, "2026-06-10"));
590 assert!(!should_auto_sync(
592 true,
593 true,
594 Some("2026-06-10"),
595 "2026-06-10"
596 ));
597 assert!(should_auto_sync(
599 true,
600 true,
601 Some("2026-06-09"),
602 "2026-06-10"
603 ));
604 }
605
606 #[test]
607 fn auto_index_push_needs_flag_login_index_and_fresh_slot() {
608 let t = "2026-06-10";
609 assert!(should_auto_push_index(true, true, true, None, t));
611 assert!(!should_auto_push_index(false, true, true, None, t));
613 assert!(!should_auto_push_index(true, false, true, None, t));
615 assert!(!should_auto_push_index(true, true, false, None, t));
616 assert!(!should_auto_push_index(true, true, true, Some(t), t));
618 assert!(should_auto_push_index(
619 true,
620 true,
621 true,
622 Some("2026-06-09"),
623 t
624 ));
625 }
626
627 #[test]
628 fn outcome_classification_is_gate_then_network_then_synced() {
629 assert_eq!(classify_outcomes(&[]), AutoSyncOutcome::Synced);
631 assert_eq!(
633 classify_outcomes(&[
634 Err("HTTP 402: upgrade required".into()),
635 Err("connection refused".into()),
636 ]),
637 AutoSyncOutcome::Gated
638 );
639 assert_eq!(
641 classify_outcomes(&[Err("connection refused".into()), Err("timeout".into()),]),
642 AutoSyncOutcome::NetworkFailure
643 );
644 assert_eq!(
646 classify_outcomes(&[Ok(()), Err("timeout".into())]),
647 AutoSyncOutcome::Synced
648 );
649 }
650}