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