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
287pub fn handle_fresh_with_task(
289 cache: &mut SessionCache,
290 path: &str,
291 mode: &str,
292 crp_mode: CrpMode,
293 task: Option<&str>,
294) -> String {
295 handle_with_options(cache, path, mode, true, crp_mode, task)
296}
297
298pub fn handle_fresh_with_task_resolved(
300 cache: &mut SessionCache,
301 path: &str,
302 mode: &str,
303 crp_mode: CrpMode,
304 task: Option<&str>,
305) -> ReadOutput {
306 handle_with_options_resolved(
307 cache,
308 path,
309 mode,
310 true,
311 crp_mode,
312 task,
313 ReadTuning::resolve(None, &[]),
314 )
315}
316
317pub fn handle_fresh_with_task_resolved_tuned(
319 cache: &mut SessionCache,
320 path: &str,
321 mode: &str,
322 crp_mode: CrpMode,
323 task: Option<&str>,
324 aggressiveness: Option<f64>,
325 protect: &[String],
326) -> ReadOutput {
327 handle_with_options_resolved(
328 cache,
329 path,
330 mode,
331 true,
332 crp_mode,
333 task,
334 ReadTuning::resolve(aggressiveness, protect),
335 )
336}
337
338fn handle_with_options(
339 cache: &mut SessionCache,
340 path: &str,
341 mode: &str,
342 fresh: bool,
343 crp_mode: CrpMode,
344 task: Option<&str>,
345) -> String {
346 handle_with_options_resolved(
347 cache,
348 path,
349 mode,
350 fresh,
351 crp_mode,
352 task,
353 ReadTuning::resolve(None, &[]),
354 )
355 .content
356}
357
358fn force_fresh_env() -> bool {
361 static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
362 *FORCE_FRESH.get_or_init(|| {
363 std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
364 })
365}
366
367fn is_subagent_context() -> bool {
377 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
378 *IS_SUBAGENT.get_or_init(|| std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty()))
379}
380
381fn handle_with_options_resolved(
382 cache: &mut SessionCache,
383 path: &str,
384 mode: &str,
385 fresh: bool,
386 crp_mode: CrpMode,
387 task: Option<&str>,
388 tuning: ReadTuning<'_>,
389) -> ReadOutput {
390 let effective_fresh = fresh
396 || force_fresh_env()
397 || (is_subagent_context() && !crate::core::conversation::scope_enabled());
398
399 if PluginManager::has_listener("pre_read") {
402 PluginManager::fire_hook_background(HookPoint::PreRead {
403 path: path.to_string(),
404 });
405 }
406
407 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
408 bt.next_seq();
409 }
410 let mut result =
411 handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task, tuning);
412
413 if let Some(entry) = cache.get_mut(path) {
414 entry.last_mode.clone_from(&result.resolved_mode);
415 }
416
417 let dedup_allowed = result
419 .resolved_mode
420 .parse::<ReadMode>()
421 .is_ok_and(|m| m.is_lossy_summary());
422 if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
423 let new_tokens = count_tokens(&deduped);
424 if new_tokens < result.output_tokens {
425 result.content = deduped;
426 result.output_tokens = new_tokens;
427 }
428 }
429
430 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
431 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
432 bt.record_read(
433 path,
434 &result.resolved_mode,
435 result.output_tokens,
436 original_tokens,
437 );
438
439 let compressed = result
449 .resolved_mode
450 .parse::<ReadMode>()
451 .map_or(true, |m| m.counts_as_compressed());
452 if compressed {
453 crate::core::adaptive_thresholds::record_quality_signal(
454 path,
455 crate::core::threshold_learning::QualitySignal::CleanCompressed,
456 );
457 } else if result.resolved_mode == "full"
458 && result.output_tokens > 2000
459 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
460 {
461 crate::core::adaptive_thresholds::record_quality_signal(
462 path,
463 crate::core::threshold_learning::QualitySignal::WastedFull,
464 );
465 }
466 }
467
468 if PluginManager::has_listener("post_compress") {
470 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
471 PluginManager::fire_hook_background(HookPoint::PostCompress {
472 path: path.to_string(),
473 original_tokens,
474 compressed_tokens: result.output_tokens,
475 });
476 }
477
478 {
485 let self_agent = crate::core::scent_field::scent_agent_id();
486 let scent_path = crate::core::pathutil::normalize_tool_path(path);
487 std::thread::spawn(move || {
488 crate::core::scent_field::deposit(
489 self_agent,
490 crate::core::scent_field::ScentKind::Hot,
491 &scent_path,
492 0.3,
493 );
494 });
495 }
496
497 result
498}
499
500pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
511 let current_conversation = crate::core::conversation::current_conversation_id_fresh();
515 try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
516}
517
518fn try_stub_hit_readonly_scoped(
522 cache: &SessionCache,
523 path: &str,
524 current_conversation: Option<&str>,
525) -> Option<ReadOutput> {
526 let no_deg = crate::core::config::Config::load().no_degrade_effective();
527 let prof = crate::core::profiles::active_profile();
528 let force_full = no_deg
529 || (prof.read.default_mode_effective() == "full"
530 && prof.compression.crp_mode_effective() == "off");
531 let policy_allows_stub =
532 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
533 if !policy_allows_stub {
534 return None;
535 }
536
537 if let Some(file_ref) = cache.get_file_ref_readonly(path) {
539 let (cached_mtime, cached_hash, line_count, delivered_conv) = {
540 let entry = cache.get(path)?;
541 (
542 entry.stored_mtime,
543 entry.hash.clone(),
544 entry.line_count,
545 entry.delivered_conversation.clone(),
546 )
547 };
548 if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
549 || !cache.is_full_delivered(path)
550 {
551 return None;
552 }
553 if !crate::core::conversation::conversation_allows_stub(
559 current_conversation,
560 delivered_conv.as_deref(),
561 ) {
562 crate::core::cache_telemetry::record_conversation_mismatch();
563 return None;
564 }
565 cache.record_cache_hit(path);
566 return Some(render_unchanged_stub(&file_ref, path, line_count));
567 }
568
569 let rec = crate::core::read_stub_index::lookup(path)?;
575 if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
576 return None;
577 }
578 if !crate::core::conversation::conversation_allows_cold_stub(
579 current_conversation,
580 rec.delivered_conversation.as_deref(),
581 ) {
582 crate::core::cache_telemetry::record_conversation_mismatch();
583 return None;
584 }
585 Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
586}
587
588fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
596 let short = protocol::shorten_path(path);
597 let out = if crate::core::protocol::meta_visible() {
598 format!(
599 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
600 )
601 } else {
602 format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
603 };
604 let out = crate::core::redaction::redact_text_if_enabled(&out);
605 let sent = count_tokens(&out);
606 ReadOutput {
607 content: out,
608 resolved_mode: "full".into(),
609 output_tokens: sent,
610 }
611}
612
613#[derive(Debug, Clone, PartialEq, Eq)]
616pub struct DeltaExplicitDecision {
617 pub mode: String,
620 pub note: Option<String>,
624}
625
626pub fn resolve_explicit_delta_mode(
648 cache: &SessionCache,
649 path: &str,
650 mode: &str,
651 explicit_mode: bool,
652 fresh: bool,
653 enabled: bool,
654) -> DeltaExplicitDecision {
655 let unchanged = DeltaExplicitDecision {
656 mode: mode.to_string(),
657 note: None,
658 };
659 if fresh || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
660 return unchanged;
661 }
662 let Some(entry) = cache.get(path) else {
663 return unchanged;
665 };
666 let stale =
667 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
668 if stale {
669 if entry.content().is_some() {
673 return DeltaExplicitDecision {
674 mode: "diff".to_string(),
675 note: Some(format!(
676 "[delta-explicit] requested mode={mode} served as a diff: the file \
677 changed since your last read and the diff is the new information. \
678 Pass fresh=true if you need the full content re-emitted."
679 )),
680 };
681 }
682 return unchanged;
683 }
684 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
688 return DeltaExplicitDecision {
689 mode: "full".to_string(),
690 note: None,
691 };
692 }
693 unchanged
694}
695
696fn handle_with_options_inner(
697 cache: &mut SessionCache,
698 path: &str,
699 mode: &str,
700 fresh: bool,
701 crp_mode: CrpMode,
702 task: Option<&str>,
703 tuning: ReadTuning<'_>,
704) -> ReadOutput {
705 let file_ref = cache.get_file_ref(path);
706 let short = protocol::shorten_path(path);
707 let ext = Path::new(path)
708 .extension()
709 .and_then(|e| e.to_str())
710 .unwrap_or("");
711
712 let mode = if mode != "raw"
720 && !mode.starts_with("lines:")
721 && crate::core::config::Config::load()
722 .proxy
723 .is_path_compress_protected(path)
724 {
725 "full"
726 } else {
727 mode
728 };
729
730 if fresh {
731 if mode == "diff" {
732 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
733 return ReadOutput {
734 content: warning.to_string(),
735 resolved_mode: "diff".into(),
736 output_tokens: count_tokens(warning),
737 };
738 }
739 cache.invalidate(path);
740 }
741
742 if mode == "diff" {
743 let (out, _) = handle_diff(cache, path, &file_ref);
744 let out = crate::core::redaction::redact_text_if_enabled(&out);
745 let sent = count_tokens(&out);
746 return ReadOutput {
747 content: out,
748 resolved_mode: "diff".into(),
749 output_tokens: sent,
750 };
751 }
752
753 if mode != "full"
754 && let Some(existing) = cache.get(path)
755 {
756 let stale = crate::core::cache::is_cache_entry_stale_verified(
757 path,
758 existing.stored_mtime,
759 &existing.hash,
760 );
761 if stale {
762 cache.invalidate(path);
763 }
764 }
765
766 let cache_snapshot = cache
769 .get(path)
770 .map(|existing| (existing.original_tokens, existing.content()));
771
772 if let Some((original_tokens, content_opt)) = cache_snapshot {
773 let resolved_mode = if mode == "auto" {
784 tuning
785 .auto_density_mode()
786 .unwrap_or_else(|| resolve_auto_mode(Some(cache), path, original_tokens, task))
787 } else {
788 mode.to_string()
789 };
790
791 if resolved_mode == "full" {
792 if let Some(out) = try_stub_hit_readonly(cache, path) {
797 return out;
798 }
799 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
800 let out = crate::core::redaction::redact_text_if_enabled(&out);
801 let sent = count_tokens(&out);
802 return ReadOutput {
803 content: out,
804 resolved_mode: "full".into(),
805 output_tokens: sent,
806 };
807 }
808
809 if is_cacheable_mode(&resolved_mode) {
810 let cache_key = compressed_cache_key(
811 &resolved_mode,
812 crp_mode,
813 task,
814 tuning.aggressiveness,
815 tuning.protect,
816 );
817 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
818 if let Some(cached_output) = compressed_hit {
819 cache.record_cache_hit(path);
820 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
821 let sent = count_tokens(&out);
822 return ReadOutput {
823 content: out,
824 resolved_mode,
825 output_tokens: sent,
826 };
827 }
828 }
829
830 if let Some(content) = content_opt {
831 let (out, _) = process_mode_tuned(
832 &content,
833 &resolved_mode,
834 &file_ref,
835 &short,
836 ext,
837 original_tokens,
838 crp_mode,
839 path,
840 task,
841 tuning,
842 );
843 let out = if mode_allows_raw_cap(&resolved_mode) {
849 let framed_tokens = count_tokens(&out);
850 cap_to_raw(out, framed_tokens, &content, original_tokens)
851 } else {
852 out
853 };
854 if is_cacheable_mode(&resolved_mode) {
855 let cache_key = compressed_cache_key(
856 &resolved_mode,
857 crp_mode,
858 task,
859 tuning.aggressiveness,
860 tuning.protect,
861 );
862 cache.set_compressed(path, &cache_key, out.clone());
863 }
864 let out = crate::core::redaction::redact_text_if_enabled(&out);
865 let sent = count_tokens(&out);
866 return ReadOutput {
867 content: out,
868 resolved_mode,
869 output_tokens: sent,
870 };
871 }
872 cache.invalidate(path);
873 }
874
875 let content = match read_file_lossy(path) {
876 Ok(c) => c,
877 Err(e) => {
878 let msg = format!("ERROR: {e}");
879 let tokens = count_tokens(&msg);
880 return ReadOutput {
881 content: msg,
882 resolved_mode: "error".into(),
883 output_tokens: tokens,
884 };
885 }
886 };
887
888 let store_result = cache.store(path, &content);
889
890 let is_line_range = mode.starts_with("lines:");
893 let hints = crate::core::profiles::active_profile().output_hints;
894 let is_repeat_read = store_result.read_count > 1;
895 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
896 find_similar_and_update_semantic_index(path, &content)
897 } else {
898 None
899 };
900 let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
901 build_graph_related_hint(path)
902 } else {
903 None
904 };
905
906 if mode == "full" {
907 cache.mark_full_delivered(path);
908 let (mut output, _) = format_full_output(
909 &file_ref,
910 &short,
911 ext,
912 &content,
913 store_result.original_tokens,
914 store_result.line_count,
915 task,
916 );
917 if let Some(hint) = &graph_hint {
918 output.push_str(&format!("\n{hint}"));
919 }
920 if let Some(hint) = similar_hint {
921 output.push_str(&format!("\n{hint}"));
922 }
923 let framed_tokens = count_tokens(&output);
924 let output = cap_to_raw(
925 output,
926 framed_tokens,
927 &content,
928 store_result.original_tokens,
929 );
930 let output = crate::core::redaction::redact_text_if_enabled(&output);
931 let sent = count_tokens(&output);
932 return ReadOutput {
933 content: output,
934 resolved_mode: "full".into(),
935 output_tokens: sent,
936 };
937 }
938
939 let resolved_mode = if mode == "auto" {
940 tuning
941 .auto_density_mode()
942 .unwrap_or_else(|| resolve_auto_mode(None, path, store_result.original_tokens, task))
943 } else {
944 mode.to_string()
945 };
946
947 let (output, _sent) = process_mode_tuned(
948 &content,
949 &resolved_mode,
950 &file_ref,
951 &short,
952 ext,
953 store_result.original_tokens,
954 crp_mode,
955 path,
956 task,
957 tuning,
958 );
959 let mut output = if mode_allows_raw_cap(&resolved_mode) {
965 let framed_tokens = count_tokens(&output);
966 cap_to_raw(
967 output,
968 framed_tokens,
969 &content,
970 store_result.original_tokens,
971 )
972 } else {
973 output
974 };
975 if is_cacheable_mode(&resolved_mode) {
976 let cache_key = compressed_cache_key(
977 &resolved_mode,
978 crp_mode,
979 task,
980 tuning.aggressiveness,
981 tuning.protect,
982 );
983 cache.set_compressed(path, &cache_key, output.clone());
984 }
985 if let Some(hint) = &graph_hint {
986 output.push_str(&format!("\n{hint}"));
987 }
988 if let Some(hint) = similar_hint {
989 output.push_str(&format!("\n{hint}"));
990 }
991 let output = crate::core::redaction::redact_text_if_enabled(&output);
992 let final_tokens = count_tokens(&output);
993 ReadOutput {
994 content: output,
995 resolved_mode,
996 output_tokens: final_tokens,
997 }
998}
999
1000pub fn is_instruction_file(path: &str) -> bool {
1001 let lower = path.to_lowercase();
1002 let filename = std::path::Path::new(&lower)
1003 .file_name()
1004 .and_then(|f| f.to_str())
1005 .unwrap_or("");
1006
1007 matches!(
1008 filename,
1009 "skill.md"
1010 | "agents.md"
1011 | "rules.md"
1012 | ".cursorrules"
1013 | ".clinerules"
1014 | "lean-ctx.md"
1015 | "lean-ctx.mdc"
1016 ) || lower.contains("/skills/")
1017 || lower.contains("/.cursor/rules/")
1018 || lower.contains("/.claude/rules/")
1019 || lower.contains("/agents.md")
1020}
1021
1022fn cap_to_raw(
1037 framed: String,
1038 framed_tokens: usize,
1039 raw_content: &str,
1040 raw_tokens: usize,
1041) -> String {
1042 if raw_tokens > 0 && framed_tokens > raw_tokens {
1043 raw_content.to_string()
1044 } else {
1045 framed
1046 }
1047}
1048
1049fn resolve_auto_mode(
1058 cache: Option<&SessionCache>,
1059 file_path: &str,
1060 original_tokens: usize,
1061 task: Option<&str>,
1062) -> String {
1063 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
1064 path: file_path,
1065 token_count: original_tokens,
1066 task,
1067 cache,
1068 };
1069 crate::core::auto_mode_resolver::resolve(&ctx).mode
1070}
1071
1072fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
1073 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
1074
1075 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
1076 return None;
1077 }
1078
1079 let cfg = crate::core::config::Config::load();
1080 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1081 if !profile.semantic_cache_enabled() {
1082 return None;
1083 }
1084
1085 let project_root = detect_project_root(path);
1086 let session_id = format!("{}", std::process::id());
1087 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
1088
1089 let similar = index.find_similar(content, 0.7);
1090 let relevant: Vec<_> = similar
1091 .into_iter()
1092 .filter(|(p, _)| p != path)
1093 .take(3)
1094 .collect();
1095
1096 index.add_file(path, content, &session_id);
1097 if let Err(e) = index.save(&project_root) {
1098 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
1099 }
1100
1101 if relevant.is_empty() {
1102 return None;
1103 }
1104
1105 let hints: Vec<String> = relevant
1106 .iter()
1107 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
1108 .collect();
1109
1110 Some(format!(
1111 "[semantic: {} similar file(s) in cache]\n{}",
1112 relevant.len(),
1113 hints.join("\n")
1114 ))
1115}
1116
1117fn detect_project_root(path: &str) -> String {
1118 crate::core::protocol::detect_project_root_or_cwd(path)
1119}
1120
1121fn build_graph_related_hint(path: &str) -> Option<String> {
1122 let project_root = detect_project_root(path);
1123 crate::core::graph_context::build_related_hint(path, &project_root, 5)
1124}
1125
1126const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1127
1128fn handle_full_with_auto_delta(
1130 cache: &mut SessionCache,
1131 path: &str,
1132 file_ref: &str,
1133 short: &str,
1134 ext: &str,
1135 task: Option<&str>,
1136) -> (String, usize) {
1137 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1138 let Ok(disk_content) = read_file_lossy(path) else {
1139 cache.record_cache_hit(path);
1140 if let Some(existing) = cache.get(path) {
1141 if !crate::core::protocol::meta_visible()
1142 && let Some(cached) = existing.content()
1143 {
1144 return format_full_output(
1145 file_ref,
1146 short,
1147 ext,
1148 &cached,
1149 existing.original_tokens,
1150 existing.line_count,
1151 task,
1152 );
1153 }
1154 let out = format!(
1155 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1156 existing.read_count(),
1157 existing.line_count
1158 );
1159 let sent = count_tokens(&out);
1160 return (out, sent);
1161 }
1162 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1163 format!("[file read failed and no cached version available] {file_ref}={short}")
1164 } else {
1165 format!("[file read failed and no cached version available] {short}")
1166 };
1167 let sent = count_tokens(&out);
1168 return (out, sent);
1169 };
1170
1171 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1172 let prof = crate::core::profiles::active_profile();
1173 let force_full = no_deg
1174 || (prof.read.default_mode_effective() == "full"
1175 && prof.compression.crp_mode_effective() == "off");
1176
1177 let old_content = cache
1178 .get(path)
1179 .and_then(crate::core::cache::CacheEntry::content)
1180 .unwrap_or_default();
1181 let store_result = cache.store(path, &disk_content);
1182
1183 if store_result.was_hit {
1184 let policy_allows_stub =
1185 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1186 if policy_allows_stub && store_result.full_content_delivered {
1187 let out = if crate::core::protocol::meta_visible() {
1188 format!(
1189 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1190 store_result.line_count
1191 )
1192 } else {
1193 format!(
1197 "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1198 store_result.line_count
1199 )
1200 };
1201 let sent = count_tokens(&out);
1202 return (out, sent);
1203 }
1204 cache.mark_full_delivered(path);
1205 return format_full_output(
1206 file_ref,
1207 short,
1208 ext,
1209 &disk_content,
1210 store_result.original_tokens,
1211 store_result.line_count,
1212 task,
1213 );
1214 }
1215
1216 let diff = compressor::diff_content(&old_content, &disk_content);
1217 let diff_tokens = count_tokens(&diff);
1218 let full_tokens = store_result.original_tokens;
1219
1220 if !force_full
1221 && full_tokens > 0
1222 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1223 {
1224 let savings = protocol::format_savings(full_tokens, diff_tokens);
1225 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1226 format!("{file_ref}={short}")
1227 } else {
1228 short.to_string()
1229 };
1230 let out = format!(
1231 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1232 disk_content.lines().count()
1233 );
1234 return (out, diff_tokens);
1235 }
1236
1237 format_full_output(
1238 file_ref,
1239 short,
1240 ext,
1241 &disk_content,
1242 store_result.original_tokens,
1243 store_result.line_count,
1244 task,
1245 )
1246}
1247
1248fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1249 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1250 let short = protocol::shorten_path(path);
1251 let old_content = cache
1252 .get(path)
1253 .and_then(crate::core::cache::CacheEntry::content);
1254
1255 let new_content = match read_file_lossy(path) {
1256 Ok(c) => c,
1257 Err(e) => {
1258 let msg = format!("ERROR: {e}");
1259 let tokens = count_tokens(&msg);
1260 return (msg, tokens);
1261 }
1262 };
1263
1264 let original_tokens = count_tokens(&new_content);
1265
1266 let diff_output = if let Some(old) = &old_content {
1267 compressor::diff_content(old, &new_content)
1268 } else {
1269 cache.store(path, &new_content);
1272 let msg = format!(
1273 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1274 );
1275 let sent = count_tokens(&msg);
1276 return (msg, sent);
1277 };
1278
1279 cache.store(path, &new_content);
1280
1281 let sent = count_tokens(&diff_output);
1282 let savings = protocol::format_savings(original_tokens, sent);
1283 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1284 format!("{file_ref}={short}")
1285 } else {
1286 short
1287 };
1288 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1289}