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