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
24pub struct ReadOutput {
27 pub content: String,
28 pub resolved_mode: String,
29 pub output_tokens: usize,
32}
33
34fn is_cacheable_mode(mode: &str) -> bool {
38 mode.parse::<ReadMode>()
39 .is_ok_and(|m| m.is_compressed_cacheable())
40}
41
42fn mode_allows_raw_cap(mode: &str) -> bool {
52 mode.parse::<ReadMode>()
55 .map_or(true, |m| m.allows_raw_cap())
56}
57
58fn compressed_cache_key(
59 mode: &str,
60 crp_mode: CrpMode,
61 task: Option<&str>,
62 aggressiveness: Option<f64>,
63 protect: &[String],
64) -> String {
65 let versioned_mode = match mode {
68 "map" => "map:v2",
69 "signatures" => "signatures:v2",
70 _ => mode,
71 };
72 let base = if crp_mode.is_tdd() {
73 format!("{versioned_mode}:tdd")
74 } else {
75 versioned_mode.to_string()
76 };
77 let keyed = match task.map(str::trim).filter(|t| !t.is_empty()) {
80 Some(t) => {
81 use std::hash::{Hash, Hasher};
82 let mut h = std::collections::hash_map::DefaultHasher::new();
83 t.hash(&mut h);
84 format!("{base}:t{:x}", h.finish())
85 }
86 None => base,
87 };
88 let mut key = keyed;
92 let aggr_frag = crate::core::aggressiveness::cache_fragment(aggressiveness);
93 if !aggr_frag.is_empty() {
94 key = format!("{key}:{aggr_frag}");
95 }
96 let protect_frag = crate::core::protect::protect_fragment(protect);
97 if !protect_frag.is_empty() {
98 key = format!("{key}:{protect_frag}");
99 }
100 key
101}
102
103fn append_compressed_hint(output: &str, file_path: &str) -> String {
109 match crate::core::recovery::read_footer(file_path) {
110 Some(footer) => format!("{output}\n{footer}"),
111 None => output.to_string(),
112 }
113}
114
115pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
119 if crate::core::binary_detect::is_binary_file(path) {
120 let msg = crate::core::binary_detect::binary_file_message(path);
121 return Err(std::io::Error::other(msg));
122 }
123
124 {
125 let canonical =
126 crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
127 if let Ok(cwd) = std::env::current_dir() {
128 let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
129 if !canonical.starts_with(&root) {
130 let allow = crate::core::pathjail::allow_paths_from_env_and_config();
131 let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
132 .is_ok_and(|d| canonical.starts_with(d));
133 let tmp_ok = canonical.starts_with(std::env::temp_dir());
134 if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
135 tracing::warn!(
136 "defense-in-depth: path may escape project root: {}",
137 canonical.display()
138 );
139 }
140 }
141 }
142 }
143
144 let cap = crate::core::limits::max_read_bytes();
145
146 let file = open_with_retry(path)?;
147 let meta = file
148 .metadata()
149 .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
150 if meta.len() > cap as u64 {
151 return Err(std::io::Error::other(format!(
152 "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
153 Increase the limit or use a line-range read: mode=\"lines:1-100\"",
154 meta.len(),
155 cap
156 )));
157 }
158
159 use std::io::Read;
160 let mut bytes = Vec::with_capacity(meta.len() as usize);
161 std::io::BufReader::new(file).read_to_end(&mut bytes)?;
162 let s = match String::from_utf8(bytes) {
163 Ok(s) => s,
164 Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
165 };
166 Ok(crate::core::io_boundary::strip_utf8_bom(s))
167}
168
169fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
173 match open_nofollow(path) {
174 Ok(f) => Ok(f),
175 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
176 std::thread::sleep(std::time::Duration::from_millis(50));
177 open_nofollow(path).map_err(|e| {
178 if e.kind() == std::io::ErrorKind::NotFound {
179 std::io::Error::other(format!(
180 "file not found: {path} — verify the path with ctx_tree or ctx_search"
181 ))
182 } else {
183 e
184 }
185 })
186 }
187 Err(e) => Err(e),
188 }
189}
190
191#[cfg(unix)]
192fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
193 use std::os::unix::fs::OpenOptionsExt;
194 use std::path::Path;
195
196 let p = Path::new(path);
197 if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
202 && parent.exists()
203 {
204 let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
205 let canonical_path = canonical_parent.join(filename);
206 return std::fs::OpenOptions::new()
207 .read(true)
208 .custom_flags(libc::O_NOFOLLOW)
209 .open(&canonical_path);
210 }
211
212 std::fs::OpenOptions::new()
214 .read(true)
215 .custom_flags(libc::O_NOFOLLOW)
216 .open(path)
217}
218
219#[cfg(not(unix))]
220fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
221 std::fs::File::open(path)
222}
223
224pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
226 handle_with_options(cache, path, mode, false, crp_mode, None)
227}
228
229pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
231 handle_with_options(cache, path, mode, true, crp_mode, None)
232}
233
234pub fn handle_with_task(
236 cache: &mut SessionCache,
237 path: &str,
238 mode: &str,
239 crp_mode: CrpMode,
240 task: Option<&str>,
241) -> String {
242 handle_with_options(cache, path, mode, false, crp_mode, task)
243}
244
245pub fn handle_with_task_resolved(
247 cache: &mut SessionCache,
248 path: &str,
249 mode: &str,
250 crp_mode: CrpMode,
251 task: Option<&str>,
252) -> ReadOutput {
253 handle_with_options_resolved(
254 cache,
255 path,
256 mode,
257 false,
258 crp_mode,
259 task,
260 ReadTuning::resolve(None, &[]),
261 )
262}
263
264pub fn handle_with_task_resolved_tuned(
268 cache: &mut SessionCache,
269 path: &str,
270 mode: &str,
271 crp_mode: CrpMode,
272 task: Option<&str>,
273 aggressiveness: Option<f64>,
274 protect: &[String],
275) -> ReadOutput {
276 handle_with_options_resolved(
277 cache,
278 path,
279 mode,
280 false,
281 crp_mode,
282 task,
283 ReadTuning::resolve(aggressiveness, protect),
284 )
285}
286
287#[allow(clippy::too_many_arguments)]
290pub fn handle_with_preread(
291 cache: &mut SessionCache,
292 path: &str,
293 mode: &str,
294 fresh: bool,
295 crp_mode: CrpMode,
296 task: Option<&str>,
297 aggressiveness: Option<f64>,
298 protect: &[String],
299 preread: String,
300) -> ReadOutput {
301 handle_with_options_resolved_preread(
302 cache,
303 path,
304 mode,
305 fresh,
306 crp_mode,
307 task,
308 ReadTuning::resolve(aggressiveness, protect),
309 Some(preread),
310 )
311}
312
313pub fn handle_fresh_with_task(
315 cache: &mut SessionCache,
316 path: &str,
317 mode: &str,
318 crp_mode: CrpMode,
319 task: Option<&str>,
320) -> String {
321 handle_with_options(cache, path, mode, true, crp_mode, task)
322}
323
324pub fn handle_fresh_with_task_resolved(
326 cache: &mut SessionCache,
327 path: &str,
328 mode: &str,
329 crp_mode: CrpMode,
330 task: Option<&str>,
331) -> ReadOutput {
332 handle_with_options_resolved(
333 cache,
334 path,
335 mode,
336 true,
337 crp_mode,
338 task,
339 ReadTuning::resolve(None, &[]),
340 )
341}
342
343pub fn handle_fresh_with_task_resolved_tuned(
345 cache: &mut SessionCache,
346 path: &str,
347 mode: &str,
348 crp_mode: CrpMode,
349 task: Option<&str>,
350 aggressiveness: Option<f64>,
351 protect: &[String],
352) -> ReadOutput {
353 handle_with_options_resolved(
354 cache,
355 path,
356 mode,
357 true,
358 crp_mode,
359 task,
360 ReadTuning::resolve(aggressiveness, protect),
361 )
362}
363
364fn handle_with_options(
365 cache: &mut SessionCache,
366 path: &str,
367 mode: &str,
368 fresh: bool,
369 crp_mode: CrpMode,
370 task: Option<&str>,
371) -> String {
372 handle_with_options_resolved(
373 cache,
374 path,
375 mode,
376 fresh,
377 crp_mode,
378 task,
379 ReadTuning::resolve(None, &[]),
380 )
381 .content
382}
383
384fn force_fresh_env() -> bool {
387 static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
388 *FORCE_FRESH.get_or_init(|| {
389 std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
390 })
391}
392
393fn is_subagent_context() -> bool {
403 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
404 *IS_SUBAGENT.get_or_init(|| std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty()))
405}
406
407fn handle_with_options_resolved(
408 cache: &mut SessionCache,
409 path: &str,
410 mode: &str,
411 fresh: bool,
412 crp_mode: CrpMode,
413 task: Option<&str>,
414 tuning: ReadTuning<'_>,
415) -> ReadOutput {
416 handle_with_options_resolved_preread(cache, path, mode, fresh, crp_mode, task, tuning, None)
417}
418
419fn handle_with_options_resolved_preread(
420 cache: &mut SessionCache,
421 path: &str,
422 mode: &str,
423 fresh: bool,
424 crp_mode: CrpMode,
425 task: Option<&str>,
426 tuning: ReadTuning<'_>,
427 preread: Option<String>,
428) -> ReadOutput {
429 let effective_fresh = fresh
430 || force_fresh_env()
431 || (is_subagent_context() && !crate::core::conversation::scope_enabled());
432
433 if PluginManager::has_listener("pre_read") {
434 PluginManager::fire_hook_background(HookPoint::PreRead {
435 path: path.to_string(),
436 });
437 }
438
439 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
440 bt.next_seq();
441 }
442 let mut result = handle_with_options_inner(
443 cache,
444 path,
445 mode,
446 effective_fresh,
447 crp_mode,
448 task,
449 tuning,
450 preread,
451 );
452
453 if let Some(entry) = cache.get_mut(path) {
454 entry.last_mode.clone_from(&result.resolved_mode);
455 }
456
457 let dedup_allowed = result
459 .resolved_mode
460 .parse::<ReadMode>()
461 .is_ok_and(|m| m.is_lossy_summary());
462 if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
463 let new_tokens = count_tokens(&deduped);
464 if new_tokens < result.output_tokens {
465 result.content = deduped;
466 result.output_tokens = new_tokens;
467 }
468 }
469
470 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
471 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
472 bt.record_read(
473 path,
474 &result.resolved_mode,
475 result.output_tokens,
476 original_tokens,
477 );
478
479 let compressed = result
489 .resolved_mode
490 .parse::<ReadMode>()
491 .map_or(true, |m| m.counts_as_compressed());
492 if compressed {
493 crate::core::adaptive_thresholds::record_quality_signal(
494 path,
495 crate::core::threshold_learning::QualitySignal::CleanCompressed,
496 );
497 } else if result.resolved_mode == "full"
498 && result.output_tokens > 2000
499 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
500 {
501 crate::core::adaptive_thresholds::record_quality_signal(
502 path,
503 crate::core::threshold_learning::QualitySignal::WastedFull,
504 );
505 }
506 }
507
508 if PluginManager::has_listener("post_compress") {
510 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
511 PluginManager::fire_hook_background(HookPoint::PostCompress {
512 path: path.to_string(),
513 original_tokens,
514 compressed_tokens: result.output_tokens,
515 });
516 }
517
518 {
525 let self_agent = crate::core::scent_field::scent_agent_id();
526 let scent_path = crate::core::pathutil::normalize_tool_path(path);
527 std::thread::spawn(move || {
528 crate::core::scent_field::deposit(
529 self_agent,
530 crate::core::scent_field::ScentKind::Hot,
531 &scent_path,
532 0.3,
533 );
534 });
535 }
536
537 result
538}
539
540pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
551 let current_conversation = crate::core::conversation::current_conversation_id_fresh();
555 try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
556}
557
558fn try_stub_hit_readonly_scoped(
562 cache: &SessionCache,
563 path: &str,
564 current_conversation: Option<&str>,
565) -> Option<ReadOutput> {
566 let no_deg = crate::core::config::Config::load().no_degrade_effective();
567 let prof = crate::core::profiles::active_profile();
568 let force_full = no_deg
569 || (prof.read.default_mode_effective() == "full"
570 && prof.compression.crp_mode_effective() == "off");
571 let policy_allows_stub =
572 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
573 if !policy_allows_stub {
574 return None;
575 }
576
577 if let Some(file_ref) = cache.get_file_ref_readonly(path) {
579 let (cached_mtime, cached_hash, line_count, delivered_conv) = {
580 let entry = cache.get(path)?;
581 (
582 entry.stored_mtime,
583 entry.hash.clone(),
584 entry.line_count,
585 entry.delivered_conversation.clone(),
586 )
587 };
588 if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
589 || !cache.is_full_delivered(path)
590 {
591 return None;
592 }
593 if !crate::core::conversation::conversation_allows_stub(
599 current_conversation,
600 delivered_conv.as_deref(),
601 ) {
602 crate::core::cache_telemetry::record_conversation_mismatch();
603 return None;
604 }
605 cache.record_cache_hit(path);
606 return Some(render_unchanged_stub(&file_ref, path, line_count));
607 }
608
609 let rec = crate::core::read_stub_index::lookup(path)?;
615 if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
616 return None;
617 }
618 if !crate::core::conversation::conversation_allows_cold_stub(
619 current_conversation,
620 rec.delivered_conversation.as_deref(),
621 ) {
622 crate::core::cache_telemetry::record_conversation_mismatch();
623 return None;
624 }
625 Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
626}
627
628fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
636 let short = protocol::shorten_path(path);
637 let out = if crate::core::protocol::meta_visible() {
638 format!(
639 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
640 )
641 } else {
642 format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
643 };
644 let out = crate::core::redaction::redact_text_if_enabled(&out);
645 let sent = count_tokens(&out);
646 ReadOutput {
647 content: out,
648 resolved_mode: "full".into(),
649 output_tokens: sent,
650 }
651}
652
653#[derive(Debug, Clone, PartialEq, Eq)]
656pub struct DeltaExplicitDecision {
657 pub mode: String,
660 pub note: Option<String>,
664}
665
666pub fn resolve_explicit_delta_mode(
688 cache: &SessionCache,
689 path: &str,
690 mode: &str,
691 explicit_mode: bool,
692 fresh: bool,
693 enabled: bool,
694) -> DeltaExplicitDecision {
695 let unchanged = DeltaExplicitDecision {
696 mode: mode.to_string(),
697 note: None,
698 };
699 if fresh
700 || !enabled
701 || !explicit_mode
702 || !(mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
703 {
704 return unchanged;
705 }
706 let Some(entry) = cache.get(path) else {
707 return unchanged;
709 };
710 let stale =
711 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
712 if stale {
713 if entry.content().is_some() {
717 return DeltaExplicitDecision {
718 mode: "diff".to_string(),
719 note: Some(format!(
720 "[delta-explicit] requested mode={mode} served as a diff: the file \
721 changed since your last read and the diff is the new information. \
722 Pass fresh=true if you need the full content re-emitted."
723 )),
724 };
725 }
726 return unchanged;
727 }
728 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
732 return DeltaExplicitDecision {
733 mode: "full".to_string(),
734 note: None,
735 };
736 }
737 unchanged
738}
739
740fn handle_with_options_inner(
741 cache: &mut SessionCache,
742 path: &str,
743 mode: &str,
744 fresh: bool,
745 crp_mode: CrpMode,
746 task: Option<&str>,
747 tuning: ReadTuning<'_>,
748 preread: Option<String>,
749) -> ReadOutput {
750 let file_ref = cache.get_file_ref(path);
751 let short = protocol::shorten_path(path);
752 let ext = Path::new(path)
753 .extension()
754 .and_then(|e| e.to_str())
755 .unwrap_or("");
756
757 let mode = if mode != "raw"
765 && !mode.starts_with("lines:")
766 && crate::core::config::Config::load()
767 .proxy
768 .is_path_compress_protected(path)
769 {
770 "full"
771 } else {
772 mode
773 };
774
775 if fresh {
776 if mode == "diff" {
777 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
778 return ReadOutput {
779 content: warning.to_string(),
780 resolved_mode: "diff".into(),
781 output_tokens: count_tokens(warning),
782 };
783 }
784 cache.invalidate(path);
785 }
786
787 if mode == "diff" {
788 let (out, _) = handle_diff(cache, path, &file_ref);
789 let out = crate::core::redaction::redact_text_if_enabled(&out);
790 let sent = count_tokens(&out);
791 return ReadOutput {
792 content: out,
793 resolved_mode: "diff".into(),
794 output_tokens: sent,
795 };
796 }
797
798 if mode != "full"
799 && let Some(existing) = cache.get(path)
800 {
801 let stale = crate::core::cache::is_cache_entry_stale_verified(
802 path,
803 existing.stored_mtime,
804 &existing.hash,
805 );
806 if stale {
807 cache.invalidate(path);
808 }
809 }
810
811 let cache_snapshot = cache
814 .get(path)
815 .map(|existing| (existing.original_tokens, existing.content()));
816
817 if let Some((original_tokens, content_opt)) = cache_snapshot {
818 let resolved_mode = if mode == "auto" {
829 tuning
830 .auto_density_mode()
831 .unwrap_or_else(|| resolve_auto_mode(Some(cache), path, original_tokens, task))
832 } else {
833 mode.to_string()
834 };
835
836 if resolved_mode == "full" || resolved_mode == "full-compact" {
837 if let Some(out) = try_stub_hit_readonly(cache, path) {
838 return out;
839 }
840 if resolved_mode == "full-compact" {
841 let content = match read_file_lossy(path) {
842 Ok(c) => c,
843 Err(e) => {
844 let msg = format!("ERROR: {e}");
845 return ReadOutput {
846 content: msg,
847 resolved_mode: "error".into(),
848 output_tokens: 0,
849 };
850 }
851 };
852 let (out, _) = format_full_compact_output(&content);
853 let out = crate::core::redaction::redact_text_if_enabled(&out);
854 let sent = count_tokens(&out);
855 return ReadOutput {
856 content: out,
857 resolved_mode: "full-compact".into(),
858 output_tokens: sent,
859 };
860 }
861 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
862 let out = crate::core::redaction::redact_text_if_enabled(&out);
863 let sent = count_tokens(&out);
864 return ReadOutput {
865 content: out,
866 resolved_mode: "full".into(),
867 output_tokens: sent,
868 };
869 }
870
871 if is_cacheable_mode(&resolved_mode) {
872 let cache_key = compressed_cache_key(
873 &resolved_mode,
874 crp_mode,
875 task,
876 tuning.aggressiveness,
877 tuning.protect,
878 );
879 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
880 if let Some(cached_output) = compressed_hit {
881 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
883 let sent = count_tokens(&out);
884 return ReadOutput {
885 content: out,
886 resolved_mode,
887 output_tokens: sent,
888 };
889 }
890 }
891
892 if let Some(content) = content_opt {
893 let (out, _) = process_mode_tuned(
894 &content,
895 &resolved_mode,
896 &file_ref,
897 &short,
898 ext,
899 original_tokens,
900 crp_mode,
901 path,
902 task,
903 tuning,
904 );
905 let out = if mode_allows_raw_cap(&resolved_mode) {
911 let framed_tokens = count_tokens(&out);
912 cap_to_raw(out, framed_tokens, &content, original_tokens)
913 } else {
914 out
915 };
916 if is_cacheable_mode(&resolved_mode) {
917 let cache_key = compressed_cache_key(
918 &resolved_mode,
919 crp_mode,
920 task,
921 tuning.aggressiveness,
922 tuning.protect,
923 );
924 cache.set_compressed(path, &cache_key, out.clone());
925 }
926 let out = crate::core::redaction::redact_text_if_enabled(&out);
927 let sent = count_tokens(&out);
928 return ReadOutput {
929 content: out,
930 resolved_mode,
931 output_tokens: sent,
932 };
933 }
934 cache.invalidate(path);
935 }
936
937 let content = if let Some(pr) = preread {
942 pr
943 } else {
944 match read_file_lossy(path) {
945 Ok(c) => c,
946 Err(e) => {
947 let msg = format!("ERROR: {e}");
948 let tokens = count_tokens(&msg);
949 return ReadOutput {
950 content: msg,
951 resolved_mode: "error".into(),
952 output_tokens: tokens,
953 };
954 }
955 }
956 };
957
958 let store_result = cache.store(path, &content);
959
960 let is_line_range = mode.starts_with("lines:");
963 let hints = crate::core::profiles::active_profile().output_hints;
964 let is_repeat_read = store_result.read_count > 1;
965 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
966 find_similar_and_update_semantic_index(path, &content)
967 } else {
968 None
969 };
970 let graph_hint: Option<String> = None;
975
976 if mode == "full" || mode == "full-compact" {
977 cache.mark_full_delivered(path);
978
979 if mode == "full-compact" {
980 let (output, _) = format_full_compact_output(&content);
981 let output = crate::core::redaction::redact_text_if_enabled(&output);
982 let sent = count_tokens(&output);
983 return ReadOutput {
984 content: output,
985 resolved_mode: "full-compact".into(),
986 output_tokens: sent,
987 };
988 }
989
990 let (mut output, _) = format_full_output(
991 &file_ref,
992 &short,
993 ext,
994 &content,
995 store_result.original_tokens,
996 store_result.line_count,
997 task,
998 );
999 if let Some(hint) = &graph_hint {
1000 output.push_str(&format!("\n{hint}"));
1001 }
1002 if let Some(hint) = similar_hint {
1003 output.push_str(&format!("\n{hint}"));
1004 }
1005 let framed_tokens = count_tokens(&output);
1006 let output = cap_to_raw(
1007 output,
1008 framed_tokens,
1009 &content,
1010 store_result.original_tokens,
1011 );
1012 let output = crate::core::redaction::redact_text_if_enabled(&output);
1013 let sent = count_tokens(&output);
1014 return ReadOutput {
1015 content: output,
1016 resolved_mode: "full".into(),
1017 output_tokens: sent,
1018 };
1019 }
1020
1021 let resolved_mode = if mode == "auto" {
1022 tuning
1023 .auto_density_mode()
1024 .unwrap_or_else(|| resolve_auto_mode(None, path, store_result.original_tokens, task))
1025 } else {
1026 mode.to_string()
1027 };
1028
1029 let (output, _sent) = process_mode_tuned(
1030 &content,
1031 &resolved_mode,
1032 &file_ref,
1033 &short,
1034 ext,
1035 store_result.original_tokens,
1036 crp_mode,
1037 path,
1038 task,
1039 tuning,
1040 );
1041 let mut output = if mode_allows_raw_cap(&resolved_mode) {
1047 let framed_tokens = count_tokens(&output);
1048 cap_to_raw(
1049 output,
1050 framed_tokens,
1051 &content,
1052 store_result.original_tokens,
1053 )
1054 } else {
1055 output
1056 };
1057 if is_cacheable_mode(&resolved_mode) {
1058 let cache_key = compressed_cache_key(
1059 &resolved_mode,
1060 crp_mode,
1061 task,
1062 tuning.aggressiveness,
1063 tuning.protect,
1064 );
1065 cache.set_compressed(path, &cache_key, output.clone());
1066 }
1067 if let Some(hint) = &graph_hint {
1068 output.push_str(&format!("\n{hint}"));
1069 }
1070 if let Some(hint) = similar_hint {
1071 output.push_str(&format!("\n{hint}"));
1072 }
1073 let output = crate::core::redaction::redact_text_if_enabled(&output);
1074 let final_tokens = count_tokens(&output);
1075 ReadOutput {
1076 content: output,
1077 resolved_mode,
1078 output_tokens: final_tokens,
1079 }
1080}
1081
1082pub fn is_instruction_file(path: &str) -> bool {
1083 let lower = path.to_lowercase();
1084 let filename = std::path::Path::new(&lower)
1085 .file_name()
1086 .and_then(|f| f.to_str())
1087 .unwrap_or("");
1088
1089 matches!(
1090 filename,
1091 "skill.md"
1092 | "agents.md"
1093 | "rules.md"
1094 | ".cursorrules"
1095 | ".clinerules"
1096 | "lean-ctx.md"
1097 | "lean-ctx.mdc"
1098 ) || lower.contains("/skills/")
1099 || lower.contains("/.cursor/rules/")
1100 || lower.contains("/.claude/rules/")
1101 || lower.contains("/agents.md")
1102}
1103
1104fn cap_to_raw(
1119 framed: String,
1120 framed_tokens: usize,
1121 raw_content: &str,
1122 raw_tokens: usize,
1123) -> String {
1124 if raw_tokens > 0 && framed_tokens > raw_tokens {
1125 let prevented = (framed_tokens - raw_tokens) as u64;
1126 crate::core::cache_telemetry::record_raw_cap(prevented);
1127 raw_content.to_string()
1128 } else {
1129 framed
1130 }
1131}
1132
1133fn resolve_auto_mode(
1142 cache: Option<&SessionCache>,
1143 file_path: &str,
1144 original_tokens: usize,
1145 task: Option<&str>,
1146) -> String {
1147 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
1148 path: file_path,
1149 token_count: original_tokens,
1150 task,
1151 cache,
1152 };
1153 crate::core::auto_mode_resolver::resolve(&ctx).mode
1154}
1155
1156fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
1157 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
1158
1159 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
1160 return None;
1161 }
1162
1163 let cfg = crate::core::config::Config::load();
1164 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1165 if !profile.semantic_cache_enabled() {
1166 return None;
1167 }
1168
1169 let project_root = detect_project_root(path);
1170 let session_id = format!("{}", std::process::id());
1171 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
1172
1173 let similar = index.find_similar(content, 0.7);
1174 let relevant: Vec<_> = similar
1175 .into_iter()
1176 .filter(|(p, _)| p != path)
1177 .take(3)
1178 .collect();
1179
1180 index.add_file(path, content, &session_id);
1181 if let Err(e) = index.save(&project_root) {
1182 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
1183 }
1184
1185 if relevant.is_empty() {
1186 return None;
1187 }
1188
1189 let hints: Vec<String> = relevant
1190 .iter()
1191 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
1192 .collect();
1193
1194 Some(format!(
1195 "[semantic: {} similar file(s) in cache]\n{}",
1196 relevant.len(),
1197 hints.join("\n")
1198 ))
1199}
1200
1201fn detect_project_root(path: &str) -> String {
1202 crate::core::protocol::detect_project_root_or_cwd(path)
1203}
1204
1205pub fn graph_related_hint(path: &str) -> Option<String> {
1208 let project_root = detect_project_root(path);
1209 crate::core::graph_context::build_related_hint(path, &project_root, 5)
1210}
1211
1212const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1213
1214fn handle_full_with_auto_delta(
1216 cache: &mut SessionCache,
1217 path: &str,
1218 file_ref: &str,
1219 short: &str,
1220 ext: &str,
1221 task: Option<&str>,
1222) -> (String, usize) {
1223 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1224 let Ok(disk_content) = read_file_lossy(path) else {
1225 cache.record_cache_hit(path);
1226 if let Some(existing) = cache.get(path) {
1227 if !crate::core::protocol::meta_visible()
1228 && let Some(cached) = existing.content()
1229 {
1230 return format_full_output(
1231 file_ref,
1232 short,
1233 ext,
1234 &cached,
1235 existing.original_tokens,
1236 existing.line_count,
1237 task,
1238 );
1239 }
1240 let out = format!(
1241 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1242 existing.read_count(),
1243 existing.line_count
1244 );
1245 let sent = count_tokens(&out);
1246 return (out, sent);
1247 }
1248 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1249 format!("[file read failed and no cached version available] {file_ref}={short}")
1250 } else {
1251 format!("[file read failed and no cached version available] {short}")
1252 };
1253 let sent = count_tokens(&out);
1254 return (out, sent);
1255 };
1256
1257 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1258 let prof = crate::core::profiles::active_profile();
1259 let force_full = no_deg
1260 || (prof.read.default_mode_effective() == "full"
1261 && prof.compression.crp_mode_effective() == "off");
1262
1263 let old_content = cache
1264 .get(path)
1265 .and_then(crate::core::cache::CacheEntry::content)
1266 .unwrap_or_default();
1267 let store_result = cache.store(path, &disk_content);
1268
1269 if store_result.was_hit {
1270 let policy_allows_stub =
1271 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1272 if policy_allows_stub && store_result.full_content_delivered {
1273 let out = if crate::core::protocol::meta_visible() {
1274 format!(
1275 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1276 store_result.line_count
1277 )
1278 } else {
1279 format!(
1283 "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1284 store_result.line_count
1285 )
1286 };
1287 let sent = count_tokens(&out);
1288 return (out, sent);
1289 }
1290 cache.mark_full_delivered(path);
1291 return format_full_output(
1292 file_ref,
1293 short,
1294 ext,
1295 &disk_content,
1296 store_result.original_tokens,
1297 store_result.line_count,
1298 task,
1299 );
1300 }
1301
1302 let diff = compressor::diff_content(&old_content, &disk_content);
1303 let diff_tokens = count_tokens(&diff);
1304 let full_tokens = store_result.original_tokens;
1305
1306 if !force_full
1307 && full_tokens > 0
1308 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1309 {
1310 let savings = protocol::format_savings(full_tokens, diff_tokens);
1311 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1312 format!("{file_ref}={short}")
1313 } else {
1314 short.to_string()
1315 };
1316 let out = format!(
1317 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1318 disk_content.lines().count()
1319 );
1320 return (out, diff_tokens);
1321 }
1322
1323 format_full_output(
1324 file_ref,
1325 short,
1326 ext,
1327 &disk_content,
1328 store_result.original_tokens,
1329 store_result.line_count,
1330 task,
1331 )
1332}
1333
1334fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1335 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1336 let short = protocol::shorten_path(path);
1337 let old_content = cache
1338 .get(path)
1339 .and_then(crate::core::cache::CacheEntry::content);
1340
1341 let new_content = match read_file_lossy(path) {
1342 Ok(c) => c,
1343 Err(e) => {
1344 let msg = format!("ERROR: {e}");
1345 let tokens = count_tokens(&msg);
1346 return (msg, tokens);
1347 }
1348 };
1349
1350 let original_tokens = count_tokens(&new_content);
1351
1352 let diff_output = if let Some(old) = &old_content {
1353 compressor::diff_content(old, &new_content)
1354 } else {
1355 cache.store(path, &new_content);
1358 let msg = format!(
1359 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1360 );
1361 let sent = count_tokens(&msg);
1362 return (msg, sent);
1363 };
1364
1365 cache.store(path, &new_content);
1366
1367 let sent = count_tokens(&diff_output);
1368 let savings = protocol::format_savings(original_tokens, sent);
1369 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1370 format!("{file_ref}={short}")
1371 } else {
1372 short
1373 };
1374 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1375}