Skip to main content

lean_ctx/tools/ctx_read/
mod.rs

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;
13// `pub(crate)`: the conformance suite renders modes directly for its
14// accuracy invariants (GL#441).
15pub(crate) mod render;
16pub(crate) use render::*;
17#[cfg(test)]
18mod tests;
19
20/// Pre-counted read output carrying the output string, resolved mode,
21/// and token count computed during mode processing.
22pub struct ReadOutput {
23    pub content: String,
24    pub resolved_mode: String,
25    /// Approximate output token count from mode processing.
26    /// The dispatch layer recounts the final assembled string for accurate savings.
27    pub output_tokens: usize,
28}
29
30const COMPRESSED_HINT: &str = "[compressed — use mode=\"full\" for complete source]";
31
32const CACHEABLE_MODES: &[&str] = &["map", "signatures"];
33
34fn is_cacheable_mode(mode: &str) -> bool {
35    CACHEABLE_MODES.contains(&mode)
36}
37
38fn compressed_cache_key(mode: &str, crp_mode: CrpMode, task: Option<&str>) -> String {
39    // Bump when the rendered map/signatures body changes shape so stale
40    // pre-line-range entries are not served from an older session cache.
41    let versioned_mode = match mode {
42        "map" => "map:v2",
43        "signatures" => "signatures:v2",
44        _ => mode,
45    };
46    let base = if crp_mode.is_tdd() {
47        format!("{versioned_mode}:tdd")
48    } else {
49        versioned_mode.to_string()
50    };
51    // map/signatures output now embeds a task-relevant body, so task-aware and
52    // task-free variants must cache under distinct keys.
53    match task.map(str::trim).filter(|t| !t.is_empty()) {
54        Some(t) => {
55            use std::hash::{Hash, Hasher};
56            let mut h = std::collections::hash_map::DefaultHasher::new();
57            t.hash(&mut h);
58            format!("{base}:t{:x}", h.finish())
59        }
60        None => base,
61    }
62}
63
64/// Extracts a short proof-line from file content to include in cache-hit stubs.
65/// Returns the first non-empty line (truncated to 60 chars) as evidence the cache is valid.
66/// Only shown after 2+ reads to avoid noise on early interactions.
67fn cache_hit_proof_line(content: &str, read_count: u32) -> Option<String> {
68    if read_count < 2 {
69        return None;
70    }
71    let first_line = content.lines().find(|l| !l.trim().is_empty())?;
72    let trimmed = first_line.trim();
73    if trimmed.len() > 60 {
74        let mut end = 57;
75        while end > 0 && !trimmed.is_char_boundary(end) {
76            end -= 1;
77        }
78        Some(format!("{}...", &trimmed[..end]))
79    } else {
80        Some(trimmed.to_string())
81    }
82}
83
84fn append_compressed_hint(output: &str, file_path: &str) -> String {
85    if !crate::core::profiles::active_profile()
86        .output_hints
87        .compressed_hint()
88    {
89        return output.to_string();
90    }
91    format!(
92        "{output}\n{COMPRESSED_HINT}\n  ctx_read(\"{file_path}\", mode=\"full\") | ctx_retrieve(\"{file_path}\")"
93    )
94}
95
96/// Reads a file as UTF-8 with lossy fallback, enforcing binary detection and max read size limit.
97/// Defense-in-depth: verifies that the canonical path stays within the process's project root
98/// (if determinable) even though callers SHOULD have already jail-checked the path.
99pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
100    if crate::core::binary_detect::is_binary_file(path) {
101        let msg = crate::core::binary_detect::binary_file_message(path);
102        return Err(std::io::Error::other(msg));
103    }
104
105    {
106        let canonical =
107            crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
108        if let Ok(cwd) = std::env::current_dir() {
109            let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
110            if !canonical.starts_with(&root) {
111                let allow = crate::core::pathjail::allow_paths_from_env_and_config();
112                let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
113                    .ok()
114                    .is_some_and(|d| canonical.starts_with(d));
115                let tmp_ok = canonical.starts_with(std::env::temp_dir());
116                if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
117                    tracing::warn!(
118                        "defense-in-depth: path may escape project root: {}",
119                        canonical.display()
120                    );
121                }
122            }
123        }
124    }
125
126    let cap = crate::core::limits::max_read_bytes();
127
128    let file = open_with_retry(path)?;
129    let meta = file
130        .metadata()
131        .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
132    if meta.len() > cap as u64 {
133        return Err(std::io::Error::other(format!(
134            "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
135             Increase the limit or use a line-range read: mode=\"lines:1-100\"",
136            meta.len(),
137            cap
138        )));
139    }
140
141    use std::io::Read;
142    let mut bytes = Vec::with_capacity(meta.len() as usize);
143    std::io::BufReader::new(file).read_to_end(&mut bytes)?;
144    match String::from_utf8(bytes) {
145        Ok(s) => Ok(s),
146        Err(e) => Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()),
147    }
148}
149
150/// Opens a file, retrying once after a brief pause on NotFound.
151/// Works around overlay/FUSE stat-cache races in container runtimes (Docker, Codex).
152/// Uses O_NOFOLLOW on Unix for TOCTOU symlink protection.
153fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
154    match open_nofollow(path) {
155        Ok(f) => Ok(f),
156        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
157            std::thread::sleep(std::time::Duration::from_millis(50));
158            open_nofollow(path).map_err(|e| {
159                if e.kind() == std::io::ErrorKind::NotFound {
160                    std::io::Error::other(format!(
161                        "file not found: {path} — verify the path with ctx_tree or ctx_search"
162                    ))
163                } else {
164                    e
165                }
166            })
167        }
168        Err(e) => Err(e),
169    }
170}
171
172#[cfg(unix)]
173fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
174    use std::os::unix::fs::OpenOptionsExt;
175    use std::path::Path;
176
177    let p = Path::new(path);
178    // Canonicalize the parent directory (resolving symlinks in the directory path)
179    // but apply O_NOFOLLOW only to the final file component. This prevents
180    // symlink-following attacks on the target file while allowing legitimate
181    // directory symlinks (e.g., /tmp → /private/tmp on macOS).
182    if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
183        && parent.exists()
184    {
185        let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
186        let canonical_path = canonical_parent.join(filename);
187        return std::fs::OpenOptions::new()
188            .read(true)
189            .custom_flags(libc::O_NOFOLLOW)
190            .open(&canonical_path);
191    }
192
193    // Fallback: direct open with O_NOFOLLOW
194    std::fs::OpenOptions::new()
195        .read(true)
196        .custom_flags(libc::O_NOFOLLOW)
197        .open(path)
198}
199
200#[cfg(not(unix))]
201fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
202    std::fs::File::open(path)
203}
204
205/// Reads a file through the cache and applies the requested compression mode.
206pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
207    handle_with_options(cache, path, mode, false, crp_mode, None)
208}
209
210/// Like `handle`, but invalidates the cache first to force a fresh disk read.
211pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
212    handle_with_options(cache, path, mode, true, crp_mode, None)
213}
214
215/// Reads a file with task-aware filtering to prioritize task-relevant content.
216pub fn handle_with_task(
217    cache: &mut SessionCache,
218    path: &str,
219    mode: &str,
220    crp_mode: CrpMode,
221    task: Option<&str>,
222) -> String {
223    handle_with_options(cache, path, mode, false, crp_mode, task)
224}
225
226/// Like `handle_with_task`, also returns the resolved mode name and pre-counted tokens.
227pub fn handle_with_task_resolved(
228    cache: &mut SessionCache,
229    path: &str,
230    mode: &str,
231    crp_mode: CrpMode,
232    task: Option<&str>,
233) -> ReadOutput {
234    handle_with_options_resolved(cache, path, mode, false, crp_mode, task)
235}
236
237/// Fresh read with task-aware filtering (invalidates cache first).
238pub fn handle_fresh_with_task(
239    cache: &mut SessionCache,
240    path: &str,
241    mode: &str,
242    crp_mode: CrpMode,
243    task: Option<&str>,
244) -> String {
245    handle_with_options(cache, path, mode, true, crp_mode, task)
246}
247
248/// Fresh read with task-aware filtering, also returns the resolved mode name and pre-counted tokens.
249pub fn handle_fresh_with_task_resolved(
250    cache: &mut SessionCache,
251    path: &str,
252    mode: &str,
253    crp_mode: CrpMode,
254    task: Option<&str>,
255) -> ReadOutput {
256    handle_with_options_resolved(cache, path, mode, true, crp_mode, task)
257}
258
259fn handle_with_options(
260    cache: &mut SessionCache,
261    path: &str,
262    mode: &str,
263    fresh: bool,
264    crp_mode: CrpMode,
265    task: Option<&str>,
266) -> String {
267    handle_with_options_resolved(cache, path, mode, fresh, crp_mode, task).content
268}
269
270/// Detects if the current execution context is a subagent (forked agent).
271/// Subagents inherit stale parent caches, so force-fresh prevents VERIFY FAIL.
272fn is_subagent_context() -> bool {
273    static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
274    *IS_SUBAGENT.get_or_init(|| {
275        if std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true") {
276            return true;
277        }
278        std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
279    })
280}
281
282fn handle_with_options_resolved(
283    cache: &mut SessionCache,
284    path: &str,
285    mode: &str,
286    fresh: bool,
287    crp_mode: CrpMode,
288    task: Option<&str>,
289) -> ReadOutput {
290    let effective_fresh = fresh || is_subagent_context();
291
292    // Plugin seam: notify listeners before the read resolves. Guarded so the hot
293    // path never allocates or spawns a thread unless a plugin opts into pre_read.
294    if PluginManager::has_listener("pre_read") {
295        PluginManager::fire_hook_background(HookPoint::PreRead {
296            path: path.to_string(),
297        });
298    }
299
300    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
301        bt.next_seq();
302    }
303    let mut result = handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task);
304
305    if let Some(entry) = cache.get_mut(path) {
306        entry.last_mode.clone_from(&result.resolved_mode);
307    }
308
309    let dedup_allowed = matches!(
310        result.resolved_mode.as_str(),
311        "map" | "signatures" | "aggressive" | "entropy" | "task"
312    );
313    if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
314        let new_tokens = count_tokens(&deduped);
315        if new_tokens < result.output_tokens {
316            result.content = deduped;
317            result.output_tokens = new_tokens;
318        }
319    }
320
321    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
322        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
323        bt.record_read(
324            path,
325            &result.resolved_mode,
326            result.output_tokens,
327            original_tokens,
328        );
329
330        // Quality signals (#538): compressed reads count as clean until a
331        // bounce proves otherwise (the bounce signal outweighs 6:1); large
332        // full reads of never-bouncing extensions are wasted compression
333        // opportunities and push the learned threshold up.
334        let compressed = !matches!(result.resolved_mode.as_str(), "full" | "diff" | "lines");
335        if compressed {
336            crate::core::adaptive_thresholds::record_quality_signal(
337                path,
338                crate::core::threshold_learning::QualitySignal::CleanCompressed,
339            );
340        } else if result.resolved_mode == "full"
341            && result.output_tokens > 2000
342            && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
343        {
344            crate::core::adaptive_thresholds::record_quality_signal(
345                path,
346                crate::core::threshold_learning::QualitySignal::WastedFull,
347            );
348        }
349    }
350
351    // Plugin seam: emit the realized compression stats. Same zero-cost guard.
352    if PluginManager::has_listener("post_compress") {
353        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
354        PluginManager::fire_hook_background(HookPoint::PostCompress {
355            path: path.to_string(),
356            original_tokens,
357            compressed_tokens: result.output_tokens,
358        });
359    }
360
361    // Stigmergy (#540): deposit a Hot scent for this read in the background
362    // (the field file lock may briefly block; never stall the read path), and
363    // surface an active foreign claim as a one-line hint (~10 tokens) so
364    // parallel agents stop duplicating work.
365    {
366        let self_agent = crate::core::scent_field::scent_agent_id();
367        let scent_path = crate::core::pathutil::normalize_tool_path(path);
368        std::thread::spawn(move || {
369            crate::core::scent_field::deposit(
370                self_agent,
371                crate::core::scent_field::ScentKind::Hot,
372                &scent_path,
373                0.3,
374            );
375        });
376        if let Some(hint) = crate::core::scent_field::read_hint(path, self_agent) {
377            result.content.push('\n');
378            result.content.push_str(&hint);
379        }
380    }
381
382    result
383}
384
385/// Attempt to serve a `mode="full"` cache hit (`[unchanged …]`) using only a
386/// shared borrow of the cache.
387///
388/// Returns `None` when the file is not cached, was modified on disk, full
389/// content was never delivered, or the cache policy forbids stubbing — in those
390/// cases the caller must fall back to the write path.
391///
392/// This is the read-locked fast path: it needs no `&mut SessionCache`, so the
393/// dominant "re-read an unchanged file" case proceeds under a shared lock and
394/// parallel reads of distinct files no longer serialize on a global write lock.
395pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
396    let file_ref = cache.get_file_ref_readonly(path)?;
397    let (cached_mtime, cached_hash, read_count, line_count, content_opt) = {
398        let entry = cache.get(path)?;
399        (
400            entry.stored_mtime,
401            entry.hash.clone(),
402            entry.read_count(),
403            entry.line_count,
404            entry.content(),
405        )
406    };
407
408    let no_deg = crate::core::config::Config::load().no_degrade_effective();
409    let prof = crate::core::profiles::active_profile();
410    let force_full = no_deg
411        || (prof.read.default_mode_effective() == "full"
412            && prof.compression.crp_mode_effective() == "off");
413    let policy_allows_stub =
414        crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
415    if !policy_allows_stub
416        || crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
417        || !cache.is_full_delivered(path)
418    {
419        return None;
420    }
421
422    cache.record_cache_hit(path);
423    let short = protocol::shorten_path(path);
424    let out = if crate::core::protocol::meta_visible() {
425        format!(
426            "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
427        )
428    } else {
429        let proof = content_opt
430            .as_deref()
431            .and_then(|c| cache_hit_proof_line(c, read_count));
432        let reads_note = if read_count > 3 {
433            format!(" (read {}x)", read_count + 1)
434        } else {
435            String::new()
436        };
437        match proof {
438            Some(p) => {
439                format!("{file_ref}={short} [unchanged {line_count}L{reads_note} | \"{p}\"]")
440            }
441            None => format!("{file_ref}={short} [unchanged {line_count}L{reads_note}]"),
442        }
443    };
444    let out = crate::core::redaction::redact_text_if_enabled(&out);
445    let sent = count_tokens(&out);
446    Some(ReadOutput {
447        content: out,
448        resolved_mode: "full".into(),
449        output_tokens: sent,
450    })
451}
452
453fn handle_with_options_inner(
454    cache: &mut SessionCache,
455    path: &str,
456    mode: &str,
457    fresh: bool,
458    crp_mode: CrpMode,
459    task: Option<&str>,
460) -> ReadOutput {
461    let file_ref = cache.get_file_ref(path);
462    let short = protocol::shorten_path(path);
463    let ext = Path::new(path)
464        .extension()
465        .and_then(|e| e.to_str())
466        .unwrap_or("");
467
468    if fresh {
469        if mode == "diff" {
470            let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
471            return ReadOutput {
472                content: warning.to_string(),
473                resolved_mode: "diff".into(),
474                output_tokens: count_tokens(warning),
475            };
476        }
477        cache.invalidate(path);
478    }
479
480    if mode == "diff" {
481        let (out, _) = handle_diff(cache, path, &file_ref);
482        let out = crate::core::redaction::redact_text_if_enabled(&out);
483        let sent = count_tokens(&out);
484        return ReadOutput {
485            content: out,
486            resolved_mode: "diff".into(),
487            output_tokens: sent,
488        };
489    }
490
491    if mode != "full"
492        && let Some(existing) = cache.get(path)
493    {
494        let stale = crate::core::cache::is_cache_entry_stale_verified(
495            path,
496            existing.stored_mtime,
497            &existing.hash,
498        );
499        if stale {
500            cache.invalidate(path);
501        }
502    }
503
504    // Snapshot the minimal immutable data the miss paths need, then drop the
505    // borrow before any mutable operations (set_compressed, invalidate, store).
506    let cache_snapshot = cache
507        .get(path)
508        .map(|existing| (existing.original_tokens, existing.content()));
509
510    if let Some((original_tokens, content_opt)) = cache_snapshot {
511        if mode == "full" {
512            // Read-locked stub fast path (single source of truth, shared with
513            // the registered handler's concurrent read-lock attempt).
514            if let Some(out) = try_stub_hit_readonly(cache, path) {
515                return out;
516            }
517            let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
518            let out = crate::core::redaction::redact_text_if_enabled(&out);
519            let sent = count_tokens(&out);
520            return ReadOutput {
521                content: out,
522                resolved_mode: "full".into(),
523                output_tokens: sent,
524            };
525        }
526
527        // Resolve mode first so we can check compressed output cache BEFORE
528        // decompressing the full content (avoids ~2-5ms zstd overhead on hits).
529        let resolved_mode = if mode == "auto" {
530            resolve_auto_mode(path, original_tokens, task)
531        } else {
532            mode.to_string()
533        };
534
535        if is_cacheable_mode(&resolved_mode) {
536            let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
537            let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
538            if let Some(cached_output) = compressed_hit {
539                cache.record_cache_hit(path);
540                let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
541                let sent = count_tokens(&out);
542                return ReadOutput {
543                    content: out,
544                    resolved_mode,
545                    output_tokens: sent,
546                };
547            }
548        }
549
550        if let Some(content) = content_opt {
551            let (out, _) = process_mode(
552                &content,
553                &resolved_mode,
554                &file_ref,
555                &short,
556                ext,
557                original_tokens,
558                crp_mode,
559                path,
560                task,
561            );
562            if is_cacheable_mode(&resolved_mode) {
563                let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
564                cache.set_compressed(path, &cache_key, out.clone());
565            }
566            // Only guard auto-resolved reads; an explicit mode is a deliberate
567            // view we must return verbatim (#361).
568            let out = if mode == "auto" {
569                let framed_tokens = count_tokens(&out);
570                cap_to_raw(out, framed_tokens, &content, original_tokens)
571            } else {
572                out
573            };
574            let out = crate::core::redaction::redact_text_if_enabled(&out);
575            let sent = count_tokens(&out);
576            return ReadOutput {
577                content: out,
578                resolved_mode,
579                output_tokens: sent,
580            };
581        }
582        cache.invalidate(path);
583    }
584
585    let content = match read_file_lossy(path) {
586        Ok(c) => c,
587        Err(e) => {
588            let msg = format!("ERROR: {e}");
589            let tokens = count_tokens(&msg);
590            return ReadOutput {
591                content: msg,
592                resolved_mode: "error".into(),
593                output_tokens: tokens,
594            };
595        }
596    };
597
598    let store_result = cache.store(path, &content);
599
600    // Skip expensive hint computation for line-range reads and first reads.
601    // Hints are only useful from the 2nd read onwards when the file is contextually relevant.
602    let is_line_range = mode.starts_with("lines:");
603    let hints = crate::core::profiles::active_profile().output_hints;
604    let is_repeat_read = store_result.read_count > 1;
605    let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
606        find_similar_and_update_semantic_index(path, &content)
607    } else {
608        None
609    };
610    let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
611        build_graph_related_hint(path)
612    } else {
613        None
614    };
615
616    if mode == "full" {
617        cache.mark_full_delivered(path);
618        let (mut output, _) = format_full_output(
619            &file_ref,
620            &short,
621            ext,
622            &content,
623            store_result.original_tokens,
624            store_result.line_count,
625            task,
626        );
627        if let Some(hint) = &graph_hint {
628            output.push_str(&format!("\n{hint}"));
629        }
630        if let Some(hint) = similar_hint {
631            output.push_str(&format!("\n{hint}"));
632        }
633        let framed_tokens = count_tokens(&output);
634        let output = cap_to_raw(
635            output,
636            framed_tokens,
637            &content,
638            store_result.original_tokens,
639        );
640        let output = crate::core::redaction::redact_text_if_enabled(&output);
641        let sent = count_tokens(&output);
642        return ReadOutput {
643            content: output,
644            resolved_mode: "full".into(),
645            output_tokens: sent,
646        };
647    }
648
649    let resolved_mode = if mode == "auto" {
650        resolve_auto_mode(path, store_result.original_tokens, task)
651    } else {
652        mode.to_string()
653    };
654
655    let (mut output, _sent) = process_mode(
656        &content,
657        &resolved_mode,
658        &file_ref,
659        &short,
660        ext,
661        store_result.original_tokens,
662        crp_mode,
663        path,
664        task,
665    );
666    if let Some(hint) = &graph_hint {
667        output.push_str(&format!("\n{hint}"));
668    }
669    if let Some(hint) = similar_hint {
670        output.push_str(&format!("\n{hint}"));
671    }
672    if is_cacheable_mode(&resolved_mode) {
673        let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
674        cache.set_compressed(path, &cache_key, output.clone());
675    }
676    // Anti-inflation only kicks in when *we* picked the mode (auto). An explicit
677    // map/signatures/lines request is a deliberate view — honour it verbatim,
678    // even on a tiny file where it happens not to save (#361).
679    let output = if mode == "auto" {
680        let framed_tokens = count_tokens(&output);
681        cap_to_raw(
682            output,
683            framed_tokens,
684            &content,
685            store_result.original_tokens,
686        )
687    } else {
688        output
689    };
690    let output = crate::core::redaction::redact_text_if_enabled(&output);
691    let final_tokens = count_tokens(&output);
692    ReadOutput {
693        content: output,
694        resolved_mode,
695        output_tokens: final_tokens,
696    }
697}
698
699pub fn is_instruction_file(path: &str) -> bool {
700    let lower = path.to_lowercase();
701    let filename = std::path::Path::new(&lower)
702        .file_name()
703        .and_then(|f| f.to_str())
704        .unwrap_or("");
705
706    matches!(
707        filename,
708        "skill.md"
709            | "agents.md"
710            | "rules.md"
711            | ".cursorrules"
712            | ".clinerules"
713            | "lean-ctx.md"
714            | "lean-ctx.mdc"
715    ) || lower.contains("/skills/")
716        || lower.contains("/.cursor/rules/")
717        || lower.contains("/.claude/rules/")
718        || lower.contains("/agents.md")
719}
720
721/// #361 anti-inflation invariant: a `ctx_read` must never cost more tokens than
722/// reading the raw file would. Framing (file-ref header, deps/exports summary,
723/// savings footer, navigation hints) only earns its keep on large files and
724/// repeated reads — on a cold read of a small file it is pure overhead, the
725/// exact inflation an independent benchmark measured (#361). When the framed
726/// payload exceeds the bare content we ship the content verbatim, so a read is
727/// break-even at worst and a win whenever a compressed mode or a cached re-read
728/// applies. Re-reads are unaffected: the cache keys on path and re-derives the
729/// file ref, so dropping the cold header here costs nothing on the next read.
730///
731/// `framed_tokens` and `raw_tokens` are both measured pre-redaction (redaction
732/// is roughly token-neutral and applied to whichever string wins), so the
733/// comparison is apples-to-apples with `original_tokens`. Empty files
734/// (`raw_tokens == 0`) keep their framing so the reader still gets a signal.
735fn cap_to_raw(
736    framed: String,
737    framed_tokens: usize,
738    raw_content: &str,
739    raw_tokens: usize,
740) -> String {
741    if raw_tokens > 0 && framed_tokens > raw_tokens {
742        raw_content.to_string()
743    } else {
744        framed
745    }
746}
747
748/// Delegates to the unified `auto_mode_resolver::resolve()`.
749fn resolve_auto_mode(file_path: &str, original_tokens: usize, task: Option<&str>) -> String {
750    let ctx = crate::core::auto_mode_resolver::AutoModeContext {
751        path: file_path,
752        token_count: original_tokens,
753        task,
754        cache: None,
755    };
756    crate::core::auto_mode_resolver::resolve(&ctx).mode
757}
758
759fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
760    const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
761
762    if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
763        return None;
764    }
765
766    let cfg = crate::core::config::Config::load();
767    let profile = crate::core::config::MemoryProfile::effective(&cfg);
768    if !profile.semantic_cache_enabled() {
769        return None;
770    }
771
772    let project_root = detect_project_root(path);
773    let session_id = format!("{}", std::process::id());
774    let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
775
776    let similar = index.find_similar(content, 0.7);
777    let relevant: Vec<_> = similar
778        .into_iter()
779        .filter(|(p, _)| p != path)
780        .take(3)
781        .collect();
782
783    index.add_file(path, content, &session_id);
784    if let Err(e) = index.save(&project_root) {
785        tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
786    }
787
788    if relevant.is_empty() {
789        return None;
790    }
791
792    let hints: Vec<String> = relevant
793        .iter()
794        .map(|(p, score)| format!("  {p} ({:.0}% similar)", score * 100.0))
795        .collect();
796
797    Some(format!(
798        "[semantic: {} similar file(s) in cache]\n{}",
799        relevant.len(),
800        hints.join("\n")
801    ))
802}
803
804fn detect_project_root(path: &str) -> String {
805    crate::core::protocol::detect_project_root_or_cwd(path)
806}
807
808fn build_graph_related_hint(path: &str) -> Option<String> {
809    let project_root = detect_project_root(path);
810    crate::core::graph_context::build_related_hint(path, &project_root, 5)
811}
812
813const AUTO_DELTA_THRESHOLD: f64 = 0.6;
814
815/// Re-reads from disk; if content changed and delta is compact, sends auto-delta.
816fn handle_full_with_auto_delta(
817    cache: &mut SessionCache,
818    path: &str,
819    file_ref: &str,
820    short: &str,
821    ext: &str,
822    task: Option<&str>,
823) -> (String, usize) {
824    let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
825    let Ok(disk_content) = read_file_lossy(path) else {
826        cache.record_cache_hit(path);
827        if let Some(existing) = cache.get(path) {
828            if !crate::core::protocol::meta_visible()
829                && let Some(cached) = existing.content()
830            {
831                return format_full_output(
832                    file_ref,
833                    short,
834                    ext,
835                    &cached,
836                    existing.original_tokens,
837                    existing.line_count,
838                    task,
839                );
840            }
841            let out = format!(
842                "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
843                existing.read_count(),
844                existing.line_count
845            );
846            let sent = count_tokens(&out);
847            return (out, sent);
848        }
849        let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
850            format!("[file read failed and no cached version available] {file_ref}={short}")
851        } else {
852            format!("[file read failed and no cached version available] {short}")
853        };
854        let sent = count_tokens(&out);
855        return (out, sent);
856    };
857
858    let no_deg = crate::core::config::Config::load().no_degrade_effective();
859    let prof = crate::core::profiles::active_profile();
860    let force_full = no_deg
861        || (prof.read.default_mode_effective() == "full"
862            && prof.compression.crp_mode_effective() == "off");
863
864    let old_content = cache
865        .get(path)
866        .and_then(crate::core::cache::CacheEntry::content)
867        .unwrap_or_default();
868    let store_result = cache.store(path, &disk_content);
869
870    if store_result.was_hit {
871        let policy_allows_stub =
872            crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
873        if policy_allows_stub && store_result.full_content_delivered {
874            let out = if crate::core::protocol::meta_visible() {
875                format!(
876                    "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
877                    store_result.line_count
878                )
879            } else {
880                let proof = cache_hit_proof_line(&disk_content, store_result.read_count);
881                let reads_note = if store_result.read_count > 3 {
882                    format!(" (read {}x)", store_result.read_count)
883                } else {
884                    String::new()
885                };
886                match proof {
887                    Some(p) => format!(
888                        "{file_ref}={short} [unchanged {}L{reads_note} | \"{p}\"]",
889                        store_result.line_count
890                    ),
891                    None => format!(
892                        "{file_ref}={short} [unchanged {}L{reads_note}]",
893                        store_result.line_count
894                    ),
895                }
896            };
897            let sent = count_tokens(&out);
898            return (out, sent);
899        }
900        cache.mark_full_delivered(path);
901        return format_full_output(
902            file_ref,
903            short,
904            ext,
905            &disk_content,
906            store_result.original_tokens,
907            store_result.line_count,
908            task,
909        );
910    }
911
912    let diff = compressor::diff_content(&old_content, &disk_content);
913    let diff_tokens = count_tokens(&diff);
914    let full_tokens = store_result.original_tokens;
915
916    if !force_full
917        && full_tokens > 0
918        && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
919    {
920        let savings = protocol::format_savings(full_tokens, diff_tokens);
921        let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
922            format!("{file_ref}={short}")
923        } else {
924            short.to_string()
925        };
926        let out = format!(
927            "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
928            disk_content.lines().count()
929        );
930        return (out, diff_tokens);
931    }
932
933    format_full_output(
934        file_ref,
935        short,
936        ext,
937        &disk_content,
938        store_result.original_tokens,
939        store_result.line_count,
940        task,
941    )
942}
943
944fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
945    let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
946    let short = protocol::shorten_path(path);
947    let old_content = cache
948        .get(path)
949        .and_then(crate::core::cache::CacheEntry::content);
950
951    let new_content = match read_file_lossy(path) {
952        Ok(c) => c,
953        Err(e) => {
954            let msg = format!("ERROR: {e}");
955            let tokens = count_tokens(&msg);
956            return (msg, tokens);
957        }
958    };
959
960    let original_tokens = count_tokens(&new_content);
961
962    let diff_output = if let Some(old) = &old_content {
963        compressor::diff_content(old, &new_content)
964    } else {
965        // No previous version cached — store content for future diffs but
966        // return a short guidance message instead of dumping the full file.
967        cache.store(path, &new_content);
968        let msg = format!(
969            "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
970        );
971        let sent = count_tokens(&msg);
972        return (msg, sent);
973    };
974
975    cache.store(path, &new_content);
976
977    let sent = count_tokens(&diff_output);
978    let savings = protocol::format_savings(original_tokens, sent);
979    let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
980        format!("{file_ref}={short}")
981    } else {
982        short.clone()
983    };
984    (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
985}