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::*;
17pub(crate) mod mode;
20pub(crate) use mode::ReadMode;
21#[cfg(test)]
22mod tests;
23#[cfg(test)]
24mod tests_delta;
25#[cfg(test)]
26mod tests_windowed;
27
28pub struct ReadOutput {
31 pub content: String,
32 pub resolved_mode: String,
33 pub output_tokens: usize,
36}
37
38pub(crate) fn is_cacheable_mode(mode: &str) -> bool {
42 mode.parse::<ReadMode>()
43 .is_ok_and(|m| m.is_compressed_cacheable())
44}
45
46pub(crate) fn mode_allows_raw_cap(mode: &str) -> bool {
56 mode.parse::<ReadMode>()
59 .map_or(true, |m| m.allows_raw_cap())
60}
61
62pub(crate) fn compressed_cache_key(
63 mode: &str,
64 crp_mode: CrpMode,
65 task: Option<&str>,
66 aggressiveness: Option<f64>,
67 protect: &[String],
68) -> String {
69 let versioned_mode = match mode {
72 "map" => "map:v2",
73 "signatures" => "signatures:v2",
74 _ => mode,
75 };
76 let base = if crp_mode.is_tdd() {
77 format!("{versioned_mode}:tdd")
78 } else {
79 versioned_mode.to_string()
80 };
81 let keyed = match task.map(str::trim).filter(|t| !t.is_empty()) {
84 Some(t) => {
85 use std::hash::{Hash, Hasher};
86 let mut h = std::collections::hash_map::DefaultHasher::new();
87 t.hash(&mut h);
88 format!("{base}:t{:x}", h.finish())
89 }
90 None => base,
91 };
92 let mut key = keyed;
96 let aggr_frag = crate::core::aggressiveness::cache_fragment(aggressiveness);
97 if !aggr_frag.is_empty() {
98 key = format!("{key}:{aggr_frag}");
99 }
100 let protect_frag = crate::core::protect::protect_fragment(protect);
101 if !protect_frag.is_empty() {
102 key = format!("{key}:{protect_frag}");
103 }
104 key
105}
106
107fn append_compressed_hint(output: &str, file_path: &str) -> String {
113 match crate::core::recovery::read_footer(file_path) {
114 Some(footer) => format!("{output}\n{footer}"),
115 None => output.to_string(),
116 }
117}
118
119pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
123 if crate::core::binary_detect::is_binary_file(path) {
124 let msg = crate::core::binary_detect::binary_file_message(path);
125 return Err(std::io::Error::other(msg));
126 }
127
128 {
129 let canonical =
130 crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
131 if let Ok(cwd) = std::env::current_dir() {
132 let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
133 if !canonical.starts_with(&root) {
134 let allow = crate::core::pathjail::allow_paths_from_env_and_config();
135 let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
136 .is_ok_and(|d| canonical.starts_with(d));
137 let tmp_ok = canonical.starts_with(std::env::temp_dir());
138 if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
139 tracing::warn!(
140 "defense-in-depth: path may escape project root: {}",
141 canonical.display()
142 );
143 }
144 }
145 }
146 }
147
148 let cap = crate::core::limits::max_read_bytes();
149
150 let file = open_with_retry(path)?;
151 let meta = file
152 .metadata()
153 .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
154 if meta.len() > cap as u64 {
155 return Err(std::io::Error::other(format!(
156 "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
157 Increase the limit or use a line-range read: mode=\"lines:1-100\"",
158 meta.len(),
159 cap
160 )));
161 }
162
163 use std::io::Read;
164 let mut bytes = Vec::with_capacity(meta.len() as usize);
165 std::io::BufReader::new(file).read_to_end(&mut bytes)?;
166 let s = match String::from_utf8(bytes) {
167 Ok(s) => s,
168 Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
169 };
170 Ok(crate::core::io_boundary::strip_utf8_bom(s))
171}
172
173struct LineWindow {
177 body: String,
179 total_lines: usize,
181 start: usize,
183 end: usize,
184}
185
186fn parse_disk_anchor_range(payload: &str) -> Option<(usize, usize)> {
193 let (s, e) = payload.split_once('-')?;
194 let start = s.trim().parse::<usize>().ok()?.max(1);
195 let end = e.trim().parse::<usize>().ok()?;
196 Some((start, end))
197}
198
199fn read_line_window(path: &str, start: usize, end: usize) -> Option<LineWindow> {
209 if crate::core::binary_detect::is_binary_file(path) {
210 return None;
211 }
212 use std::io::BufRead;
213 let file = open_with_retry(path).ok()?;
214 let reader = std::io::BufReader::new(file);
215 let mut total = 0usize;
216 let mut collected = Vec::new();
217 for line in reader.lines() {
218 let line = line.ok()?;
219 total += 1;
220 if total >= start && total <= end {
221 collected.push(line);
222 }
223 }
224 Some(LineWindow {
225 body: collected.join("\n"),
226 total_lines: total,
227 start: start.min(total.max(1)),
228 end: end.min(total),
229 })
230}
231
232fn try_disk_anchored_window(
238 path: &str,
239 mode: &str,
240 fresh: bool,
241 preread_is_none: bool,
242 file_ref: &str,
243 short: &str,
244) -> Option<ReadOutput> {
245 if !fresh || !preread_is_none {
246 return None;
247 }
248 let range = mode.strip_prefix("anchored:")?;
249 let (start, end) = parse_disk_anchor_range(range)?;
250 let window = read_line_window(path, start, end)?;
251 let (out, _) = format_anchored_output_window(
252 file_ref,
253 short,
254 &window.body,
255 window.total_lines,
256 Some((window.start, window.end)),
257 );
258 let out = crate::core::redaction::redact_text_if_enabled(&out);
259 let sent = count_tokens(&out);
260 Some(ReadOutput {
261 content: out,
262 resolved_mode: mode.to_string(),
263 output_tokens: sent,
264 })
265}
266
267fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
271 match open_nofollow(path) {
272 Ok(f) => Ok(f),
273 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
274 std::thread::sleep(std::time::Duration::from_millis(50));
275 open_nofollow(path).map_err(|e| {
276 if e.kind() == std::io::ErrorKind::NotFound {
277 std::io::Error::other(format!(
278 "file not found: {path} — verify the path with ctx_tree or ctx_search"
279 ))
280 } else {
281 e
282 }
283 })
284 }
285 Err(e) => Err(e),
286 }
287}
288
289#[cfg(unix)]
290fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
291 use std::os::unix::fs::OpenOptionsExt;
292 use std::path::Path;
293
294 let p = Path::new(path);
295 if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
300 && parent.exists()
301 {
302 let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
303 let canonical_path = canonical_parent.join(filename);
304 return std::fs::OpenOptions::new()
305 .read(true)
306 .custom_flags(libc::O_NOFOLLOW)
307 .open(&canonical_path);
308 }
309
310 std::fs::OpenOptions::new()
312 .read(true)
313 .custom_flags(libc::O_NOFOLLOW)
314 .open(path)
315}
316
317#[cfg(not(unix))]
318fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
319 std::fs::File::open(path)
320}
321
322pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
324 handle_with_options(cache, path, mode, false, crp_mode, None)
325}
326
327pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
329 handle_with_options(cache, path, mode, true, crp_mode, None)
330}
331
332pub fn handle_with_task(
334 cache: &mut SessionCache,
335 path: &str,
336 mode: &str,
337 crp_mode: CrpMode,
338 task: Option<&str>,
339) -> String {
340 handle_with_options(cache, path, mode, false, crp_mode, task)
341}
342
343pub fn handle_with_task_resolved(
345 cache: &mut SessionCache,
346 path: &str,
347 mode: &str,
348 crp_mode: CrpMode,
349 task: Option<&str>,
350) -> ReadOutput {
351 handle_with_options_resolved(
352 cache,
353 path,
354 mode,
355 false,
356 crp_mode,
357 task,
358 ReadTuning::resolve(None, &[]),
359 )
360}
361
362pub fn handle_with_task_resolved_tuned(
366 cache: &mut SessionCache,
367 path: &str,
368 mode: &str,
369 crp_mode: CrpMode,
370 task: Option<&str>,
371 aggressiveness: Option<f64>,
372 protect: &[String],
373) -> ReadOutput {
374 handle_with_options_resolved(
375 cache,
376 path,
377 mode,
378 false,
379 crp_mode,
380 task,
381 ReadTuning::resolve(aggressiveness, protect),
382 )
383}
384
385#[allow(clippy::too_many_arguments)]
388pub fn handle_with_preread(
389 cache: &mut SessionCache,
390 path: &str,
391 mode: &str,
392 fresh: bool,
393 crp_mode: CrpMode,
394 task: Option<&str>,
395 aggressiveness: Option<f64>,
396 protect: &[String],
397 preread: String,
398) -> ReadOutput {
399 handle_with_options_resolved_preread(
400 cache,
401 path,
402 mode,
403 fresh,
404 crp_mode,
405 task,
406 ReadTuning::resolve(aggressiveness, protect),
407 Some(preread),
408 )
409}
410
411pub fn handle_fresh_with_task(
413 cache: &mut SessionCache,
414 path: &str,
415 mode: &str,
416 crp_mode: CrpMode,
417 task: Option<&str>,
418) -> String {
419 handle_with_options(cache, path, mode, true, crp_mode, task)
420}
421
422pub fn handle_fresh_with_task_resolved(
424 cache: &mut SessionCache,
425 path: &str,
426 mode: &str,
427 crp_mode: CrpMode,
428 task: Option<&str>,
429) -> ReadOutput {
430 handle_with_options_resolved(
431 cache,
432 path,
433 mode,
434 true,
435 crp_mode,
436 task,
437 ReadTuning::resolve(None, &[]),
438 )
439}
440
441pub fn handle_fresh_with_task_resolved_tuned(
443 cache: &mut SessionCache,
444 path: &str,
445 mode: &str,
446 crp_mode: CrpMode,
447 task: Option<&str>,
448 aggressiveness: Option<f64>,
449 protect: &[String],
450) -> ReadOutput {
451 handle_with_options_resolved(
452 cache,
453 path,
454 mode,
455 true,
456 crp_mode,
457 task,
458 ReadTuning::resolve(aggressiveness, protect),
459 )
460}
461
462fn handle_with_options(
463 cache: &mut SessionCache,
464 path: &str,
465 mode: &str,
466 fresh: bool,
467 crp_mode: CrpMode,
468 task: Option<&str>,
469) -> String {
470 handle_with_options_resolved(
471 cache,
472 path,
473 mode,
474 fresh,
475 crp_mode,
476 task,
477 ReadTuning::resolve(None, &[]),
478 )
479 .content
480}
481
482pub(crate) fn force_fresh_env() -> bool {
485 static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
486 *FORCE_FRESH.get_or_init(|| {
487 std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
488 })
489}
490
491pub(crate) fn is_subagent_context() -> bool {
501 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
502 *IS_SUBAGENT.get_or_init(|| std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty()))
503}
504
505fn handle_with_options_resolved(
506 cache: &mut SessionCache,
507 path: &str,
508 mode: &str,
509 fresh: bool,
510 crp_mode: CrpMode,
511 task: Option<&str>,
512 tuning: ReadTuning<'_>,
513) -> ReadOutput {
514 handle_with_options_resolved_preread(cache, path, mode, fresh, crp_mode, task, tuning, None)
515}
516
517fn handle_with_options_resolved_preread(
518 cache: &mut SessionCache,
519 path: &str,
520 mode: &str,
521 fresh: bool,
522 crp_mode: CrpMode,
523 task: Option<&str>,
524 tuning: ReadTuning<'_>,
525 preread: Option<String>,
526) -> ReadOutput {
527 let effective_fresh = fresh
528 || force_fresh_env()
529 || (is_subagent_context() && !crate::core::conversation::scope_enabled());
530
531 if PluginManager::has_listener("pre_read") {
532 PluginManager::fire_hook_background(HookPoint::PreRead {
533 path: path.to_string(),
534 });
535 }
536
537 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
538 bt.next_seq();
539 }
540 let mut result = handle_with_options_inner(
541 cache,
542 path,
543 mode,
544 effective_fresh,
545 crp_mode,
546 task,
547 tuning,
548 preread,
549 );
550
551 if let Some(entry) = cache.get_mut(path) {
552 entry.last_mode.clone_from(&result.resolved_mode);
553 if !matches!(result.resolved_mode.as_str(), "full" | "full-compact") {
559 entry.full_content_delivered = false;
560 }
561 }
562
563 let dedup_allowed = result
565 .resolved_mode
566 .parse::<ReadMode>()
567 .is_ok_and(|m| m.is_lossy_summary());
568 if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
569 let new_tokens = count_tokens(&deduped);
570 if new_tokens < result.output_tokens {
571 result.content = deduped;
572 result.output_tokens = new_tokens;
573 }
574 }
575
576 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
577 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
578 bt.record_read(
579 path,
580 &result.resolved_mode,
581 result.output_tokens,
582 original_tokens,
583 );
584
585 let compressed = result
595 .resolved_mode
596 .parse::<ReadMode>()
597 .map_or(true, |m| m.counts_as_compressed());
598 if compressed {
599 crate::core::adaptive_thresholds::record_quality_signal(
600 path,
601 crate::core::threshold_learning::QualitySignal::CleanCompressed,
602 );
603 } else if result.resolved_mode == "full"
604 && result.output_tokens > 2000
605 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
606 {
607 crate::core::adaptive_thresholds::record_quality_signal(
608 path,
609 crate::core::threshold_learning::QualitySignal::WastedFull,
610 );
611 }
612 }
613
614 if PluginManager::has_listener("post_compress") {
616 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
617 PluginManager::fire_hook_background(HookPoint::PostCompress {
618 path: path.to_string(),
619 original_tokens,
620 compressed_tokens: result.output_tokens,
621 });
622 }
623
624 {
631 let self_agent = crate::core::scent_field::scent_agent_id();
632 let scent_path = crate::core::pathutil::normalize_tool_path(path);
633 std::thread::spawn(move || {
634 crate::core::scent_field::deposit(
635 self_agent,
636 crate::core::scent_field::ScentKind::Hot,
637 &scent_path,
638 0.3,
639 );
640 });
641 }
642
643 result
644}
645
646pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
657 let current_conversation = crate::core::conversation::current_conversation_id_fresh();
661 try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
662}
663
664pub(crate) fn try_stub_hit_readonly_scoped(
668 cache: &SessionCache,
669 path: &str,
670 current_conversation: Option<&str>,
671) -> Option<ReadOutput> {
672 let no_deg = crate::core::config::Config::load().no_degrade_effective();
673 let prof = crate::core::profiles::active_profile();
674 let force_full = no_deg
675 || (prof.read.default_mode_effective() == "full"
676 && prof.compression.crp_mode_effective() == "off");
677 let policy_allows_stub =
678 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
679 if !policy_allows_stub {
680 return None;
681 }
682
683 if let Some(file_ref) = cache.get_file_ref_readonly(path) {
685 let (cached_mtime, cached_hash, line_count, delivered_conv) = {
686 let entry = cache.get(path)?;
687 (
688 entry.stored_mtime,
689 entry.hash.clone(),
690 entry.line_count,
691 entry.delivered_conversation.clone(),
692 )
693 };
694 if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
695 || !cache.is_full_delivered(path)
696 {
697 return None;
698 }
699 if !crate::core::conversation::conversation_allows_stub(
705 current_conversation,
706 delivered_conv.as_deref(),
707 ) {
708 crate::core::cache_telemetry::record_conversation_mismatch();
709 return None;
710 }
711 cache.record_cache_hit(path);
712 return Some(render_unchanged_stub(&file_ref, path, line_count));
713 }
714
715 let rec = crate::core::read_stub_index::lookup(path)?;
721 if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
722 return None;
723 }
724 if !crate::core::conversation::conversation_allows_cold_stub(
725 current_conversation,
726 rec.delivered_conversation.as_deref(),
727 ) {
728 crate::core::cache_telemetry::record_conversation_mismatch();
729 return None;
730 }
731 Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
732}
733
734fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
742 let short = protocol::shorten_path(path);
743 let out = if crate::core::protocol::meta_visible() {
744 format!(
745 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
746 )
747 } else {
748 format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
749 };
750 let out = crate::core::redaction::redact_text_if_enabled(&out);
751 let sent = count_tokens(&out);
752 ReadOutput {
753 content: out,
754 resolved_mode: "full".into(),
755 output_tokens: sent,
756 }
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
762pub struct DeltaExplicitDecision {
763 pub mode: String,
766 pub note: Option<String>,
770}
771
772pub fn resolve_explicit_delta_mode(
794 cache: &SessionCache,
795 path: &str,
796 mode: &str,
797 explicit_mode: bool,
798 fresh: bool,
799 enabled: bool,
800) -> DeltaExplicitDecision {
801 let unchanged = DeltaExplicitDecision {
802 mode: mode.to_string(),
803 note: None,
804 };
805 if fresh
806 || !enabled
807 || !explicit_mode
808 || !(mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
809 {
810 return unchanged;
811 }
812 let Some(entry) = cache.get(path) else {
813 return unchanged;
815 };
816 let stale =
817 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
818 if stale {
819 if entry.content().is_some() {
823 return DeltaExplicitDecision {
824 mode: "diff".to_string(),
825 note: Some(format!(
826 "[delta-explicit] requested mode={mode} served as a diff: the file \
827 changed since your last read and the diff is the new information. \
828 Pass fresh=true if you need the full content re-emitted."
829 )),
830 };
831 }
832 return unchanged;
833 }
834 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
838 return DeltaExplicitDecision {
839 mode: "full".to_string(),
840 note: None,
841 };
842 }
843 unchanged
844}
845
846fn handle_with_options_inner(
847 cache: &mut SessionCache,
848 path: &str,
849 mode: &str,
850 fresh: bool,
851 crp_mode: CrpMode,
852 task: Option<&str>,
853 tuning: ReadTuning<'_>,
854 preread: Option<String>,
855) -> ReadOutput {
856 let file_ref = cache.get_file_ref(path);
857 let short = protocol::shorten_path(path);
858 let ext = Path::new(path)
859 .extension()
860 .and_then(|e| e.to_str())
861 .unwrap_or("");
862
863 let mode = if mode != "raw"
871 && !mode.starts_with("lines:")
872 && crate::core::config::Config::load()
873 .proxy
874 .is_path_compress_protected(path)
875 {
876 "full"
877 } else {
878 mode
879 };
880
881 if fresh {
882 if mode == "diff" {
883 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
884 return ReadOutput {
885 content: warning.to_string(),
886 resolved_mode: "diff".into(),
887 output_tokens: count_tokens(warning),
888 };
889 }
890 cache.invalidate(path);
891 }
892
893 if let Some(out) =
897 try_disk_anchored_window(path, mode, fresh, preread.is_none(), &file_ref, &short)
898 {
899 return out;
900 }
901
902 if mode == "diff" {
903 let (out, _) = handle_diff(cache, path, &file_ref);
904 let out = crate::core::redaction::redact_text_if_enabled(&out);
905 let sent = count_tokens(&out);
906 return ReadOutput {
907 content: out,
908 resolved_mode: "diff".into(),
909 output_tokens: sent,
910 };
911 }
912
913 if mode != "full"
914 && let Some(existing) = cache.get(path)
915 {
916 let stale = crate::core::cache::is_cache_entry_stale_verified(
917 path,
918 existing.stored_mtime,
919 &existing.hash,
920 );
921 if stale {
922 cache.invalidate(path);
923 }
924 }
925
926 let cache_snapshot = cache
929 .get(path)
930 .map(|existing| (existing.original_tokens, existing.content()));
931
932 if let Some((original_tokens, content_opt)) = cache_snapshot {
933 let resolved_mode = if mode == "auto" {
944 tuning
945 .auto_density_mode()
946 .unwrap_or_else(|| resolve_auto_mode(Some(cache), path, original_tokens, task))
947 } else {
948 mode.to_string()
949 };
950
951 if resolved_mode == "full" || resolved_mode == "full-compact" {
952 if let Some(out) = try_stub_hit_readonly(cache, path) {
953 return out;
954 }
955 if resolved_mode == "full-compact" {
956 let content = match read_file_lossy(path) {
957 Ok(c) => c,
958 Err(e) => {
959 let msg = format!("ERROR: {e}");
960 return ReadOutput {
961 content: msg,
962 resolved_mode: "error".into(),
963 output_tokens: 0,
964 };
965 }
966 };
967 let (out, _) = format_full_compact_output(&content);
968 let out = crate::core::redaction::redact_text_if_enabled(&out);
969 let sent = count_tokens(&out);
970 return ReadOutput {
971 content: out,
972 resolved_mode: "full-compact".into(),
973 output_tokens: sent,
974 };
975 }
976 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
977 let out = crate::core::redaction::redact_text_if_enabled(&out);
978 let sent = count_tokens(&out);
979 return ReadOutput {
980 content: out,
981 resolved_mode: "full".into(),
982 output_tokens: sent,
983 };
984 }
985
986 if is_cacheable_mode(&resolved_mode) {
987 let cache_key = compressed_cache_key(
988 &resolved_mode,
989 crp_mode,
990 task,
991 tuning.aggressiveness,
992 tuning.protect,
993 );
994 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
995 if let Some(cached_output) = compressed_hit {
996 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
998 let sent = count_tokens(&out);
999 return ReadOutput {
1000 content: out,
1001 resolved_mode,
1002 output_tokens: sent,
1003 };
1004 }
1005 }
1006
1007 if let Some(content) = content_opt {
1008 let (out, _) = process_mode_tuned(
1009 &content,
1010 &resolved_mode,
1011 &file_ref,
1012 &short,
1013 ext,
1014 original_tokens,
1015 crp_mode,
1016 path,
1017 task,
1018 tuning,
1019 );
1020 let out = if mode_allows_raw_cap(&resolved_mode) {
1026 let framed_tokens = count_tokens(&out);
1027 cap_to_raw(out, framed_tokens, &content, original_tokens)
1028 } else {
1029 out
1030 };
1031 if is_cacheable_mode(&resolved_mode) {
1032 let cache_key = compressed_cache_key(
1033 &resolved_mode,
1034 crp_mode,
1035 task,
1036 tuning.aggressiveness,
1037 tuning.protect,
1038 );
1039 cache.set_compressed(path, &cache_key, out.clone());
1040 }
1041 let out = crate::core::redaction::redact_text_if_enabled(&out);
1042 let sent = count_tokens(&out);
1043 return ReadOutput {
1044 content: out,
1045 resolved_mode,
1046 output_tokens: sent,
1047 };
1048 }
1049 cache.invalidate(path);
1050 }
1051
1052 let content = if let Some(pr) = preread {
1057 pr
1058 } else {
1059 match read_file_lossy(path) {
1060 Ok(c) => c,
1061 Err(e) => {
1062 let msg = format!("ERROR: {e}");
1063 let tokens = count_tokens(&msg);
1064 return ReadOutput {
1065 content: msg,
1066 resolved_mode: "error".into(),
1067 output_tokens: tokens,
1068 };
1069 }
1070 }
1071 };
1072
1073 let store_result = cache.store(path, &content);
1074
1075 let is_line_range = mode.starts_with("lines:");
1078 let hints = crate::core::profiles::active_profile().output_hints;
1079 let is_repeat_read = store_result.read_count > 1;
1080 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
1081 find_similar_and_update_semantic_index(path, &content)
1082 } else {
1083 None
1084 };
1085 let graph_hint: Option<String> = None;
1090
1091 if mode == "full" || mode == "full-compact" {
1092 cache.mark_full_delivered(path);
1093
1094 if mode == "full-compact" {
1095 let (output, _) = format_full_compact_output(&content);
1096 let output = crate::core::redaction::redact_text_if_enabled(&output);
1097 let sent = count_tokens(&output);
1098 return ReadOutput {
1099 content: output,
1100 resolved_mode: "full-compact".into(),
1101 output_tokens: sent,
1102 };
1103 }
1104
1105 let (mut output, _) = format_full_output(
1106 &file_ref,
1107 &short,
1108 ext,
1109 &content,
1110 store_result.original_tokens,
1111 store_result.line_count,
1112 task,
1113 );
1114 if let Some(hint) = &graph_hint {
1115 output.push_str(&format!("\n{hint}"));
1116 }
1117 if let Some(hint) = similar_hint {
1118 output.push_str(&format!("\n{hint}"));
1119 }
1120 let framed_tokens = count_tokens(&output);
1121 let output = cap_to_raw(
1122 output,
1123 framed_tokens,
1124 &content,
1125 store_result.original_tokens,
1126 );
1127 let output = crate::core::redaction::redact_text_if_enabled(&output);
1128 let sent = count_tokens(&output);
1129 return ReadOutput {
1130 content: output,
1131 resolved_mode: "full".into(),
1132 output_tokens: sent,
1133 };
1134 }
1135
1136 let resolved_mode = if mode == "auto" {
1137 tuning
1138 .auto_density_mode()
1139 .unwrap_or_else(|| resolve_auto_mode(None, path, store_result.original_tokens, task))
1140 } else {
1141 mode.to_string()
1142 };
1143
1144 let (output, _sent) = process_mode_tuned(
1145 &content,
1146 &resolved_mode,
1147 &file_ref,
1148 &short,
1149 ext,
1150 store_result.original_tokens,
1151 crp_mode,
1152 path,
1153 task,
1154 tuning,
1155 );
1156 let mut output = if mode_allows_raw_cap(&resolved_mode) {
1162 let framed_tokens = count_tokens(&output);
1163 cap_to_raw(
1164 output,
1165 framed_tokens,
1166 &content,
1167 store_result.original_tokens,
1168 )
1169 } else {
1170 output
1171 };
1172 if is_cacheable_mode(&resolved_mode) {
1173 let cache_key = compressed_cache_key(
1174 &resolved_mode,
1175 crp_mode,
1176 task,
1177 tuning.aggressiveness,
1178 tuning.protect,
1179 );
1180 cache.set_compressed(path, &cache_key, output.clone());
1181 }
1182 if let Some(hint) = &graph_hint {
1183 output.push_str(&format!("\n{hint}"));
1184 }
1185 if let Some(hint) = similar_hint {
1186 output.push_str(&format!("\n{hint}"));
1187 }
1188 let output = crate::core::redaction::redact_text_if_enabled(&output);
1189 let final_tokens = count_tokens(&output);
1190 ReadOutput {
1191 content: output,
1192 resolved_mode,
1193 output_tokens: final_tokens,
1194 }
1195}
1196
1197pub fn is_instruction_file(path: &str) -> bool {
1198 let lower = path.to_lowercase();
1199 let filename = std::path::Path::new(&lower)
1200 .file_name()
1201 .and_then(|f| f.to_str())
1202 .unwrap_or("");
1203
1204 matches!(
1205 filename,
1206 "skill.md"
1207 | "agents.md"
1208 | "rules.md"
1209 | ".cursorrules"
1210 | ".clinerules"
1211 | "lean-ctx.md"
1212 | "lean-ctx.mdc"
1213 ) || lower.contains("/skills/")
1214 || lower.contains("/.cursor/rules/")
1215 || lower.contains("/.claude/rules/")
1216 || lower.contains("/agents.md")
1217}
1218
1219pub(crate) fn cap_to_raw(
1234 framed: String,
1235 framed_tokens: usize,
1236 raw_content: &str,
1237 raw_tokens: usize,
1238) -> String {
1239 if raw_tokens > 0 && framed_tokens > raw_tokens {
1240 let prevented = (framed_tokens - raw_tokens) as u64;
1241 crate::core::cache_telemetry::record_raw_cap(prevented);
1242 raw_content.to_string()
1243 } else {
1244 framed
1245 }
1246}
1247
1248pub(crate) fn resolve_auto_mode(
1257 cache: Option<&SessionCache>,
1258 file_path: &str,
1259 original_tokens: usize,
1260 task: Option<&str>,
1261) -> String {
1262 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
1263 path: file_path,
1264 token_count: original_tokens,
1265 task,
1266 cache,
1267 };
1268 crate::core::auto_mode_resolver::resolve(&ctx).mode
1269}
1270
1271fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
1272 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
1273
1274 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
1275 return None;
1276 }
1277
1278 let cfg = crate::core::config::Config::load();
1279 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1280 if !profile.semantic_cache_enabled() {
1281 return None;
1282 }
1283
1284 let project_root = detect_project_root(path);
1285 let session_id = format!("{}", std::process::id());
1286 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
1287
1288 let similar = index.find_similar(content, 0.7);
1289 let relevant: Vec<_> = similar
1290 .into_iter()
1291 .filter(|(p, _)| p != path)
1292 .take(3)
1293 .collect();
1294
1295 index.add_file(path, content, &session_id);
1296 if let Err(e) = index.save(&project_root) {
1297 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
1298 }
1299
1300 if relevant.is_empty() {
1301 return None;
1302 }
1303
1304 let hints: Vec<String> = relevant
1305 .iter()
1306 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
1307 .collect();
1308
1309 Some(format!(
1310 "[semantic: {} similar file(s) in cache]\n{}",
1311 relevant.len(),
1312 hints.join("\n")
1313 ))
1314}
1315
1316fn detect_project_root(path: &str) -> String {
1317 crate::core::protocol::detect_project_root_or_cwd(path)
1318}
1319
1320pub fn graph_related_hint(path: &str) -> Option<String> {
1323 let project_root = detect_project_root(path);
1324 crate::core::graph_context::build_related_hint(path, &project_root, 5)
1325}
1326
1327const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1328
1329fn handle_full_with_auto_delta(
1331 cache: &mut SessionCache,
1332 path: &str,
1333 file_ref: &str,
1334 short: &str,
1335 ext: &str,
1336 task: Option<&str>,
1337) -> (String, usize) {
1338 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1339 let Ok(disk_content) = read_file_lossy(path) else {
1340 cache.record_cache_hit(path);
1341 if let Some(existing) = cache.get(path) {
1342 if !crate::core::protocol::meta_visible()
1343 && let Some(cached) = existing.content()
1344 {
1345 return format_full_output(
1346 file_ref,
1347 short,
1348 ext,
1349 &cached,
1350 existing.original_tokens,
1351 existing.line_count,
1352 task,
1353 );
1354 }
1355 let out = format!(
1356 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1357 existing.read_count(),
1358 existing.line_count
1359 );
1360 let sent = count_tokens(&out);
1361 return (out, sent);
1362 }
1363 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1364 format!("[file read failed and no cached version available] {file_ref}={short}")
1365 } else {
1366 format!("[file read failed and no cached version available] {short}")
1367 };
1368 let sent = count_tokens(&out);
1369 return (out, sent);
1370 };
1371
1372 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1373 let prof = crate::core::profiles::active_profile();
1374 let force_full = no_deg
1375 || (prof.read.default_mode_effective() == "full"
1376 && prof.compression.crp_mode_effective() == "off");
1377
1378 let old_content = cache
1379 .get(path)
1380 .and_then(crate::core::cache::CacheEntry::content)
1381 .unwrap_or_default();
1382 let store_result = cache.store(path, &disk_content);
1383
1384 if store_result.was_hit {
1385 let policy_allows_stub =
1386 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1387 if policy_allows_stub && store_result.full_content_delivered {
1388 let out = if crate::core::protocol::meta_visible() {
1389 format!(
1390 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1391 store_result.line_count
1392 )
1393 } else {
1394 format!(
1398 "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1399 store_result.line_count
1400 )
1401 };
1402 let sent = count_tokens(&out);
1403 return (out, sent);
1404 }
1405 cache.mark_full_delivered(path);
1406 return format_full_output(
1407 file_ref,
1408 short,
1409 ext,
1410 &disk_content,
1411 store_result.original_tokens,
1412 store_result.line_count,
1413 task,
1414 );
1415 }
1416
1417 let diff = compressor::diff_content(&old_content, &disk_content);
1418 let diff_tokens = count_tokens(&diff);
1419 let full_tokens = store_result.original_tokens;
1420
1421 if !force_full
1422 && full_tokens > 0
1423 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1424 {
1425 let savings = protocol::format_savings(full_tokens, diff_tokens);
1426 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1427 format!("{file_ref}={short}")
1428 } else {
1429 short.to_string()
1430 };
1431 let out = format!(
1432 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1433 disk_content.lines().count()
1434 );
1435 return (out, diff_tokens);
1436 }
1437
1438 format_full_output(
1439 file_ref,
1440 short,
1441 ext,
1442 &disk_content,
1443 store_result.original_tokens,
1444 store_result.line_count,
1445 task,
1446 )
1447}
1448
1449fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1450 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1451 let short = protocol::shorten_path(path);
1452 let old_content = cache
1453 .get(path)
1454 .and_then(crate::core::cache::CacheEntry::content);
1455
1456 let new_content = match read_file_lossy(path) {
1457 Ok(c) => c,
1458 Err(e) => {
1459 let msg = format!("ERROR: {e}");
1460 let tokens = count_tokens(&msg);
1461 return (msg, tokens);
1462 }
1463 };
1464
1465 let original_tokens = count_tokens(&new_content);
1466
1467 let diff_output = if let Some(old) = &old_content {
1468 compressor::diff_content(old, &new_content)
1469 } else {
1470 cache.store(path, &new_content);
1473 let msg = format!(
1474 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1475 );
1476 let sent = count_tokens(&msg);
1477 return (msg, sent);
1478 };
1479
1480 cache.store(path, &new_content);
1481
1482 let sent = count_tokens(&diff_output);
1483 let savings = protocol::format_savings(original_tokens, sent);
1484 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1485 format!("{file_ref}={short}")
1486 } else {
1487 short
1488 };
1489 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1490}