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 is_subagent_context() -> bool {
362 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
363 *IS_SUBAGENT.get_or_init(|| {
364 if std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true") {
365 return true;
366 }
367 std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
368 })
369}
370
371fn handle_with_options_resolved(
372 cache: &mut SessionCache,
373 path: &str,
374 mode: &str,
375 fresh: bool,
376 crp_mode: CrpMode,
377 task: Option<&str>,
378 tuning: ReadTuning<'_>,
379) -> ReadOutput {
380 let effective_fresh = fresh || is_subagent_context();
381
382 if PluginManager::has_listener("pre_read") {
385 PluginManager::fire_hook_background(HookPoint::PreRead {
386 path: path.to_string(),
387 });
388 }
389
390 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
391 bt.next_seq();
392 }
393 let mut result =
394 handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task, tuning);
395
396 if let Some(entry) = cache.get_mut(path) {
397 entry.last_mode.clone_from(&result.resolved_mode);
398 }
399
400 let dedup_allowed = result
402 .resolved_mode
403 .parse::<ReadMode>()
404 .is_ok_and(|m| m.is_lossy_summary());
405 if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
406 let new_tokens = count_tokens(&deduped);
407 if new_tokens < result.output_tokens {
408 result.content = deduped;
409 result.output_tokens = new_tokens;
410 }
411 }
412
413 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
414 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
415 bt.record_read(
416 path,
417 &result.resolved_mode,
418 result.output_tokens,
419 original_tokens,
420 );
421
422 let compressed = result
432 .resolved_mode
433 .parse::<ReadMode>()
434 .map_or(true, |m| m.counts_as_compressed());
435 if compressed {
436 crate::core::adaptive_thresholds::record_quality_signal(
437 path,
438 crate::core::threshold_learning::QualitySignal::CleanCompressed,
439 );
440 } else if result.resolved_mode == "full"
441 && result.output_tokens > 2000
442 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
443 {
444 crate::core::adaptive_thresholds::record_quality_signal(
445 path,
446 crate::core::threshold_learning::QualitySignal::WastedFull,
447 );
448 }
449 }
450
451 if PluginManager::has_listener("post_compress") {
453 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
454 PluginManager::fire_hook_background(HookPoint::PostCompress {
455 path: path.to_string(),
456 original_tokens,
457 compressed_tokens: result.output_tokens,
458 });
459 }
460
461 {
468 let self_agent = crate::core::scent_field::scent_agent_id();
469 let scent_path = crate::core::pathutil::normalize_tool_path(path);
470 std::thread::spawn(move || {
471 crate::core::scent_field::deposit(
472 self_agent,
473 crate::core::scent_field::ScentKind::Hot,
474 &scent_path,
475 0.3,
476 );
477 });
478 }
479
480 result
481}
482
483pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
494 let file_ref = cache.get_file_ref_readonly(path)?;
495 let (cached_mtime, cached_hash, line_count) = {
496 let entry = cache.get(path)?;
497 (entry.stored_mtime, entry.hash.clone(), entry.line_count)
498 };
499
500 let no_deg = crate::core::config::Config::load().no_degrade_effective();
501 let prof = crate::core::profiles::active_profile();
502 let force_full = no_deg
503 || (prof.read.default_mode_effective() == "full"
504 && prof.compression.crp_mode_effective() == "off");
505 let policy_allows_stub =
506 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
507 if !policy_allows_stub
508 || crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
509 || !cache.is_full_delivered(path)
510 {
511 return None;
512 }
513
514 cache.record_cache_hit(path);
515 let short = protocol::shorten_path(path);
516 let out = if crate::core::protocol::meta_visible() {
517 format!(
518 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
519 )
520 } else {
521 format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
527 };
528 let out = crate::core::redaction::redact_text_if_enabled(&out);
529 let sent = count_tokens(&out);
530 Some(ReadOutput {
531 content: out,
532 resolved_mode: "full".into(),
533 output_tokens: sent,
534 })
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct DeltaExplicitDecision {
541 pub mode: String,
544 pub note: Option<String>,
548}
549
550pub fn resolve_explicit_delta_mode(
572 cache: &SessionCache,
573 path: &str,
574 mode: &str,
575 explicit_mode: bool,
576 fresh: bool,
577 enabled: bool,
578) -> DeltaExplicitDecision {
579 let unchanged = DeltaExplicitDecision {
580 mode: mode.to_string(),
581 note: None,
582 };
583 if fresh || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
584 return unchanged;
585 }
586 let Some(entry) = cache.get(path) else {
587 return unchanged;
589 };
590 let stale =
591 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
592 if stale {
593 if entry.content().is_some() {
597 return DeltaExplicitDecision {
598 mode: "diff".to_string(),
599 note: Some(format!(
600 "[delta-explicit] requested mode={mode} served as a diff: the file \
601 changed since your last read and the diff is the new information. \
602 Pass fresh=true if you need the full content re-emitted."
603 )),
604 };
605 }
606 return unchanged;
607 }
608 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
612 return DeltaExplicitDecision {
613 mode: "full".to_string(),
614 note: None,
615 };
616 }
617 unchanged
618}
619
620fn handle_with_options_inner(
621 cache: &mut SessionCache,
622 path: &str,
623 mode: &str,
624 fresh: bool,
625 crp_mode: CrpMode,
626 task: Option<&str>,
627 tuning: ReadTuning<'_>,
628) -> ReadOutput {
629 let file_ref = cache.get_file_ref(path);
630 let short = protocol::shorten_path(path);
631 let ext = Path::new(path)
632 .extension()
633 .and_then(|e| e.to_str())
634 .unwrap_or("");
635
636 if fresh {
637 if mode == "diff" {
638 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
639 return ReadOutput {
640 content: warning.to_string(),
641 resolved_mode: "diff".into(),
642 output_tokens: count_tokens(warning),
643 };
644 }
645 cache.invalidate(path);
646 }
647
648 if mode == "diff" {
649 let (out, _) = handle_diff(cache, path, &file_ref);
650 let out = crate::core::redaction::redact_text_if_enabled(&out);
651 let sent = count_tokens(&out);
652 return ReadOutput {
653 content: out,
654 resolved_mode: "diff".into(),
655 output_tokens: sent,
656 };
657 }
658
659 if mode != "full"
660 && let Some(existing) = cache.get(path)
661 {
662 let stale = crate::core::cache::is_cache_entry_stale_verified(
663 path,
664 existing.stored_mtime,
665 &existing.hash,
666 );
667 if stale {
668 cache.invalidate(path);
669 }
670 }
671
672 let cache_snapshot = cache
675 .get(path)
676 .map(|existing| (existing.original_tokens, existing.content()));
677
678 if let Some((original_tokens, content_opt)) = cache_snapshot {
679 if mode == "full" {
680 if let Some(out) = try_stub_hit_readonly(cache, path) {
683 return out;
684 }
685 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
686 let out = crate::core::redaction::redact_text_if_enabled(&out);
687 let sent = count_tokens(&out);
688 return ReadOutput {
689 content: out,
690 resolved_mode: "full".into(),
691 output_tokens: sent,
692 };
693 }
694
695 let resolved_mode = if mode == "auto" {
700 tuning
701 .auto_density_mode()
702 .unwrap_or_else(|| resolve_auto_mode(path, original_tokens, task))
703 } else {
704 mode.to_string()
705 };
706
707 if is_cacheable_mode(&resolved_mode) {
708 let cache_key = compressed_cache_key(
709 &resolved_mode,
710 crp_mode,
711 task,
712 tuning.aggressiveness,
713 tuning.protect,
714 );
715 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
716 if let Some(cached_output) = compressed_hit {
717 cache.record_cache_hit(path);
718 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
719 let sent = count_tokens(&out);
720 return ReadOutput {
721 content: out,
722 resolved_mode,
723 output_tokens: sent,
724 };
725 }
726 }
727
728 if let Some(content) = content_opt {
729 let (out, _) = process_mode_tuned(
730 &content,
731 &resolved_mode,
732 &file_ref,
733 &short,
734 ext,
735 original_tokens,
736 crp_mode,
737 path,
738 task,
739 tuning,
740 );
741 let out = if mode_allows_raw_cap(&resolved_mode) {
747 let framed_tokens = count_tokens(&out);
748 cap_to_raw(out, framed_tokens, &content, original_tokens)
749 } else {
750 out
751 };
752 if is_cacheable_mode(&resolved_mode) {
753 let cache_key = compressed_cache_key(
754 &resolved_mode,
755 crp_mode,
756 task,
757 tuning.aggressiveness,
758 tuning.protect,
759 );
760 cache.set_compressed(path, &cache_key, out.clone());
761 }
762 let out = crate::core::redaction::redact_text_if_enabled(&out);
763 let sent = count_tokens(&out);
764 return ReadOutput {
765 content: out,
766 resolved_mode,
767 output_tokens: sent,
768 };
769 }
770 cache.invalidate(path);
771 }
772
773 let content = match read_file_lossy(path) {
774 Ok(c) => c,
775 Err(e) => {
776 let msg = format!("ERROR: {e}");
777 let tokens = count_tokens(&msg);
778 return ReadOutput {
779 content: msg,
780 resolved_mode: "error".into(),
781 output_tokens: tokens,
782 };
783 }
784 };
785
786 let store_result = cache.store(path, &content);
787
788 let is_line_range = mode.starts_with("lines:");
791 let hints = crate::core::profiles::active_profile().output_hints;
792 let is_repeat_read = store_result.read_count > 1;
793 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
794 find_similar_and_update_semantic_index(path, &content)
795 } else {
796 None
797 };
798 let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
799 build_graph_related_hint(path)
800 } else {
801 None
802 };
803
804 if mode == "full" {
805 cache.mark_full_delivered(path);
806 let (mut output, _) = format_full_output(
807 &file_ref,
808 &short,
809 ext,
810 &content,
811 store_result.original_tokens,
812 store_result.line_count,
813 task,
814 );
815 if let Some(hint) = &graph_hint {
816 output.push_str(&format!("\n{hint}"));
817 }
818 if let Some(hint) = similar_hint {
819 output.push_str(&format!("\n{hint}"));
820 }
821 let framed_tokens = count_tokens(&output);
822 let output = cap_to_raw(
823 output,
824 framed_tokens,
825 &content,
826 store_result.original_tokens,
827 );
828 let output = crate::core::redaction::redact_text_if_enabled(&output);
829 let sent = count_tokens(&output);
830 return ReadOutput {
831 content: output,
832 resolved_mode: "full".into(),
833 output_tokens: sent,
834 };
835 }
836
837 let resolved_mode = if mode == "auto" {
838 tuning
839 .auto_density_mode()
840 .unwrap_or_else(|| resolve_auto_mode(path, store_result.original_tokens, task))
841 } else {
842 mode.to_string()
843 };
844
845 let (output, _sent) = process_mode_tuned(
846 &content,
847 &resolved_mode,
848 &file_ref,
849 &short,
850 ext,
851 store_result.original_tokens,
852 crp_mode,
853 path,
854 task,
855 tuning,
856 );
857 let mut output = if mode_allows_raw_cap(&resolved_mode) {
863 let framed_tokens = count_tokens(&output);
864 cap_to_raw(
865 output,
866 framed_tokens,
867 &content,
868 store_result.original_tokens,
869 )
870 } else {
871 output
872 };
873 if is_cacheable_mode(&resolved_mode) {
874 let cache_key = compressed_cache_key(
875 &resolved_mode,
876 crp_mode,
877 task,
878 tuning.aggressiveness,
879 tuning.protect,
880 );
881 cache.set_compressed(path, &cache_key, output.clone());
882 }
883 if let Some(hint) = &graph_hint {
884 output.push_str(&format!("\n{hint}"));
885 }
886 if let Some(hint) = similar_hint {
887 output.push_str(&format!("\n{hint}"));
888 }
889 let output = crate::core::redaction::redact_text_if_enabled(&output);
890 let final_tokens = count_tokens(&output);
891 ReadOutput {
892 content: output,
893 resolved_mode,
894 output_tokens: final_tokens,
895 }
896}
897
898pub fn is_instruction_file(path: &str) -> bool {
899 let lower = path.to_lowercase();
900 let filename = std::path::Path::new(&lower)
901 .file_name()
902 .and_then(|f| f.to_str())
903 .unwrap_or("");
904
905 matches!(
906 filename,
907 "skill.md"
908 | "agents.md"
909 | "rules.md"
910 | ".cursorrules"
911 | ".clinerules"
912 | "lean-ctx.md"
913 | "lean-ctx.mdc"
914 ) || lower.contains("/skills/")
915 || lower.contains("/.cursor/rules/")
916 || lower.contains("/.claude/rules/")
917 || lower.contains("/agents.md")
918}
919
920fn cap_to_raw(
935 framed: String,
936 framed_tokens: usize,
937 raw_content: &str,
938 raw_tokens: usize,
939) -> String {
940 if raw_tokens > 0 && framed_tokens > raw_tokens {
941 raw_content.to_string()
942 } else {
943 framed
944 }
945}
946
947fn resolve_auto_mode(file_path: &str, original_tokens: usize, task: Option<&str>) -> String {
949 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
950 path: file_path,
951 token_count: original_tokens,
952 task,
953 cache: None,
954 };
955 crate::core::auto_mode_resolver::resolve(&ctx).mode
956}
957
958fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
959 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
960
961 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
962 return None;
963 }
964
965 let cfg = crate::core::config::Config::load();
966 let profile = crate::core::config::MemoryProfile::effective(&cfg);
967 if !profile.semantic_cache_enabled() {
968 return None;
969 }
970
971 let project_root = detect_project_root(path);
972 let session_id = format!("{}", std::process::id());
973 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
974
975 let similar = index.find_similar(content, 0.7);
976 let relevant: Vec<_> = similar
977 .into_iter()
978 .filter(|(p, _)| p != path)
979 .take(3)
980 .collect();
981
982 index.add_file(path, content, &session_id);
983 if let Err(e) = index.save(&project_root) {
984 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
985 }
986
987 if relevant.is_empty() {
988 return None;
989 }
990
991 let hints: Vec<String> = relevant
992 .iter()
993 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
994 .collect();
995
996 Some(format!(
997 "[semantic: {} similar file(s) in cache]\n{}",
998 relevant.len(),
999 hints.join("\n")
1000 ))
1001}
1002
1003fn detect_project_root(path: &str) -> String {
1004 crate::core::protocol::detect_project_root_or_cwd(path)
1005}
1006
1007fn build_graph_related_hint(path: &str) -> Option<String> {
1008 let project_root = detect_project_root(path);
1009 crate::core::graph_context::build_related_hint(path, &project_root, 5)
1010}
1011
1012const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1013
1014fn handle_full_with_auto_delta(
1016 cache: &mut SessionCache,
1017 path: &str,
1018 file_ref: &str,
1019 short: &str,
1020 ext: &str,
1021 task: Option<&str>,
1022) -> (String, usize) {
1023 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1024 let Ok(disk_content) = read_file_lossy(path) else {
1025 cache.record_cache_hit(path);
1026 if let Some(existing) = cache.get(path) {
1027 if !crate::core::protocol::meta_visible()
1028 && let Some(cached) = existing.content()
1029 {
1030 return format_full_output(
1031 file_ref,
1032 short,
1033 ext,
1034 &cached,
1035 existing.original_tokens,
1036 existing.line_count,
1037 task,
1038 );
1039 }
1040 let out = format!(
1041 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1042 existing.read_count(),
1043 existing.line_count
1044 );
1045 let sent = count_tokens(&out);
1046 return (out, sent);
1047 }
1048 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1049 format!("[file read failed and no cached version available] {file_ref}={short}")
1050 } else {
1051 format!("[file read failed and no cached version available] {short}")
1052 };
1053 let sent = count_tokens(&out);
1054 return (out, sent);
1055 };
1056
1057 let no_deg = crate::core::config::Config::load().no_degrade_effective();
1058 let prof = crate::core::profiles::active_profile();
1059 let force_full = no_deg
1060 || (prof.read.default_mode_effective() == "full"
1061 && prof.compression.crp_mode_effective() == "off");
1062
1063 let old_content = cache
1064 .get(path)
1065 .and_then(crate::core::cache::CacheEntry::content)
1066 .unwrap_or_default();
1067 let store_result = cache.store(path, &disk_content);
1068
1069 if store_result.was_hit {
1070 let policy_allows_stub =
1071 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1072 if policy_allows_stub && store_result.full_content_delivered {
1073 let out = if crate::core::protocol::meta_visible() {
1074 format!(
1075 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1076 store_result.line_count
1077 )
1078 } else {
1079 format!(
1083 "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1084 store_result.line_count
1085 )
1086 };
1087 let sent = count_tokens(&out);
1088 return (out, sent);
1089 }
1090 cache.mark_full_delivered(path);
1091 return format_full_output(
1092 file_ref,
1093 short,
1094 ext,
1095 &disk_content,
1096 store_result.original_tokens,
1097 store_result.line_count,
1098 task,
1099 );
1100 }
1101
1102 let diff = compressor::diff_content(&old_content, &disk_content);
1103 let diff_tokens = count_tokens(&diff);
1104 let full_tokens = store_result.original_tokens;
1105
1106 if !force_full
1107 && full_tokens > 0
1108 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1109 {
1110 let savings = protocol::format_savings(full_tokens, diff_tokens);
1111 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1112 format!("{file_ref}={short}")
1113 } else {
1114 short.to_string()
1115 };
1116 let out = format!(
1117 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1118 disk_content.lines().count()
1119 );
1120 return (out, diff_tokens);
1121 }
1122
1123 format_full_output(
1124 file_ref,
1125 short,
1126 ext,
1127 &disk_content,
1128 store_result.original_tokens,
1129 store_result.line_count,
1130 task,
1131 )
1132}
1133
1134fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1135 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1136 let short = protocol::shorten_path(path);
1137 let old_content = cache
1138 .get(path)
1139 .and_then(crate::core::cache::CacheEntry::content);
1140
1141 let new_content = match read_file_lossy(path) {
1142 Ok(c) => c,
1143 Err(e) => {
1144 let msg = format!("ERROR: {e}");
1145 let tokens = count_tokens(&msg);
1146 return (msg, tokens);
1147 }
1148 };
1149
1150 let original_tokens = count_tokens(&new_content);
1151
1152 let diff_output = if let Some(old) = &old_content {
1153 compressor::diff_content(old, &new_content)
1154 } else {
1155 cache.store(path, &new_content);
1158 let msg = format!(
1159 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1160 );
1161 let sent = count_tokens(&msg);
1162 return (msg, sent);
1163 };
1164
1165 cache.store(path, &new_content);
1166
1167 let sent = count_tokens(&diff_output);
1168 let savings = protocol::format_savings(original_tokens, sent);
1169 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1170 format!("{file_ref}={short}")
1171 } else {
1172 short
1173 };
1174 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1175}