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
34const COMPRESSED_HINT: &str = "[lean-ctx: compact view — nothing lost, full source on request]";
35
36fn is_cacheable_mode(mode: &str) -> bool {
40 mode.parse::<ReadMode>()
41 .is_ok_and(|m| m.is_compressed_cacheable())
42}
43
44fn mode_allows_raw_cap(mode: &str) -> bool {
54 mode.parse::<ReadMode>()
57 .map_or(true, |m| m.allows_raw_cap())
58}
59
60fn compressed_cache_key(
61 mode: &str,
62 crp_mode: CrpMode,
63 task: Option<&str>,
64 aggressiveness: Option<f64>,
65 protect: &[String],
66) -> String {
67 let versioned_mode = match mode {
70 "map" => "map:v2",
71 "signatures" => "signatures:v2",
72 _ => mode,
73 };
74 let base = if crp_mode.is_tdd() {
75 format!("{versioned_mode}:tdd")
76 } else {
77 versioned_mode.to_string()
78 };
79 let keyed = match task.map(str::trim).filter(|t| !t.is_empty()) {
82 Some(t) => {
83 use std::hash::{Hash, Hasher};
84 let mut h = std::collections::hash_map::DefaultHasher::new();
85 t.hash(&mut h);
86 format!("{base}:t{:x}", h.finish())
87 }
88 None => base,
89 };
90 let mut key = keyed;
94 let aggr_frag = crate::core::aggressiveness::cache_fragment(aggressiveness);
95 if !aggr_frag.is_empty() {
96 key = format!("{key}:{aggr_frag}");
97 }
98 let protect_frag = crate::core::protect::protect_fragment(protect);
99 if !protect_frag.is_empty() {
100 key = format!("{key}:{protect_frag}");
101 }
102 key
103}
104
105fn append_compressed_hint(output: &str, file_path: &str) -> String {
106 if !crate::core::profiles::active_profile()
107 .output_hints
108 .compressed_hint()
109 {
110 return output.to_string();
111 }
112 format!(
113 "{output}\n{COMPRESSED_HINT}\n full: ctx_read(\"{file_path}\", mode=\"full\") · exact bytes: ctx_read(\"{file_path}\", raw=true) · recover: ctx_retrieve(\"{file_path}\")"
114 )
115}
116
117pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
121 if crate::core::binary_detect::is_binary_file(path) {
122 let msg = crate::core::binary_detect::binary_file_message(path);
123 return Err(std::io::Error::other(msg));
124 }
125
126 {
127 let canonical =
128 crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
129 if let Ok(cwd) = std::env::current_dir() {
130 let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
131 if !canonical.starts_with(&root) {
132 let allow = crate::core::pathjail::allow_paths_from_env_and_config();
133 let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
134 .is_ok_and(|d| canonical.starts_with(d));
135 let tmp_ok = canonical.starts_with(std::env::temp_dir());
136 if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
137 tracing::warn!(
138 "defense-in-depth: path may escape project root: {}",
139 canonical.display()
140 );
141 }
142 }
143 }
144 }
145
146 let cap = crate::core::limits::max_read_bytes();
147
148 let file = open_with_retry(path)?;
149 let meta = file
150 .metadata()
151 .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
152 if meta.len() > cap as u64 {
153 return Err(std::io::Error::other(format!(
154 "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
155 Increase the limit or use a line-range read: mode=\"lines:1-100\"",
156 meta.len(),
157 cap
158 )));
159 }
160
161 use std::io::Read;
162 let mut bytes = Vec::with_capacity(meta.len() as usize);
163 std::io::BufReader::new(file).read_to_end(&mut bytes)?;
164 match String::from_utf8(bytes) {
165 Ok(s) => Ok(s),
166 Err(e) => Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()),
167 }
168}
169
170fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
174 match open_nofollow(path) {
175 Ok(f) => Ok(f),
176 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
177 std::thread::sleep(std::time::Duration::from_millis(50));
178 open_nofollow(path).map_err(|e| {
179 if e.kind() == std::io::ErrorKind::NotFound {
180 std::io::Error::other(format!(
181 "file not found: {path} — verify the path with ctx_tree or ctx_search"
182 ))
183 } else {
184 e
185 }
186 })
187 }
188 Err(e) => Err(e),
189 }
190}
191
192#[cfg(unix)]
193fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
194 use std::os::unix::fs::OpenOptionsExt;
195 use std::path::Path;
196
197 let p = Path::new(path);
198 if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
203 && parent.exists()
204 {
205 let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
206 let canonical_path = canonical_parent.join(filename);
207 return std::fs::OpenOptions::new()
208 .read(true)
209 .custom_flags(libc::O_NOFOLLOW)
210 .open(&canonical_path);
211 }
212
213 std::fs::OpenOptions::new()
215 .read(true)
216 .custom_flags(libc::O_NOFOLLOW)
217 .open(path)
218}
219
220#[cfg(not(unix))]
221fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
222 std::fs::File::open(path)
223}
224
225pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
227 handle_with_options(cache, path, mode, false, crp_mode, None)
228}
229
230pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
232 handle_with_options(cache, path, mode, true, crp_mode, None)
233}
234
235pub fn handle_with_task(
237 cache: &mut SessionCache,
238 path: &str,
239 mode: &str,
240 crp_mode: CrpMode,
241 task: Option<&str>,
242) -> String {
243 handle_with_options(cache, path, mode, false, crp_mode, task)
244}
245
246pub fn handle_with_task_resolved(
248 cache: &mut SessionCache,
249 path: &str,
250 mode: &str,
251 crp_mode: CrpMode,
252 task: Option<&str>,
253) -> ReadOutput {
254 handle_with_options_resolved(
255 cache,
256 path,
257 mode,
258 false,
259 crp_mode,
260 task,
261 ReadTuning::resolve(None, &[]),
262 )
263}
264
265pub fn handle_with_task_resolved_tuned(
269 cache: &mut SessionCache,
270 path: &str,
271 mode: &str,
272 crp_mode: CrpMode,
273 task: Option<&str>,
274 aggressiveness: Option<f64>,
275 protect: &[String],
276) -> ReadOutput {
277 handle_with_options_resolved(
278 cache,
279 path,
280 mode,
281 false,
282 crp_mode,
283 task,
284 ReadTuning::resolve(aggressiveness, protect),
285 )
286}
287
288pub fn handle_fresh_with_task(
290 cache: &mut SessionCache,
291 path: &str,
292 mode: &str,
293 crp_mode: CrpMode,
294 task: Option<&str>,
295) -> String {
296 handle_with_options(cache, path, mode, true, crp_mode, task)
297}
298
299pub fn handle_fresh_with_task_resolved(
301 cache: &mut SessionCache,
302 path: &str,
303 mode: &str,
304 crp_mode: CrpMode,
305 task: Option<&str>,
306) -> ReadOutput {
307 handle_with_options_resolved(
308 cache,
309 path,
310 mode,
311 true,
312 crp_mode,
313 task,
314 ReadTuning::resolve(None, &[]),
315 )
316}
317
318pub fn handle_fresh_with_task_resolved_tuned(
320 cache: &mut SessionCache,
321 path: &str,
322 mode: &str,
323 crp_mode: CrpMode,
324 task: Option<&str>,
325 aggressiveness: Option<f64>,
326 protect: &[String],
327) -> ReadOutput {
328 handle_with_options_resolved(
329 cache,
330 path,
331 mode,
332 true,
333 crp_mode,
334 task,
335 ReadTuning::resolve(aggressiveness, protect),
336 )
337}
338
339fn handle_with_options(
340 cache: &mut SessionCache,
341 path: &str,
342 mode: &str,
343 fresh: bool,
344 crp_mode: CrpMode,
345 task: Option<&str>,
346) -> String {
347 handle_with_options_resolved(
348 cache,
349 path,
350 mode,
351 fresh,
352 crp_mode,
353 task,
354 ReadTuning::resolve(None, &[]),
355 )
356 .content
357}
358
359fn force_fresh_env() -> bool {
362 static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
363 *FORCE_FRESH.get_or_init(|| {
364 std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
365 })
366}
367
368fn is_subagent_context() -> bool {
378 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
379 *IS_SUBAGENT.get_or_init(|| std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty()))
380}
381
382fn handle_with_options_resolved(
383 cache: &mut SessionCache,
384 path: &str,
385 mode: &str,
386 fresh: bool,
387 crp_mode: CrpMode,
388 task: Option<&str>,
389 tuning: ReadTuning<'_>,
390) -> ReadOutput {
391 let effective_fresh = fresh
397 || force_fresh_env()
398 || (is_subagent_context() && !crate::core::conversation::scope_enabled());
399
400 if PluginManager::has_listener("pre_read") {
403 PluginManager::fire_hook_background(HookPoint::PreRead {
404 path: path.to_string(),
405 });
406 }
407
408 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
409 bt.next_seq();
410 }
411 let mut result =
412 handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task, tuning);
413
414 if let Some(entry) = cache.get_mut(path) {
415 entry.last_mode.clone_from(&result.resolved_mode);
416 }
417
418 let dedup_allowed = result
420 .resolved_mode
421 .parse::<ReadMode>()
422 .is_ok_and(|m| m.is_lossy_summary());
423 if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
424 let new_tokens = count_tokens(&deduped);
425 if new_tokens < result.output_tokens {
426 result.content = deduped;
427 result.output_tokens = new_tokens;
428 }
429 }
430
431 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
432 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
433 bt.record_read(
434 path,
435 &result.resolved_mode,
436 result.output_tokens,
437 original_tokens,
438 );
439
440 let compressed = result
450 .resolved_mode
451 .parse::<ReadMode>()
452 .map_or(true, |m| m.counts_as_compressed());
453 if compressed {
454 crate::core::adaptive_thresholds::record_quality_signal(
455 path,
456 crate::core::threshold_learning::QualitySignal::CleanCompressed,
457 );
458 } else if result.resolved_mode == "full"
459 && result.output_tokens > 2000
460 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
461 {
462 crate::core::adaptive_thresholds::record_quality_signal(
463 path,
464 crate::core::threshold_learning::QualitySignal::WastedFull,
465 );
466 }
467 }
468
469 if PluginManager::has_listener("post_compress") {
471 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
472 PluginManager::fire_hook_background(HookPoint::PostCompress {
473 path: path.to_string(),
474 original_tokens,
475 compressed_tokens: result.output_tokens,
476 });
477 }
478
479 {
486 let self_agent = crate::core::scent_field::scent_agent_id();
487 let scent_path = crate::core::pathutil::normalize_tool_path(path);
488 std::thread::spawn(move || {
489 crate::core::scent_field::deposit(
490 self_agent,
491 crate::core::scent_field::ScentKind::Hot,
492 &scent_path,
493 0.3,
494 );
495 });
496 }
497
498 result
499}
500
501pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
512 let current_conversation = crate::core::conversation::current_conversation_id();
513 try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
514}
515
516fn try_stub_hit_readonly_scoped(
520 cache: &SessionCache,
521 path: &str,
522 current_conversation: Option<&str>,
523) -> Option<ReadOutput> {
524 let no_deg = crate::core::config::Config::load().no_degrade_effective();
525 let prof = crate::core::profiles::active_profile();
526 let force_full = no_deg
527 || (prof.read.default_mode_effective() == "full"
528 && prof.compression.crp_mode_effective() == "off");
529 let policy_allows_stub =
530 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
531 if !policy_allows_stub {
532 return None;
533 }
534
535 if let Some(file_ref) = cache.get_file_ref_readonly(path) {
537 let (cached_mtime, cached_hash, line_count, delivered_conv) = {
538 let entry = cache.get(path)?;
539 (
540 entry.stored_mtime,
541 entry.hash.clone(),
542 entry.line_count,
543 entry.delivered_conversation.clone(),
544 )
545 };
546 if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
547 || !cache.is_full_delivered(path)
548 {
549 return None;
550 }
551 if !crate::core::conversation::conversation_allows_stub(
557 current_conversation,
558 delivered_conv.as_deref(),
559 ) {
560 crate::core::cache_telemetry::record_conversation_mismatch();
561 return None;
562 }
563 cache.record_cache_hit(path);
564 return Some(render_unchanged_stub(&file_ref, path, line_count));
565 }
566
567 let rec = crate::core::read_stub_index::lookup(path)?;
573 if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
574 return None;
575 }
576 if !crate::core::conversation::conversation_allows_cold_stub(
577 current_conversation,
578 rec.delivered_conversation.as_deref(),
579 ) {
580 crate::core::cache_telemetry::record_conversation_mismatch();
581 return None;
582 }
583 Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
584}
585
586fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
594 let short = protocol::shorten_path(path);
595 let out = if crate::core::protocol::meta_visible() {
596 format!(
597 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
598 )
599 } else {
600 format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
601 };
602 let out = crate::core::redaction::redact_text_if_enabled(&out);
603 let sent = count_tokens(&out);
604 ReadOutput {
605 content: out,
606 resolved_mode: "full".into(),
607 output_tokens: sent,
608 }
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
614pub struct DeltaExplicitDecision {
615 pub mode: String,
618 pub note: Option<String>,
622}
623
624pub fn resolve_explicit_delta_mode(
646 cache: &SessionCache,
647 path: &str,
648 mode: &str,
649 explicit_mode: bool,
650 fresh: bool,
651 enabled: bool,
652) -> DeltaExplicitDecision {
653 let unchanged = DeltaExplicitDecision {
654 mode: mode.to_string(),
655 note: None,
656 };
657 if fresh || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
658 return unchanged;
659 }
660 let Some(entry) = cache.get(path) else {
661 return unchanged;
663 };
664 let stale =
665 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
666 if stale {
667 if entry.content().is_some() {
671 return DeltaExplicitDecision {
672 mode: "diff".to_string(),
673 note: Some(format!(
674 "[delta-explicit] requested mode={mode} served as a diff: the file \
675 changed since your last read and the diff is the new information. \
676 Pass fresh=true if you need the full content re-emitted."
677 )),
678 };
679 }
680 return unchanged;
681 }
682 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
686 return DeltaExplicitDecision {
687 mode: "full".to_string(),
688 note: None,
689 };
690 }
691 unchanged
692}
693
694fn handle_with_options_inner(
695 cache: &mut SessionCache,
696 path: &str,
697 mode: &str,
698 fresh: bool,
699 crp_mode: CrpMode,
700 task: Option<&str>,
701 tuning: ReadTuning<'_>,
702) -> ReadOutput {
703 let file_ref = cache.get_file_ref(path);
704 let short = protocol::shorten_path(path);
705 let ext = Path::new(path)
706 .extension()
707 .and_then(|e| e.to_str())
708 .unwrap_or("");
709
710 if fresh {
711 if mode == "diff" {
712 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
713 return ReadOutput {
714 content: warning.to_string(),
715 resolved_mode: "diff".into(),
716 output_tokens: count_tokens(warning),
717 };
718 }
719 cache.invalidate(path);
720 }
721
722 if mode == "diff" {
723 let (out, _) = handle_diff(cache, path, &file_ref);
724 let out = crate::core::redaction::redact_text_if_enabled(&out);
725 let sent = count_tokens(&out);
726 return ReadOutput {
727 content: out,
728 resolved_mode: "diff".into(),
729 output_tokens: sent,
730 };
731 }
732
733 if mode != "full"
734 && let Some(existing) = cache.get(path)
735 {
736 let stale = crate::core::cache::is_cache_entry_stale_verified(
737 path,
738 existing.stored_mtime,
739 &existing.hash,
740 );
741 if stale {
742 cache.invalidate(path);
743 }
744 }
745
746 let cache_snapshot = cache
749 .get(path)
750 .map(|existing| (existing.original_tokens, existing.content()));
751
752 if let Some((original_tokens, content_opt)) = cache_snapshot {
753 let resolved_mode = if mode == "auto" {
764 tuning
765 .auto_density_mode()
766 .unwrap_or_else(|| resolve_auto_mode(Some(cache), path, original_tokens, task))
767 } else {
768 mode.to_string()
769 };
770
771 if resolved_mode == "full" {
772 if let Some(out) = try_stub_hit_readonly(cache, path) {
777 return out;
778 }
779 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
780 let out = crate::core::redaction::redact_text_if_enabled(&out);
781 let sent = count_tokens(&out);
782 return ReadOutput {
783 content: out,
784 resolved_mode: "full".into(),
785 output_tokens: sent,
786 };
787 }
788
789 if is_cacheable_mode(&resolved_mode) {
790 let cache_key = compressed_cache_key(
791 &resolved_mode,
792 crp_mode,
793 task,
794 tuning.aggressiveness,
795 tuning.protect,
796 );
797 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
798 if let Some(cached_output) = compressed_hit {
799 cache.record_cache_hit(path);
800 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
801 let sent = count_tokens(&out);
802 return ReadOutput {
803 content: out,
804 resolved_mode,
805 output_tokens: sent,
806 };
807 }
808 }
809
810 if let Some(content) = content_opt {
811 let (out, _) = process_mode_tuned(
812 &content,
813 &resolved_mode,
814 &file_ref,
815 &short,
816 ext,
817 original_tokens,
818 crp_mode,
819 path,
820 task,
821 tuning,
822 );
823 let out = if mode_allows_raw_cap(&resolved_mode) {
829 let framed_tokens = count_tokens(&out);
830 cap_to_raw(out, framed_tokens, &content, original_tokens)
831 } else {
832 out
833 };
834 if is_cacheable_mode(&resolved_mode) {
835 let cache_key = compressed_cache_key(
836 &resolved_mode,
837 crp_mode,
838 task,
839 tuning.aggressiveness,
840 tuning.protect,
841 );
842 cache.set_compressed(path, &cache_key, out.clone());
843 }
844 let out = crate::core::redaction::redact_text_if_enabled(&out);
845 let sent = count_tokens(&out);
846 return ReadOutput {
847 content: out,
848 resolved_mode,
849 output_tokens: sent,
850 };
851 }
852 cache.invalidate(path);
853 }
854
855 let content = match read_file_lossy(path) {
856 Ok(c) => c,
857 Err(e) => {
858 let msg = format!("ERROR: {e}");
859 let tokens = count_tokens(&msg);
860 return ReadOutput {
861 content: msg,
862 resolved_mode: "error".into(),
863 output_tokens: tokens,
864 };
865 }
866 };
867
868 let store_result = cache.store(path, &content);
869
870 let is_line_range = mode.starts_with("lines:");
873 let hints = crate::core::profiles::active_profile().output_hints;
874 let is_repeat_read = store_result.read_count > 1;
875 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
876 find_similar_and_update_semantic_index(path, &content)
877 } else {
878 None
879 };
880 let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
881 build_graph_related_hint(path)
882 } else {
883 None
884 };
885
886 if mode == "full" {
887 cache.mark_full_delivered(path);
888 let (mut output, _) = format_full_output(
889 &file_ref,
890 &short,
891 ext,
892 &content,
893 store_result.original_tokens,
894 store_result.line_count,
895 task,
896 );
897 if let Some(hint) = &graph_hint {
898 output.push_str(&format!("\n{hint}"));
899 }
900 if let Some(hint) = similar_hint {
901 output.push_str(&format!("\n{hint}"));
902 }
903 let framed_tokens = count_tokens(&output);
904 let output = cap_to_raw(
905 output,
906 framed_tokens,
907 &content,
908 store_result.original_tokens,
909 );
910 let output = crate::core::redaction::redact_text_if_enabled(&output);
911 let sent = count_tokens(&output);
912 return ReadOutput {
913 content: output,
914 resolved_mode: "full".into(),
915 output_tokens: sent,
916 };
917 }
918
919 let resolved_mode = if mode == "auto" {
920 tuning
921 .auto_density_mode()
922 .unwrap_or_else(|| resolve_auto_mode(None, path, store_result.original_tokens, task))
923 } else {
924 mode.to_string()
925 };
926
927 let (output, _sent) = process_mode_tuned(
928 &content,
929 &resolved_mode,
930 &file_ref,
931 &short,
932 ext,
933 store_result.original_tokens,
934 crp_mode,
935 path,
936 task,
937 tuning,
938 );
939 let mut output = if mode_allows_raw_cap(&resolved_mode) {
945 let framed_tokens = count_tokens(&output);
946 cap_to_raw(
947 output,
948 framed_tokens,
949 &content,
950 store_result.original_tokens,
951 )
952 } else {
953 output
954 };
955 if is_cacheable_mode(&resolved_mode) {
956 let cache_key = compressed_cache_key(
957 &resolved_mode,
958 crp_mode,
959 task,
960 tuning.aggressiveness,
961 tuning.protect,
962 );
963 cache.set_compressed(path, &cache_key, output.clone());
964 }
965 if let Some(hint) = &graph_hint {
966 output.push_str(&format!("\n{hint}"));
967 }
968 if let Some(hint) = similar_hint {
969 output.push_str(&format!("\n{hint}"));
970 }
971 let output = crate::core::redaction::redact_text_if_enabled(&output);
972 let final_tokens = count_tokens(&output);
973 ReadOutput {
974 content: output,
975 resolved_mode,
976 output_tokens: final_tokens,
977 }
978}
979
980pub fn is_instruction_file(path: &str) -> bool {
981 let lower = path.to_lowercase();
982 let filename = std::path::Path::new(&lower)
983 .file_name()
984 .and_then(|f| f.to_str())
985 .unwrap_or("");
986
987 matches!(
988 filename,
989 "skill.md"
990 | "agents.md"
991 | "rules.md"
992 | ".cursorrules"
993 | ".clinerules"
994 | "lean-ctx.md"
995 | "lean-ctx.mdc"
996 ) || lower.contains("/skills/")
997 || lower.contains("/.cursor/rules/")
998 || lower.contains("/.claude/rules/")
999 || lower.contains("/agents.md")
1000}
1001
1002fn cap_to_raw(
1017 framed: String,
1018 framed_tokens: usize,
1019 raw_content: &str,
1020 raw_tokens: usize,
1021) -> String {
1022 if raw_tokens > 0 && framed_tokens > raw_tokens {
1023 raw_content.to_string()
1024 } else {
1025 framed
1026 }
1027}
1028
1029fn resolve_auto_mode(
1038 cache: Option<&SessionCache>,
1039 file_path: &str,
1040 original_tokens: usize,
1041 task: Option<&str>,
1042) -> String {
1043 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
1044 path: file_path,
1045 token_count: original_tokens,
1046 task,
1047 cache,
1048 };
1049 crate::core::auto_mode_resolver::resolve(&ctx).mode
1050}
1051
1052fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
1053 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
1054
1055 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
1056 return None;
1057 }
1058
1059 let cfg = crate::core::config::Config::load();
1060 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1061 if !profile.semantic_cache_enabled() {
1062 return None;
1063 }
1064
1065 let project_root = detect_project_root(path);
1066 let session_id = format!("{}", std::process::id());
1067 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
1068
1069 let similar = index.find_similar(content, 0.7);
1070 let relevant: Vec<_> = similar
1071 .into_iter()
1072 .filter(|(p, _)| p != path)
1073 .take(3)
1074 .collect();
1075
1076 index.add_file(path, content, &session_id);
1077 if let Err(e) = index.save(&project_root) {
1078 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
1079 }
1080
1081 if relevant.is_empty() {
1082 return None;
1083 }
1084
1085 let hints: Vec<String> = relevant
1086 .iter()
1087 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
1088 .collect();
1089
1090 Some(format!(
1091 "[semantic: {} similar file(s) in cache]\n{}",
1092 relevant.len(),
1093 hints.join("\n")
1094 ))
1095}
1096
1097fn detect_project_root(path: &str) -> String {
1098 crate::core::protocol::detect_project_root_or_cwd(path)
1099}
1100
1101fn build_graph_related_hint(path: &str) -> Option<String> {
1102 let project_root = detect_project_root(path);
1103 crate::core::graph_context::build_related_hint(path, &project_root, 5)
1104}
1105
1106const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1107
1108fn handle_full_with_auto_delta(
1110 cache: &mut SessionCache,
1111 path: &str,
1112 file_ref: &str,
1113 short: &str,
1114 ext: &str,
1115 task: Option<&str>,
1116) -> (String, usize) {
1117 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1118 let Ok(disk_content) = read_file_lossy(path) else {
1119 cache.record_cache_hit(path);
1120 if let Some(existing) = cache.get(path) {
1121 if !crate::core::protocol::meta_visible()
1122 && let Some(cached) = existing.content()
1123 {
1124 return format_full_output(
1125 file_ref,
1126 short,
1127 ext,
1128 &cached,
1129 existing.original_tokens,
1130 existing.line_count,
1131 task,
1132 );
1133 }
1134 let out = format!(
1135 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1136 existing.read_count(),
1137 existing.line_count
1138 );
1139 let sent = count_tokens(&out);
1140 return (out, sent);
1141 }
1142 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1143 format!("[file read failed and no cached version available] {file_ref}={short}")
1144 } else {
1145 format!("[file read failed and no cached version available] {short}")
1146 };
1147 let sent = count_tokens(&out);
1148 return (out, sent);
1149 };
1150
1151 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1152 let prof = crate::core::profiles::active_profile();
1153 let force_full = no_deg
1154 || (prof.read.default_mode_effective() == "full"
1155 && prof.compression.crp_mode_effective() == "off");
1156
1157 let old_content = cache
1158 .get(path)
1159 .and_then(crate::core::cache::CacheEntry::content)
1160 .unwrap_or_default();
1161 let store_result = cache.store(path, &disk_content);
1162
1163 if store_result.was_hit {
1164 let policy_allows_stub =
1165 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1166 if policy_allows_stub && store_result.full_content_delivered {
1167 let out = if crate::core::protocol::meta_visible() {
1168 format!(
1169 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1170 store_result.line_count
1171 )
1172 } else {
1173 format!(
1177 "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1178 store_result.line_count
1179 )
1180 };
1181 let sent = count_tokens(&out);
1182 return (out, sent);
1183 }
1184 cache.mark_full_delivered(path);
1185 return format_full_output(
1186 file_ref,
1187 short,
1188 ext,
1189 &disk_content,
1190 store_result.original_tokens,
1191 store_result.line_count,
1192 task,
1193 );
1194 }
1195
1196 let diff = compressor::diff_content(&old_content, &disk_content);
1197 let diff_tokens = count_tokens(&diff);
1198 let full_tokens = store_result.original_tokens;
1199
1200 if !force_full
1201 && full_tokens > 0
1202 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1203 {
1204 let savings = protocol::format_savings(full_tokens, diff_tokens);
1205 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1206 format!("{file_ref}={short}")
1207 } else {
1208 short.to_string()
1209 };
1210 let out = format!(
1211 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1212 disk_content.lines().count()
1213 );
1214 return (out, diff_tokens);
1215 }
1216
1217 format_full_output(
1218 file_ref,
1219 short,
1220 ext,
1221 &disk_content,
1222 store_result.original_tokens,
1223 store_result.line_count,
1224 task,
1225 )
1226}
1227
1228fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1229 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1230 let short = protocol::shorten_path(path);
1231 let old_content = cache
1232 .get(path)
1233 .and_then(crate::core::cache::CacheEntry::content);
1234
1235 let new_content = match read_file_lossy(path) {
1236 Ok(c) => c,
1237 Err(e) => {
1238 let msg = format!("ERROR: {e}");
1239 let tokens = count_tokens(&msg);
1240 return (msg, tokens);
1241 }
1242 };
1243
1244 let original_tokens = count_tokens(&new_content);
1245
1246 let diff_output = if let Some(old) = &old_content {
1247 compressor::diff_content(old, &new_content)
1248 } else {
1249 cache.store(path, &new_content);
1252 let msg = format!(
1253 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1254 );
1255 let sent = count_tokens(&msg);
1256 return (msg, sent);
1257 };
1258
1259 cache.store(path, &new_content);
1260
1261 let sent = count_tokens(&diff_output);
1262 let savings = protocol::format_savings(original_tokens, sent);
1263 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1264 format!("{file_ref}={short}")
1265 } else {
1266 short
1267 };
1268 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1269}