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