1use std::path::Path;
2
3use crate::core::cache::SessionCache;
4use crate::core::compressor;
5use crate::core::deps;
6use crate::core::entropy;
7use crate::core::plugins::{PluginManager, executor::HookPoint};
8use crate::core::protocol;
9use crate::core::signatures;
10use crate::core::symbol_map::{self, SymbolMap};
11use crate::core::tokens::count_tokens;
12use crate::tools::CrpMode;
13pub(crate) mod render;
16pub(crate) use render::*;
17#[cfg(test)]
18mod tests;
19
20pub struct ReadOutput {
23 pub content: String,
24 pub resolved_mode: String,
25 pub output_tokens: usize,
28}
29
30const COMPRESSED_HINT: &str = "[compressed — use mode=\"full\" for complete source]";
31
32const CACHEABLE_MODES: &[&str] = &["map", "signatures"];
33
34fn is_cacheable_mode(mode: &str) -> bool {
35 CACHEABLE_MODES.contains(&mode)
36}
37
38fn mode_allows_raw_cap(mode: &str) -> bool {
48 !(mode.starts_with("lines:") || matches!(mode, "reference" | "diff" | "raw"))
49}
50
51fn compressed_cache_key(
52 mode: &str,
53 crp_mode: CrpMode,
54 task: Option<&str>,
55 aggressiveness: Option<f64>,
56 protect: &[String],
57) -> String {
58 let versioned_mode = match mode {
61 "map" => "map:v2",
62 "signatures" => "signatures:v2",
63 _ => mode,
64 };
65 let base = if crp_mode.is_tdd() {
66 format!("{versioned_mode}:tdd")
67 } else {
68 versioned_mode.to_string()
69 };
70 let keyed = match task.map(str::trim).filter(|t| !t.is_empty()) {
73 Some(t) => {
74 use std::hash::{Hash, Hasher};
75 let mut h = std::collections::hash_map::DefaultHasher::new();
76 t.hash(&mut h);
77 format!("{base}:t{:x}", h.finish())
78 }
79 None => base,
80 };
81 let mut key = keyed;
85 let aggr_frag = crate::core::aggressiveness::cache_fragment(aggressiveness);
86 if !aggr_frag.is_empty() {
87 key = format!("{key}:{aggr_frag}");
88 }
89 let protect_frag = crate::core::protect::protect_fragment(protect);
90 if !protect_frag.is_empty() {
91 key = format!("{key}:{protect_frag}");
92 }
93 key
94}
95
96fn append_compressed_hint(output: &str, file_path: &str) -> String {
97 if !crate::core::profiles::active_profile()
98 .output_hints
99 .compressed_hint()
100 {
101 return output.to_string();
102 }
103 format!(
104 "{output}\n{COMPRESSED_HINT}\n ctx_read(\"{file_path}\", mode=\"full\") | ctx_retrieve(\"{file_path}\")"
105 )
106}
107
108pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
112 if crate::core::binary_detect::is_binary_file(path) {
113 let msg = crate::core::binary_detect::binary_file_message(path);
114 return Err(std::io::Error::other(msg));
115 }
116
117 {
118 let canonical =
119 crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
120 if let Ok(cwd) = std::env::current_dir() {
121 let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
122 if !canonical.starts_with(&root) {
123 let allow = crate::core::pathjail::allow_paths_from_env_and_config();
124 let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
125 .ok()
126 .is_some_and(|d| canonical.starts_with(d));
127 let tmp_ok = canonical.starts_with(std::env::temp_dir());
128 if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
129 tracing::warn!(
130 "defense-in-depth: path may escape project root: {}",
131 canonical.display()
132 );
133 }
134 }
135 }
136 }
137
138 let cap = crate::core::limits::max_read_bytes();
139
140 let file = open_with_retry(path)?;
141 let meta = file
142 .metadata()
143 .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
144 if meta.len() > cap as u64 {
145 return Err(std::io::Error::other(format!(
146 "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
147 Increase the limit or use a line-range read: mode=\"lines:1-100\"",
148 meta.len(),
149 cap
150 )));
151 }
152
153 use std::io::Read;
154 let mut bytes = Vec::with_capacity(meta.len() as usize);
155 std::io::BufReader::new(file).read_to_end(&mut bytes)?;
156 match String::from_utf8(bytes) {
157 Ok(s) => Ok(s),
158 Err(e) => Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()),
159 }
160}
161
162fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
166 match open_nofollow(path) {
167 Ok(f) => Ok(f),
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
169 std::thread::sleep(std::time::Duration::from_millis(50));
170 open_nofollow(path).map_err(|e| {
171 if e.kind() == std::io::ErrorKind::NotFound {
172 std::io::Error::other(format!(
173 "file not found: {path} — verify the path with ctx_tree or ctx_search"
174 ))
175 } else {
176 e
177 }
178 })
179 }
180 Err(e) => Err(e),
181 }
182}
183
184#[cfg(unix)]
185fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
186 use std::os::unix::fs::OpenOptionsExt;
187 use std::path::Path;
188
189 let p = Path::new(path);
190 if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
195 && parent.exists()
196 {
197 let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
198 let canonical_path = canonical_parent.join(filename);
199 return std::fs::OpenOptions::new()
200 .read(true)
201 .custom_flags(libc::O_NOFOLLOW)
202 .open(&canonical_path);
203 }
204
205 std::fs::OpenOptions::new()
207 .read(true)
208 .custom_flags(libc::O_NOFOLLOW)
209 .open(path)
210}
211
212#[cfg(not(unix))]
213fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
214 std::fs::File::open(path)
215}
216
217pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
219 handle_with_options(cache, path, mode, false, crp_mode, None)
220}
221
222pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
224 handle_with_options(cache, path, mode, true, crp_mode, None)
225}
226
227pub fn handle_with_task(
229 cache: &mut SessionCache,
230 path: &str,
231 mode: &str,
232 crp_mode: CrpMode,
233 task: Option<&str>,
234) -> String {
235 handle_with_options(cache, path, mode, false, crp_mode, task)
236}
237
238pub fn handle_with_task_resolved(
240 cache: &mut SessionCache,
241 path: &str,
242 mode: &str,
243 crp_mode: CrpMode,
244 task: Option<&str>,
245) -> ReadOutput {
246 handle_with_options_resolved(
247 cache,
248 path,
249 mode,
250 false,
251 crp_mode,
252 task,
253 ReadTuning::resolve(None, &[]),
254 )
255}
256
257pub fn handle_with_task_resolved_tuned(
261 cache: &mut SessionCache,
262 path: &str,
263 mode: &str,
264 crp_mode: CrpMode,
265 task: Option<&str>,
266 aggressiveness: Option<f64>,
267 protect: &[String],
268) -> ReadOutput {
269 handle_with_options_resolved(
270 cache,
271 path,
272 mode,
273 false,
274 crp_mode,
275 task,
276 ReadTuning::resolve(aggressiveness, protect),
277 )
278}
279
280pub fn handle_fresh_with_task(
282 cache: &mut SessionCache,
283 path: &str,
284 mode: &str,
285 crp_mode: CrpMode,
286 task: Option<&str>,
287) -> String {
288 handle_with_options(cache, path, mode, true, crp_mode, task)
289}
290
291pub fn handle_fresh_with_task_resolved(
293 cache: &mut SessionCache,
294 path: &str,
295 mode: &str,
296 crp_mode: CrpMode,
297 task: Option<&str>,
298) -> ReadOutput {
299 handle_with_options_resolved(
300 cache,
301 path,
302 mode,
303 true,
304 crp_mode,
305 task,
306 ReadTuning::resolve(None, &[]),
307 )
308}
309
310pub fn handle_fresh_with_task_resolved_tuned(
312 cache: &mut SessionCache,
313 path: &str,
314 mode: &str,
315 crp_mode: CrpMode,
316 task: Option<&str>,
317 aggressiveness: Option<f64>,
318 protect: &[String],
319) -> ReadOutput {
320 handle_with_options_resolved(
321 cache,
322 path,
323 mode,
324 true,
325 crp_mode,
326 task,
327 ReadTuning::resolve(aggressiveness, protect),
328 )
329}
330
331fn handle_with_options(
332 cache: &mut SessionCache,
333 path: &str,
334 mode: &str,
335 fresh: bool,
336 crp_mode: CrpMode,
337 task: Option<&str>,
338) -> String {
339 handle_with_options_resolved(
340 cache,
341 path,
342 mode,
343 fresh,
344 crp_mode,
345 task,
346 ReadTuning::resolve(None, &[]),
347 )
348 .content
349}
350
351fn is_subagent_context() -> bool {
354 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
355 *IS_SUBAGENT.get_or_init(|| {
356 if std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true") {
357 return true;
358 }
359 std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
360 })
361}
362
363fn handle_with_options_resolved(
364 cache: &mut SessionCache,
365 path: &str,
366 mode: &str,
367 fresh: bool,
368 crp_mode: CrpMode,
369 task: Option<&str>,
370 tuning: ReadTuning<'_>,
371) -> ReadOutput {
372 let effective_fresh = fresh || is_subagent_context();
373
374 if PluginManager::has_listener("pre_read") {
377 PluginManager::fire_hook_background(HookPoint::PreRead {
378 path: path.to_string(),
379 });
380 }
381
382 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
383 bt.next_seq();
384 }
385 let mut result =
386 handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task, tuning);
387
388 if let Some(entry) = cache.get_mut(path) {
389 entry.last_mode.clone_from(&result.resolved_mode);
390 }
391
392 let dedup_allowed = matches!(
393 result.resolved_mode.as_str(),
394 "map" | "signatures" | "aggressive" | "entropy" | "task"
395 );
396 if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
397 let new_tokens = count_tokens(&deduped);
398 if new_tokens < result.output_tokens {
399 result.content = deduped;
400 result.output_tokens = new_tokens;
401 }
402 }
403
404 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
405 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
406 bt.record_read(
407 path,
408 &result.resolved_mode,
409 result.output_tokens,
410 original_tokens,
411 );
412
413 let compressed = !matches!(result.resolved_mode.as_str(), "full" | "diff" | "lines");
418 if compressed {
419 crate::core::adaptive_thresholds::record_quality_signal(
420 path,
421 crate::core::threshold_learning::QualitySignal::CleanCompressed,
422 );
423 } else if result.resolved_mode == "full"
424 && result.output_tokens > 2000
425 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
426 {
427 crate::core::adaptive_thresholds::record_quality_signal(
428 path,
429 crate::core::threshold_learning::QualitySignal::WastedFull,
430 );
431 }
432 }
433
434 if PluginManager::has_listener("post_compress") {
436 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
437 PluginManager::fire_hook_background(HookPoint::PostCompress {
438 path: path.to_string(),
439 original_tokens,
440 compressed_tokens: result.output_tokens,
441 });
442 }
443
444 {
451 let self_agent = crate::core::scent_field::scent_agent_id();
452 let scent_path = crate::core::pathutil::normalize_tool_path(path);
453 std::thread::spawn(move || {
454 crate::core::scent_field::deposit(
455 self_agent,
456 crate::core::scent_field::ScentKind::Hot,
457 &scent_path,
458 0.3,
459 );
460 });
461 }
462
463 result
464}
465
466pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
477 let file_ref = cache.get_file_ref_readonly(path)?;
478 let (cached_mtime, cached_hash, line_count) = {
479 let entry = cache.get(path)?;
480 (entry.stored_mtime, entry.hash.clone(), entry.line_count)
481 };
482
483 let no_deg = crate::core::config::Config::load().no_degrade_effective();
484 let prof = crate::core::profiles::active_profile();
485 let force_full = no_deg
486 || (prof.read.default_mode_effective() == "full"
487 && prof.compression.crp_mode_effective() == "off");
488 let policy_allows_stub =
489 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
490 if !policy_allows_stub
491 || crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
492 || !cache.is_full_delivered(path)
493 {
494 return None;
495 }
496
497 cache.record_cache_hit(path);
498 let short = protocol::shorten_path(path);
499 let out = if crate::core::protocol::meta_visible() {
500 format!(
501 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
502 )
503 } else {
504 format!("{file_ref}={short} [unchanged {line_count}L]")
509 };
510 let out = crate::core::redaction::redact_text_if_enabled(&out);
511 let sent = count_tokens(&out);
512 Some(ReadOutput {
513 content: out,
514 resolved_mode: "full".into(),
515 output_tokens: sent,
516 })
517}
518
519#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct DeltaExplicitDecision {
523 pub mode: String,
526 pub note: Option<String>,
530}
531
532pub fn resolve_explicit_delta_mode(
554 cache: &SessionCache,
555 path: &str,
556 mode: &str,
557 explicit_mode: bool,
558 fresh: bool,
559 enabled: bool,
560) -> DeltaExplicitDecision {
561 let unchanged = DeltaExplicitDecision {
562 mode: mode.to_string(),
563 note: None,
564 };
565 if fresh || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
566 return unchanged;
567 }
568 let Some(entry) = cache.get(path) else {
569 return unchanged;
571 };
572 let stale =
573 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
574 if stale {
575 if entry.content().is_some() {
579 return DeltaExplicitDecision {
580 mode: "diff".to_string(),
581 note: Some(format!(
582 "[delta-explicit] requested mode={mode} served as a diff: the file \
583 changed since your last read and the diff is the new information. \
584 Pass fresh=true if you need the full content re-emitted."
585 )),
586 };
587 }
588 return unchanged;
589 }
590 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
594 return DeltaExplicitDecision {
595 mode: "full".to_string(),
596 note: None,
597 };
598 }
599 unchanged
600}
601
602fn handle_with_options_inner(
603 cache: &mut SessionCache,
604 path: &str,
605 mode: &str,
606 fresh: bool,
607 crp_mode: CrpMode,
608 task: Option<&str>,
609 tuning: ReadTuning<'_>,
610) -> ReadOutput {
611 let file_ref = cache.get_file_ref(path);
612 let short = protocol::shorten_path(path);
613 let ext = Path::new(path)
614 .extension()
615 .and_then(|e| e.to_str())
616 .unwrap_or("");
617
618 if fresh {
619 if mode == "diff" {
620 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
621 return ReadOutput {
622 content: warning.to_string(),
623 resolved_mode: "diff".into(),
624 output_tokens: count_tokens(warning),
625 };
626 }
627 cache.invalidate(path);
628 }
629
630 if mode == "diff" {
631 let (out, _) = handle_diff(cache, path, &file_ref);
632 let out = crate::core::redaction::redact_text_if_enabled(&out);
633 let sent = count_tokens(&out);
634 return ReadOutput {
635 content: out,
636 resolved_mode: "diff".into(),
637 output_tokens: sent,
638 };
639 }
640
641 if mode != "full"
642 && let Some(existing) = cache.get(path)
643 {
644 let stale = crate::core::cache::is_cache_entry_stale_verified(
645 path,
646 existing.stored_mtime,
647 &existing.hash,
648 );
649 if stale {
650 cache.invalidate(path);
651 }
652 }
653
654 let cache_snapshot = cache
657 .get(path)
658 .map(|existing| (existing.original_tokens, existing.content()));
659
660 if let Some((original_tokens, content_opt)) = cache_snapshot {
661 if mode == "full" {
662 if let Some(out) = try_stub_hit_readonly(cache, path) {
665 return out;
666 }
667 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
668 let out = crate::core::redaction::redact_text_if_enabled(&out);
669 let sent = count_tokens(&out);
670 return ReadOutput {
671 content: out,
672 resolved_mode: "full".into(),
673 output_tokens: sent,
674 };
675 }
676
677 let resolved_mode = if mode == "auto" {
682 tuning
683 .auto_density_mode()
684 .unwrap_or_else(|| resolve_auto_mode(path, original_tokens, task))
685 } else {
686 mode.to_string()
687 };
688
689 if is_cacheable_mode(&resolved_mode) {
690 let cache_key = compressed_cache_key(
691 &resolved_mode,
692 crp_mode,
693 task,
694 tuning.aggressiveness,
695 tuning.protect,
696 );
697 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
698 if let Some(cached_output) = compressed_hit {
699 cache.record_cache_hit(path);
700 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
701 let sent = count_tokens(&out);
702 return ReadOutput {
703 content: out,
704 resolved_mode,
705 output_tokens: sent,
706 };
707 }
708 }
709
710 if let Some(content) = content_opt {
711 let (out, _) = process_mode_tuned(
712 &content,
713 &resolved_mode,
714 &file_ref,
715 &short,
716 ext,
717 original_tokens,
718 crp_mode,
719 path,
720 task,
721 tuning,
722 );
723 let out = if mode_allows_raw_cap(&resolved_mode) {
729 let framed_tokens = count_tokens(&out);
730 cap_to_raw(out, framed_tokens, &content, original_tokens)
731 } else {
732 out
733 };
734 if is_cacheable_mode(&resolved_mode) {
735 let cache_key = compressed_cache_key(
736 &resolved_mode,
737 crp_mode,
738 task,
739 tuning.aggressiveness,
740 tuning.protect,
741 );
742 cache.set_compressed(path, &cache_key, out.clone());
743 }
744 let out = crate::core::redaction::redact_text_if_enabled(&out);
745 let sent = count_tokens(&out);
746 return ReadOutput {
747 content: out,
748 resolved_mode,
749 output_tokens: sent,
750 };
751 }
752 cache.invalidate(path);
753 }
754
755 let content = match read_file_lossy(path) {
756 Ok(c) => c,
757 Err(e) => {
758 let msg = format!("ERROR: {e}");
759 let tokens = count_tokens(&msg);
760 return ReadOutput {
761 content: msg,
762 resolved_mode: "error".into(),
763 output_tokens: tokens,
764 };
765 }
766 };
767
768 let store_result = cache.store(path, &content);
769
770 let is_line_range = mode.starts_with("lines:");
773 let hints = crate::core::profiles::active_profile().output_hints;
774 let is_repeat_read = store_result.read_count > 1;
775 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
776 find_similar_and_update_semantic_index(path, &content)
777 } else {
778 None
779 };
780 let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
781 build_graph_related_hint(path)
782 } else {
783 None
784 };
785
786 if mode == "full" {
787 cache.mark_full_delivered(path);
788 let (mut output, _) = format_full_output(
789 &file_ref,
790 &short,
791 ext,
792 &content,
793 store_result.original_tokens,
794 store_result.line_count,
795 task,
796 );
797 if let Some(hint) = &graph_hint {
798 output.push_str(&format!("\n{hint}"));
799 }
800 if let Some(hint) = similar_hint {
801 output.push_str(&format!("\n{hint}"));
802 }
803 let framed_tokens = count_tokens(&output);
804 let output = cap_to_raw(
805 output,
806 framed_tokens,
807 &content,
808 store_result.original_tokens,
809 );
810 let output = crate::core::redaction::redact_text_if_enabled(&output);
811 let sent = count_tokens(&output);
812 return ReadOutput {
813 content: output,
814 resolved_mode: "full".into(),
815 output_tokens: sent,
816 };
817 }
818
819 let resolved_mode = if mode == "auto" {
820 tuning
821 .auto_density_mode()
822 .unwrap_or_else(|| resolve_auto_mode(path, store_result.original_tokens, task))
823 } else {
824 mode.to_string()
825 };
826
827 let (output, _sent) = process_mode_tuned(
828 &content,
829 &resolved_mode,
830 &file_ref,
831 &short,
832 ext,
833 store_result.original_tokens,
834 crp_mode,
835 path,
836 task,
837 tuning,
838 );
839 let mut output = if mode_allows_raw_cap(&resolved_mode) {
845 let framed_tokens = count_tokens(&output);
846 cap_to_raw(
847 output,
848 framed_tokens,
849 &content,
850 store_result.original_tokens,
851 )
852 } else {
853 output
854 };
855 if is_cacheable_mode(&resolved_mode) {
856 let cache_key = compressed_cache_key(
857 &resolved_mode,
858 crp_mode,
859 task,
860 tuning.aggressiveness,
861 tuning.protect,
862 );
863 cache.set_compressed(path, &cache_key, output.clone());
864 }
865 if let Some(hint) = &graph_hint {
866 output.push_str(&format!("\n{hint}"));
867 }
868 if let Some(hint) = similar_hint {
869 output.push_str(&format!("\n{hint}"));
870 }
871 let output = crate::core::redaction::redact_text_if_enabled(&output);
872 let final_tokens = count_tokens(&output);
873 ReadOutput {
874 content: output,
875 resolved_mode,
876 output_tokens: final_tokens,
877 }
878}
879
880pub fn is_instruction_file(path: &str) -> bool {
881 let lower = path.to_lowercase();
882 let filename = std::path::Path::new(&lower)
883 .file_name()
884 .and_then(|f| f.to_str())
885 .unwrap_or("");
886
887 matches!(
888 filename,
889 "skill.md"
890 | "agents.md"
891 | "rules.md"
892 | ".cursorrules"
893 | ".clinerules"
894 | "lean-ctx.md"
895 | "lean-ctx.mdc"
896 ) || lower.contains("/skills/")
897 || lower.contains("/.cursor/rules/")
898 || lower.contains("/.claude/rules/")
899 || lower.contains("/agents.md")
900}
901
902fn cap_to_raw(
917 framed: String,
918 framed_tokens: usize,
919 raw_content: &str,
920 raw_tokens: usize,
921) -> String {
922 if raw_tokens > 0 && framed_tokens > raw_tokens {
923 raw_content.to_string()
924 } else {
925 framed
926 }
927}
928
929fn resolve_auto_mode(file_path: &str, original_tokens: usize, task: Option<&str>) -> String {
931 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
932 path: file_path,
933 token_count: original_tokens,
934 task,
935 cache: None,
936 };
937 crate::core::auto_mode_resolver::resolve(&ctx).mode
938}
939
940fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
941 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
942
943 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
944 return None;
945 }
946
947 let cfg = crate::core::config::Config::load();
948 let profile = crate::core::config::MemoryProfile::effective(&cfg);
949 if !profile.semantic_cache_enabled() {
950 return None;
951 }
952
953 let project_root = detect_project_root(path);
954 let session_id = format!("{}", std::process::id());
955 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
956
957 let similar = index.find_similar(content, 0.7);
958 let relevant: Vec<_> = similar
959 .into_iter()
960 .filter(|(p, _)| p != path)
961 .take(3)
962 .collect();
963
964 index.add_file(path, content, &session_id);
965 if let Err(e) = index.save(&project_root) {
966 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
967 }
968
969 if relevant.is_empty() {
970 return None;
971 }
972
973 let hints: Vec<String> = relevant
974 .iter()
975 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
976 .collect();
977
978 Some(format!(
979 "[semantic: {} similar file(s) in cache]\n{}",
980 relevant.len(),
981 hints.join("\n")
982 ))
983}
984
985fn detect_project_root(path: &str) -> String {
986 crate::core::protocol::detect_project_root_or_cwd(path)
987}
988
989fn build_graph_related_hint(path: &str) -> Option<String> {
990 let project_root = detect_project_root(path);
991 crate::core::graph_context::build_related_hint(path, &project_root, 5)
992}
993
994const AUTO_DELTA_THRESHOLD: f64 = 0.6;
995
996fn handle_full_with_auto_delta(
998 cache: &mut SessionCache,
999 path: &str,
1000 file_ref: &str,
1001 short: &str,
1002 ext: &str,
1003 task: Option<&str>,
1004) -> (String, usize) {
1005 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1006 let Ok(disk_content) = read_file_lossy(path) else {
1007 cache.record_cache_hit(path);
1008 if let Some(existing) = cache.get(path) {
1009 if !crate::core::protocol::meta_visible()
1010 && let Some(cached) = existing.content()
1011 {
1012 return format_full_output(
1013 file_ref,
1014 short,
1015 ext,
1016 &cached,
1017 existing.original_tokens,
1018 existing.line_count,
1019 task,
1020 );
1021 }
1022 let out = format!(
1023 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1024 existing.read_count(),
1025 existing.line_count
1026 );
1027 let sent = count_tokens(&out);
1028 return (out, sent);
1029 }
1030 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1031 format!("[file read failed and no cached version available] {file_ref}={short}")
1032 } else {
1033 format!("[file read failed and no cached version available] {short}")
1034 };
1035 let sent = count_tokens(&out);
1036 return (out, sent);
1037 };
1038
1039 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1040 let prof = crate::core::profiles::active_profile();
1041 let force_full = no_deg
1042 || (prof.read.default_mode_effective() == "full"
1043 && prof.compression.crp_mode_effective() == "off");
1044
1045 let old_content = cache
1046 .get(path)
1047 .and_then(crate::core::cache::CacheEntry::content)
1048 .unwrap_or_default();
1049 let store_result = cache.store(path, &disk_content);
1050
1051 if store_result.was_hit {
1052 let policy_allows_stub =
1053 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1054 if policy_allows_stub && store_result.full_content_delivered {
1055 let out = if crate::core::protocol::meta_visible() {
1056 format!(
1057 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1058 store_result.line_count
1059 )
1060 } else {
1061 format!(
1064 "{file_ref}={short} [unchanged {}L]",
1065 store_result.line_count
1066 )
1067 };
1068 let sent = count_tokens(&out);
1069 return (out, sent);
1070 }
1071 cache.mark_full_delivered(path);
1072 return format_full_output(
1073 file_ref,
1074 short,
1075 ext,
1076 &disk_content,
1077 store_result.original_tokens,
1078 store_result.line_count,
1079 task,
1080 );
1081 }
1082
1083 let diff = compressor::diff_content(&old_content, &disk_content);
1084 let diff_tokens = count_tokens(&diff);
1085 let full_tokens = store_result.original_tokens;
1086
1087 if !force_full
1088 && full_tokens > 0
1089 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1090 {
1091 let savings = protocol::format_savings(full_tokens, diff_tokens);
1092 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1093 format!("{file_ref}={short}")
1094 } else {
1095 short.to_string()
1096 };
1097 let out = format!(
1098 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1099 disk_content.lines().count()
1100 );
1101 return (out, diff_tokens);
1102 }
1103
1104 format_full_output(
1105 file_ref,
1106 short,
1107 ext,
1108 &disk_content,
1109 store_result.original_tokens,
1110 store_result.line_count,
1111 task,
1112 )
1113}
1114
1115fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1116 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1117 let short = protocol::shorten_path(path);
1118 let old_content = cache
1119 .get(path)
1120 .and_then(crate::core::cache::CacheEntry::content);
1121
1122 let new_content = match read_file_lossy(path) {
1123 Ok(c) => c,
1124 Err(e) => {
1125 let msg = format!("ERROR: {e}");
1126 let tokens = count_tokens(&msg);
1127 return (msg, tokens);
1128 }
1129 };
1130
1131 let original_tokens = count_tokens(&new_content);
1132
1133 let diff_output = if let Some(old) = &old_content {
1134 compressor::diff_content(old, &new_content)
1135 } else {
1136 cache.store(path, &new_content);
1139 let msg = format!(
1140 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1141 );
1142 let sent = count_tokens(&msg);
1143 return (msg, sent);
1144 };
1145
1146 cache.store(path, &new_content);
1147
1148 let sent = count_tokens(&diff_output);
1149 let savings = protocol::format_savings(original_tokens, sent);
1150 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1151 format!("{file_ref}={short}")
1152 } else {
1153 short.clone()
1154 };
1155 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1156}