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_fresh();
516 try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
517}
518
519fn try_stub_hit_readonly_scoped(
523 cache: &SessionCache,
524 path: &str,
525 current_conversation: Option<&str>,
526) -> Option<ReadOutput> {
527 let no_deg = crate::core::config::Config::load().no_degrade_effective();
528 let prof = crate::core::profiles::active_profile();
529 let force_full = no_deg
530 || (prof.read.default_mode_effective() == "full"
531 && prof.compression.crp_mode_effective() == "off");
532 let policy_allows_stub =
533 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
534 if !policy_allows_stub {
535 return None;
536 }
537
538 if let Some(file_ref) = cache.get_file_ref_readonly(path) {
540 let (cached_mtime, cached_hash, line_count, delivered_conv) = {
541 let entry = cache.get(path)?;
542 (
543 entry.stored_mtime,
544 entry.hash.clone(),
545 entry.line_count,
546 entry.delivered_conversation.clone(),
547 )
548 };
549 if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
550 || !cache.is_full_delivered(path)
551 {
552 return None;
553 }
554 if !crate::core::conversation::conversation_allows_stub(
560 current_conversation,
561 delivered_conv.as_deref(),
562 ) {
563 crate::core::cache_telemetry::record_conversation_mismatch();
564 return None;
565 }
566 cache.record_cache_hit(path);
567 return Some(render_unchanged_stub(&file_ref, path, line_count));
568 }
569
570 let rec = crate::core::read_stub_index::lookup(path)?;
576 if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
577 return None;
578 }
579 if !crate::core::conversation::conversation_allows_cold_stub(
580 current_conversation,
581 rec.delivered_conversation.as_deref(),
582 ) {
583 crate::core::cache_telemetry::record_conversation_mismatch();
584 return None;
585 }
586 Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
587}
588
589fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
597 let short = protocol::shorten_path(path);
598 let out = if crate::core::protocol::meta_visible() {
599 format!(
600 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
601 )
602 } else {
603 format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
604 };
605 let out = crate::core::redaction::redact_text_if_enabled(&out);
606 let sent = count_tokens(&out);
607 ReadOutput {
608 content: out,
609 resolved_mode: "full".into(),
610 output_tokens: sent,
611 }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct DeltaExplicitDecision {
618 pub mode: String,
621 pub note: Option<String>,
625}
626
627pub fn resolve_explicit_delta_mode(
649 cache: &SessionCache,
650 path: &str,
651 mode: &str,
652 explicit_mode: bool,
653 fresh: bool,
654 enabled: bool,
655) -> DeltaExplicitDecision {
656 let unchanged = DeltaExplicitDecision {
657 mode: mode.to_string(),
658 note: None,
659 };
660 if fresh || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
661 return unchanged;
662 }
663 let Some(entry) = cache.get(path) else {
664 return unchanged;
666 };
667 let stale =
668 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
669 if stale {
670 if entry.content().is_some() {
674 return DeltaExplicitDecision {
675 mode: "diff".to_string(),
676 note: Some(format!(
677 "[delta-explicit] requested mode={mode} served as a diff: the file \
678 changed since your last read and the diff is the new information. \
679 Pass fresh=true if you need the full content re-emitted."
680 )),
681 };
682 }
683 return unchanged;
684 }
685 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
689 return DeltaExplicitDecision {
690 mode: "full".to_string(),
691 note: None,
692 };
693 }
694 unchanged
695}
696
697fn handle_with_options_inner(
698 cache: &mut SessionCache,
699 path: &str,
700 mode: &str,
701 fresh: bool,
702 crp_mode: CrpMode,
703 task: Option<&str>,
704 tuning: ReadTuning<'_>,
705) -> ReadOutput {
706 let file_ref = cache.get_file_ref(path);
707 let short = protocol::shorten_path(path);
708 let ext = Path::new(path)
709 .extension()
710 .and_then(|e| e.to_str())
711 .unwrap_or("");
712
713 let mode = if mode != "raw"
721 && !mode.starts_with("lines:")
722 && crate::core::config::Config::load()
723 .proxy
724 .is_path_compress_protected(path)
725 {
726 "full"
727 } else {
728 mode
729 };
730
731 if fresh {
732 if mode == "diff" {
733 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
734 return ReadOutput {
735 content: warning.to_string(),
736 resolved_mode: "diff".into(),
737 output_tokens: count_tokens(warning),
738 };
739 }
740 cache.invalidate(path);
741 }
742
743 if mode == "diff" {
744 let (out, _) = handle_diff(cache, path, &file_ref);
745 let out = crate::core::redaction::redact_text_if_enabled(&out);
746 let sent = count_tokens(&out);
747 return ReadOutput {
748 content: out,
749 resolved_mode: "diff".into(),
750 output_tokens: sent,
751 };
752 }
753
754 if mode != "full"
755 && let Some(existing) = cache.get(path)
756 {
757 let stale = crate::core::cache::is_cache_entry_stale_verified(
758 path,
759 existing.stored_mtime,
760 &existing.hash,
761 );
762 if stale {
763 cache.invalidate(path);
764 }
765 }
766
767 let cache_snapshot = cache
770 .get(path)
771 .map(|existing| (existing.original_tokens, existing.content()));
772
773 if let Some((original_tokens, content_opt)) = cache_snapshot {
774 let resolved_mode = if mode == "auto" {
785 tuning
786 .auto_density_mode()
787 .unwrap_or_else(|| resolve_auto_mode(Some(cache), path, original_tokens, task))
788 } else {
789 mode.to_string()
790 };
791
792 if resolved_mode == "full" {
793 if let Some(out) = try_stub_hit_readonly(cache, path) {
798 return out;
799 }
800 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
801 let out = crate::core::redaction::redact_text_if_enabled(&out);
802 let sent = count_tokens(&out);
803 return ReadOutput {
804 content: out,
805 resolved_mode: "full".into(),
806 output_tokens: sent,
807 };
808 }
809
810 if is_cacheable_mode(&resolved_mode) {
811 let cache_key = compressed_cache_key(
812 &resolved_mode,
813 crp_mode,
814 task,
815 tuning.aggressiveness,
816 tuning.protect,
817 );
818 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
819 if let Some(cached_output) = compressed_hit {
820 cache.record_cache_hit(path);
821 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
822 let sent = count_tokens(&out);
823 return ReadOutput {
824 content: out,
825 resolved_mode,
826 output_tokens: sent,
827 };
828 }
829 }
830
831 if let Some(content) = content_opt {
832 let (out, _) = process_mode_tuned(
833 &content,
834 &resolved_mode,
835 &file_ref,
836 &short,
837 ext,
838 original_tokens,
839 crp_mode,
840 path,
841 task,
842 tuning,
843 );
844 let out = if mode_allows_raw_cap(&resolved_mode) {
850 let framed_tokens = count_tokens(&out);
851 cap_to_raw(out, framed_tokens, &content, original_tokens)
852 } else {
853 out
854 };
855 if is_cacheable_mode(&resolved_mode) {
856 let cache_key = compressed_cache_key(
857 &resolved_mode,
858 crp_mode,
859 task,
860 tuning.aggressiveness,
861 tuning.protect,
862 );
863 cache.set_compressed(path, &cache_key, out.clone());
864 }
865 let out = crate::core::redaction::redact_text_if_enabled(&out);
866 let sent = count_tokens(&out);
867 return ReadOutput {
868 content: out,
869 resolved_mode,
870 output_tokens: sent,
871 };
872 }
873 cache.invalidate(path);
874 }
875
876 let content = match read_file_lossy(path) {
877 Ok(c) => c,
878 Err(e) => {
879 let msg = format!("ERROR: {e}");
880 let tokens = count_tokens(&msg);
881 return ReadOutput {
882 content: msg,
883 resolved_mode: "error".into(),
884 output_tokens: tokens,
885 };
886 }
887 };
888
889 let store_result = cache.store(path, &content);
890
891 let is_line_range = mode.starts_with("lines:");
894 let hints = crate::core::profiles::active_profile().output_hints;
895 let is_repeat_read = store_result.read_count > 1;
896 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
897 find_similar_and_update_semantic_index(path, &content)
898 } else {
899 None
900 };
901 let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
902 build_graph_related_hint(path)
903 } else {
904 None
905 };
906
907 if mode == "full" {
908 cache.mark_full_delivered(path);
909 let (mut output, _) = format_full_output(
910 &file_ref,
911 &short,
912 ext,
913 &content,
914 store_result.original_tokens,
915 store_result.line_count,
916 task,
917 );
918 if let Some(hint) = &graph_hint {
919 output.push_str(&format!("\n{hint}"));
920 }
921 if let Some(hint) = similar_hint {
922 output.push_str(&format!("\n{hint}"));
923 }
924 let framed_tokens = count_tokens(&output);
925 let output = cap_to_raw(
926 output,
927 framed_tokens,
928 &content,
929 store_result.original_tokens,
930 );
931 let output = crate::core::redaction::redact_text_if_enabled(&output);
932 let sent = count_tokens(&output);
933 return ReadOutput {
934 content: output,
935 resolved_mode: "full".into(),
936 output_tokens: sent,
937 };
938 }
939
940 let resolved_mode = if mode == "auto" {
941 tuning
942 .auto_density_mode()
943 .unwrap_or_else(|| resolve_auto_mode(None, path, store_result.original_tokens, task))
944 } else {
945 mode.to_string()
946 };
947
948 let (output, _sent) = process_mode_tuned(
949 &content,
950 &resolved_mode,
951 &file_ref,
952 &short,
953 ext,
954 store_result.original_tokens,
955 crp_mode,
956 path,
957 task,
958 tuning,
959 );
960 let mut output = if mode_allows_raw_cap(&resolved_mode) {
966 let framed_tokens = count_tokens(&output);
967 cap_to_raw(
968 output,
969 framed_tokens,
970 &content,
971 store_result.original_tokens,
972 )
973 } else {
974 output
975 };
976 if is_cacheable_mode(&resolved_mode) {
977 let cache_key = compressed_cache_key(
978 &resolved_mode,
979 crp_mode,
980 task,
981 tuning.aggressiveness,
982 tuning.protect,
983 );
984 cache.set_compressed(path, &cache_key, output.clone());
985 }
986 if let Some(hint) = &graph_hint {
987 output.push_str(&format!("\n{hint}"));
988 }
989 if let Some(hint) = similar_hint {
990 output.push_str(&format!("\n{hint}"));
991 }
992 let output = crate::core::redaction::redact_text_if_enabled(&output);
993 let final_tokens = count_tokens(&output);
994 ReadOutput {
995 content: output,
996 resolved_mode,
997 output_tokens: final_tokens,
998 }
999}
1000
1001pub fn is_instruction_file(path: &str) -> bool {
1002 let lower = path.to_lowercase();
1003 let filename = std::path::Path::new(&lower)
1004 .file_name()
1005 .and_then(|f| f.to_str())
1006 .unwrap_or("");
1007
1008 matches!(
1009 filename,
1010 "skill.md"
1011 | "agents.md"
1012 | "rules.md"
1013 | ".cursorrules"
1014 | ".clinerules"
1015 | "lean-ctx.md"
1016 | "lean-ctx.mdc"
1017 ) || lower.contains("/skills/")
1018 || lower.contains("/.cursor/rules/")
1019 || lower.contains("/.claude/rules/")
1020 || lower.contains("/agents.md")
1021}
1022
1023fn cap_to_raw(
1038 framed: String,
1039 framed_tokens: usize,
1040 raw_content: &str,
1041 raw_tokens: usize,
1042) -> String {
1043 if raw_tokens > 0 && framed_tokens > raw_tokens {
1044 raw_content.to_string()
1045 } else {
1046 framed
1047 }
1048}
1049
1050fn resolve_auto_mode(
1059 cache: Option<&SessionCache>,
1060 file_path: &str,
1061 original_tokens: usize,
1062 task: Option<&str>,
1063) -> String {
1064 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
1065 path: file_path,
1066 token_count: original_tokens,
1067 task,
1068 cache,
1069 };
1070 crate::core::auto_mode_resolver::resolve(&ctx).mode
1071}
1072
1073fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
1074 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
1075
1076 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
1077 return None;
1078 }
1079
1080 let cfg = crate::core::config::Config::load();
1081 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1082 if !profile.semantic_cache_enabled() {
1083 return None;
1084 }
1085
1086 let project_root = detect_project_root(path);
1087 let session_id = format!("{}", std::process::id());
1088 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
1089
1090 let similar = index.find_similar(content, 0.7);
1091 let relevant: Vec<_> = similar
1092 .into_iter()
1093 .filter(|(p, _)| p != path)
1094 .take(3)
1095 .collect();
1096
1097 index.add_file(path, content, &session_id);
1098 if let Err(e) = index.save(&project_root) {
1099 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
1100 }
1101
1102 if relevant.is_empty() {
1103 return None;
1104 }
1105
1106 let hints: Vec<String> = relevant
1107 .iter()
1108 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
1109 .collect();
1110
1111 Some(format!(
1112 "[semantic: {} similar file(s) in cache]\n{}",
1113 relevant.len(),
1114 hints.join("\n")
1115 ))
1116}
1117
1118fn detect_project_root(path: &str) -> String {
1119 crate::core::protocol::detect_project_root_or_cwd(path)
1120}
1121
1122fn build_graph_related_hint(path: &str) -> Option<String> {
1123 let project_root = detect_project_root(path);
1124 crate::core::graph_context::build_related_hint(path, &project_root, 5)
1125}
1126
1127const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1128
1129fn handle_full_with_auto_delta(
1131 cache: &mut SessionCache,
1132 path: &str,
1133 file_ref: &str,
1134 short: &str,
1135 ext: &str,
1136 task: Option<&str>,
1137) -> (String, usize) {
1138 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1139 let Ok(disk_content) = read_file_lossy(path) else {
1140 cache.record_cache_hit(path);
1141 if let Some(existing) = cache.get(path) {
1142 if !crate::core::protocol::meta_visible()
1143 && let Some(cached) = existing.content()
1144 {
1145 return format_full_output(
1146 file_ref,
1147 short,
1148 ext,
1149 &cached,
1150 existing.original_tokens,
1151 existing.line_count,
1152 task,
1153 );
1154 }
1155 let out = format!(
1156 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1157 existing.read_count(),
1158 existing.line_count
1159 );
1160 let sent = count_tokens(&out);
1161 return (out, sent);
1162 }
1163 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1164 format!("[file read failed and no cached version available] {file_ref}={short}")
1165 } else {
1166 format!("[file read failed and no cached version available] {short}")
1167 };
1168 let sent = count_tokens(&out);
1169 return (out, sent);
1170 };
1171
1172 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1173 let prof = crate::core::profiles::active_profile();
1174 let force_full = no_deg
1175 || (prof.read.default_mode_effective() == "full"
1176 && prof.compression.crp_mode_effective() == "off");
1177
1178 let old_content = cache
1179 .get(path)
1180 .and_then(crate::core::cache::CacheEntry::content)
1181 .unwrap_or_default();
1182 let store_result = cache.store(path, &disk_content);
1183
1184 if store_result.was_hit {
1185 let policy_allows_stub =
1186 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1187 if policy_allows_stub && store_result.full_content_delivered {
1188 let out = if crate::core::protocol::meta_visible() {
1189 format!(
1190 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1191 store_result.line_count
1192 )
1193 } else {
1194 format!(
1198 "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1199 store_result.line_count
1200 )
1201 };
1202 let sent = count_tokens(&out);
1203 return (out, sent);
1204 }
1205 cache.mark_full_delivered(path);
1206 return format_full_output(
1207 file_ref,
1208 short,
1209 ext,
1210 &disk_content,
1211 store_result.original_tokens,
1212 store_result.line_count,
1213 task,
1214 );
1215 }
1216
1217 let diff = compressor::diff_content(&old_content, &disk_content);
1218 let diff_tokens = count_tokens(&diff);
1219 let full_tokens = store_result.original_tokens;
1220
1221 if !force_full
1222 && full_tokens > 0
1223 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1224 {
1225 let savings = protocol::format_savings(full_tokens, diff_tokens);
1226 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1227 format!("{file_ref}={short}")
1228 } else {
1229 short.to_string()
1230 };
1231 let out = format!(
1232 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1233 disk_content.lines().count()
1234 );
1235 return (out, diff_tokens);
1236 }
1237
1238 format_full_output(
1239 file_ref,
1240 short,
1241 ext,
1242 &disk_content,
1243 store_result.original_tokens,
1244 store_result.line_count,
1245 task,
1246 )
1247}
1248
1249fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1250 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1251 let short = protocol::shorten_path(path);
1252 let old_content = cache
1253 .get(path)
1254 .and_then(crate::core::cache::CacheEntry::content);
1255
1256 let new_content = match read_file_lossy(path) {
1257 Ok(c) => c,
1258 Err(e) => {
1259 let msg = format!("ERROR: {e}");
1260 let tokens = count_tokens(&msg);
1261 return (msg, tokens);
1262 }
1263 };
1264
1265 let original_tokens = count_tokens(&new_content);
1266
1267 let diff_output = if let Some(old) = &old_content {
1268 compressor::diff_content(old, &new_content)
1269 } else {
1270 cache.store(path, &new_content);
1273 let msg = format!(
1274 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1275 );
1276 let sent = count_tokens(&msg);
1277 return (msg, sent);
1278 };
1279
1280 cache.store(path, &new_content);
1281
1282 let sent = count_tokens(&diff_output);
1283 let savings = protocol::format_savings(original_tokens, sent);
1284 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1285 format!("{file_ref}={short}")
1286 } else {
1287 short
1288 };
1289 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1290}