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