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 || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
700 return unchanged;
701 }
702 let Some(entry) = cache.get(path) else {
703 return unchanged;
705 };
706 let stale =
707 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
708 if stale {
709 if entry.content().is_some() {
713 return DeltaExplicitDecision {
714 mode: "diff".to_string(),
715 note: Some(format!(
716 "[delta-explicit] requested mode={mode} served as a diff: the file \
717 changed since your last read and the diff is the new information. \
718 Pass fresh=true if you need the full content re-emitted."
719 )),
720 };
721 }
722 return unchanged;
723 }
724 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
728 return DeltaExplicitDecision {
729 mode: "full".to_string(),
730 note: None,
731 };
732 }
733 unchanged
734}
735
736fn handle_with_options_inner(
737 cache: &mut SessionCache,
738 path: &str,
739 mode: &str,
740 fresh: bool,
741 crp_mode: CrpMode,
742 task: Option<&str>,
743 tuning: ReadTuning<'_>,
744 preread: Option<String>,
745) -> ReadOutput {
746 let file_ref = cache.get_file_ref(path);
747 let short = protocol::shorten_path(path);
748 let ext = Path::new(path)
749 .extension()
750 .and_then(|e| e.to_str())
751 .unwrap_or("");
752
753 let mode = if mode != "raw"
761 && !mode.starts_with("lines:")
762 && crate::core::config::Config::load()
763 .proxy
764 .is_path_compress_protected(path)
765 {
766 "full"
767 } else {
768 mode
769 };
770
771 if fresh {
772 if mode == "diff" {
773 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
774 return ReadOutput {
775 content: warning.to_string(),
776 resolved_mode: "diff".into(),
777 output_tokens: count_tokens(warning),
778 };
779 }
780 cache.invalidate(path);
781 }
782
783 if mode == "diff" {
784 let (out, _) = handle_diff(cache, path, &file_ref);
785 let out = crate::core::redaction::redact_text_if_enabled(&out);
786 let sent = count_tokens(&out);
787 return ReadOutput {
788 content: out,
789 resolved_mode: "diff".into(),
790 output_tokens: sent,
791 };
792 }
793
794 if mode != "full"
795 && let Some(existing) = cache.get(path)
796 {
797 let stale = crate::core::cache::is_cache_entry_stale_verified(
798 path,
799 existing.stored_mtime,
800 &existing.hash,
801 );
802 if stale {
803 cache.invalidate(path);
804 }
805 }
806
807 let cache_snapshot = cache
810 .get(path)
811 .map(|existing| (existing.original_tokens, existing.content()));
812
813 if let Some((original_tokens, content_opt)) = cache_snapshot {
814 let resolved_mode = if mode == "auto" {
825 tuning
826 .auto_density_mode()
827 .unwrap_or_else(|| resolve_auto_mode(Some(cache), path, original_tokens, task))
828 } else {
829 mode.to_string()
830 };
831
832 if resolved_mode == "full" {
833 if let Some(out) = try_stub_hit_readonly(cache, path) {
838 return out;
839 }
840 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
841 let out = crate::core::redaction::redact_text_if_enabled(&out);
842 let sent = count_tokens(&out);
843 return ReadOutput {
844 content: out,
845 resolved_mode: "full".into(),
846 output_tokens: sent,
847 };
848 }
849
850 if is_cacheable_mode(&resolved_mode) {
851 let cache_key = compressed_cache_key(
852 &resolved_mode,
853 crp_mode,
854 task,
855 tuning.aggressiveness,
856 tuning.protect,
857 );
858 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
859 if let Some(cached_output) = compressed_hit {
860 cache.record_cache_hit(path);
861 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
862 let sent = count_tokens(&out);
863 return ReadOutput {
864 content: out,
865 resolved_mode,
866 output_tokens: sent,
867 };
868 }
869 }
870
871 if let Some(content) = content_opt {
872 let (out, _) = process_mode_tuned(
873 &content,
874 &resolved_mode,
875 &file_ref,
876 &short,
877 ext,
878 original_tokens,
879 crp_mode,
880 path,
881 task,
882 tuning,
883 );
884 let out = if mode_allows_raw_cap(&resolved_mode) {
890 let framed_tokens = count_tokens(&out);
891 cap_to_raw(out, framed_tokens, &content, original_tokens)
892 } else {
893 out
894 };
895 if is_cacheable_mode(&resolved_mode) {
896 let cache_key = compressed_cache_key(
897 &resolved_mode,
898 crp_mode,
899 task,
900 tuning.aggressiveness,
901 tuning.protect,
902 );
903 cache.set_compressed(path, &cache_key, out.clone());
904 }
905 let out = crate::core::redaction::redact_text_if_enabled(&out);
906 let sent = count_tokens(&out);
907 return ReadOutput {
908 content: out,
909 resolved_mode,
910 output_tokens: sent,
911 };
912 }
913 cache.invalidate(path);
914 }
915
916 let content = if let Some(pr) = preread {
921 pr
922 } else {
923 match read_file_lossy(path) {
924 Ok(c) => c,
925 Err(e) => {
926 let msg = format!("ERROR: {e}");
927 let tokens = count_tokens(&msg);
928 return ReadOutput {
929 content: msg,
930 resolved_mode: "error".into(),
931 output_tokens: tokens,
932 };
933 }
934 }
935 };
936
937 let store_result = cache.store(path, &content);
938
939 let is_line_range = mode.starts_with("lines:");
942 let hints = crate::core::profiles::active_profile().output_hints;
943 let is_repeat_read = store_result.read_count > 1;
944 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
945 find_similar_and_update_semantic_index(path, &content)
946 } else {
947 None
948 };
949 let graph_hint: Option<String> = None;
954
955 if mode == "full" {
956 cache.mark_full_delivered(path);
957 let (mut output, _) = format_full_output(
958 &file_ref,
959 &short,
960 ext,
961 &content,
962 store_result.original_tokens,
963 store_result.line_count,
964 task,
965 );
966 if let Some(hint) = &graph_hint {
967 output.push_str(&format!("\n{hint}"));
968 }
969 if let Some(hint) = similar_hint {
970 output.push_str(&format!("\n{hint}"));
971 }
972 let framed_tokens = count_tokens(&output);
973 let output = cap_to_raw(
974 output,
975 framed_tokens,
976 &content,
977 store_result.original_tokens,
978 );
979 let output = crate::core::redaction::redact_text_if_enabled(&output);
980 let sent = count_tokens(&output);
981 return ReadOutput {
982 content: output,
983 resolved_mode: "full".into(),
984 output_tokens: sent,
985 };
986 }
987
988 let resolved_mode = if mode == "auto" {
989 tuning
990 .auto_density_mode()
991 .unwrap_or_else(|| resolve_auto_mode(None, path, store_result.original_tokens, task))
992 } else {
993 mode.to_string()
994 };
995
996 let (output, _sent) = process_mode_tuned(
997 &content,
998 &resolved_mode,
999 &file_ref,
1000 &short,
1001 ext,
1002 store_result.original_tokens,
1003 crp_mode,
1004 path,
1005 task,
1006 tuning,
1007 );
1008 let mut output = if mode_allows_raw_cap(&resolved_mode) {
1014 let framed_tokens = count_tokens(&output);
1015 cap_to_raw(
1016 output,
1017 framed_tokens,
1018 &content,
1019 store_result.original_tokens,
1020 )
1021 } else {
1022 output
1023 };
1024 if is_cacheable_mode(&resolved_mode) {
1025 let cache_key = compressed_cache_key(
1026 &resolved_mode,
1027 crp_mode,
1028 task,
1029 tuning.aggressiveness,
1030 tuning.protect,
1031 );
1032 cache.set_compressed(path, &cache_key, output.clone());
1033 }
1034 if let Some(hint) = &graph_hint {
1035 output.push_str(&format!("\n{hint}"));
1036 }
1037 if let Some(hint) = similar_hint {
1038 output.push_str(&format!("\n{hint}"));
1039 }
1040 let output = crate::core::redaction::redact_text_if_enabled(&output);
1041 let final_tokens = count_tokens(&output);
1042 ReadOutput {
1043 content: output,
1044 resolved_mode,
1045 output_tokens: final_tokens,
1046 }
1047}
1048
1049pub fn is_instruction_file(path: &str) -> bool {
1050 let lower = path.to_lowercase();
1051 let filename = std::path::Path::new(&lower)
1052 .file_name()
1053 .and_then(|f| f.to_str())
1054 .unwrap_or("");
1055
1056 matches!(
1057 filename,
1058 "skill.md"
1059 | "agents.md"
1060 | "rules.md"
1061 | ".cursorrules"
1062 | ".clinerules"
1063 | "lean-ctx.md"
1064 | "lean-ctx.mdc"
1065 ) || lower.contains("/skills/")
1066 || lower.contains("/.cursor/rules/")
1067 || lower.contains("/.claude/rules/")
1068 || lower.contains("/agents.md")
1069}
1070
1071fn cap_to_raw(
1086 framed: String,
1087 framed_tokens: usize,
1088 raw_content: &str,
1089 raw_tokens: usize,
1090) -> String {
1091 if raw_tokens > 0 && framed_tokens > raw_tokens {
1092 raw_content.to_string()
1093 } else {
1094 framed
1095 }
1096}
1097
1098fn resolve_auto_mode(
1107 cache: Option<&SessionCache>,
1108 file_path: &str,
1109 original_tokens: usize,
1110 task: Option<&str>,
1111) -> String {
1112 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
1113 path: file_path,
1114 token_count: original_tokens,
1115 task,
1116 cache,
1117 };
1118 crate::core::auto_mode_resolver::resolve(&ctx).mode
1119}
1120
1121fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
1122 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
1123
1124 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
1125 return None;
1126 }
1127
1128 let cfg = crate::core::config::Config::load();
1129 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1130 if !profile.semantic_cache_enabled() {
1131 return None;
1132 }
1133
1134 let project_root = detect_project_root(path);
1135 let session_id = format!("{}", std::process::id());
1136 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
1137
1138 let similar = index.find_similar(content, 0.7);
1139 let relevant: Vec<_> = similar
1140 .into_iter()
1141 .filter(|(p, _)| p != path)
1142 .take(3)
1143 .collect();
1144
1145 index.add_file(path, content, &session_id);
1146 if let Err(e) = index.save(&project_root) {
1147 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
1148 }
1149
1150 if relevant.is_empty() {
1151 return None;
1152 }
1153
1154 let hints: Vec<String> = relevant
1155 .iter()
1156 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
1157 .collect();
1158
1159 Some(format!(
1160 "[semantic: {} similar file(s) in cache]\n{}",
1161 relevant.len(),
1162 hints.join("\n")
1163 ))
1164}
1165
1166fn detect_project_root(path: &str) -> String {
1167 crate::core::protocol::detect_project_root_or_cwd(path)
1168}
1169
1170pub fn graph_related_hint(path: &str) -> Option<String> {
1173 let project_root = detect_project_root(path);
1174 crate::core::graph_context::build_related_hint(path, &project_root, 5)
1175}
1176
1177const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1178
1179fn handle_full_with_auto_delta(
1181 cache: &mut SessionCache,
1182 path: &str,
1183 file_ref: &str,
1184 short: &str,
1185 ext: &str,
1186 task: Option<&str>,
1187) -> (String, usize) {
1188 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1189 let Ok(disk_content) = read_file_lossy(path) else {
1190 cache.record_cache_hit(path);
1191 if let Some(existing) = cache.get(path) {
1192 if !crate::core::protocol::meta_visible()
1193 && let Some(cached) = existing.content()
1194 {
1195 return format_full_output(
1196 file_ref,
1197 short,
1198 ext,
1199 &cached,
1200 existing.original_tokens,
1201 existing.line_count,
1202 task,
1203 );
1204 }
1205 let out = format!(
1206 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1207 existing.read_count(),
1208 existing.line_count
1209 );
1210 let sent = count_tokens(&out);
1211 return (out, sent);
1212 }
1213 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1214 format!("[file read failed and no cached version available] {file_ref}={short}")
1215 } else {
1216 format!("[file read failed and no cached version available] {short}")
1217 };
1218 let sent = count_tokens(&out);
1219 return (out, sent);
1220 };
1221
1222 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1223 let prof = crate::core::profiles::active_profile();
1224 let force_full = no_deg
1225 || (prof.read.default_mode_effective() == "full"
1226 && prof.compression.crp_mode_effective() == "off");
1227
1228 let old_content = cache
1229 .get(path)
1230 .and_then(crate::core::cache::CacheEntry::content)
1231 .unwrap_or_default();
1232 let store_result = cache.store(path, &disk_content);
1233
1234 if store_result.was_hit {
1235 let policy_allows_stub =
1236 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1237 if policy_allows_stub && store_result.full_content_delivered {
1238 let out = if crate::core::protocol::meta_visible() {
1239 format!(
1240 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1241 store_result.line_count
1242 )
1243 } else {
1244 format!(
1248 "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1249 store_result.line_count
1250 )
1251 };
1252 let sent = count_tokens(&out);
1253 return (out, sent);
1254 }
1255 cache.mark_full_delivered(path);
1256 return format_full_output(
1257 file_ref,
1258 short,
1259 ext,
1260 &disk_content,
1261 store_result.original_tokens,
1262 store_result.line_count,
1263 task,
1264 );
1265 }
1266
1267 let diff = compressor::diff_content(&old_content, &disk_content);
1268 let diff_tokens = count_tokens(&diff);
1269 let full_tokens = store_result.original_tokens;
1270
1271 if !force_full
1272 && full_tokens > 0
1273 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1274 {
1275 let savings = protocol::format_savings(full_tokens, diff_tokens);
1276 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1277 format!("{file_ref}={short}")
1278 } else {
1279 short.to_string()
1280 };
1281 let out = format!(
1282 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1283 disk_content.lines().count()
1284 );
1285 return (out, diff_tokens);
1286 }
1287
1288 format_full_output(
1289 file_ref,
1290 short,
1291 ext,
1292 &disk_content,
1293 store_result.original_tokens,
1294 store_result.line_count,
1295 task,
1296 )
1297}
1298
1299fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1300 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1301 let short = protocol::shorten_path(path);
1302 let old_content = cache
1303 .get(path)
1304 .and_then(crate::core::cache::CacheEntry::content);
1305
1306 let new_content = match read_file_lossy(path) {
1307 Ok(c) => c,
1308 Err(e) => {
1309 let msg = format!("ERROR: {e}");
1310 let tokens = count_tokens(&msg);
1311 return (msg, tokens);
1312 }
1313 };
1314
1315 let original_tokens = count_tokens(&new_content);
1316
1317 let diff_output = if let Some(old) = &old_content {
1318 compressor::diff_content(old, &new_content)
1319 } else {
1320 cache.store(path, &new_content);
1323 let msg = format!(
1324 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1325 );
1326 let sent = count_tokens(&msg);
1327 return (msg, sent);
1328 };
1329
1330 cache.store(path, &new_content);
1331
1332 let sent = count_tokens(&diff_output);
1333 let savings = protocol::format_savings(original_tokens, sent);
1334 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1335 format!("{file_ref}={short}")
1336 } else {
1337 short
1338 };
1339 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1340}