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