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
38/// `#361` anti-inflation capping applies to whole-file views (`full` and the
39/// lossy summaries `map`/`signatures`/`aggressive`/`entropy`/`task`/…), where the
40/// raw file is a strict superset of the information and is therefore never a
41/// worse answer when the framing happens to inflate on a small file. `full` is
42/// included: an `auto` read can resolve to `full` and reach this path, and its
43/// header must not push the cost above raw. Selection and delta views have
44/// view-specific semantics — `lines:` returns a window, `reference` a pointer,
45/// `diff` a delta, `raw` the bytes — so replacing them with the whole file would
46/// be wrong, not cheaper, and they are never capped.
47fn mode_allows_raw_cap(mode: &str) -> bool {
48    !(mode.starts_with("lines:") || matches!(mode, "reference" | "diff" | "raw"))
49}
50
51fn compressed_cache_key(
52    mode: &str,
53    crp_mode: CrpMode,
54    task: Option<&str>,
55    aggressiveness: Option<f64>,
56    protect: &[String],
57) -> String {
58    // Bump when the rendered map/signatures body changes shape so stale
59    // pre-line-range entries are not served from an older session cache.
60    let versioned_mode = match mode {
61        "map" => "map:v2",
62        "signatures" => "signatures:v2",
63        _ => mode,
64    };
65    let base = if crp_mode.is_tdd() {
66        format!("{versioned_mode}:tdd")
67    } else {
68        versioned_mode.to_string()
69    };
70    // map/signatures output now embeds a task-relevant body, so task-aware and
71    // task-free variants must cache under distinct keys.
72    let keyed = match task.map(str::trim).filter(|t| !t.is_empty()) {
73        Some(t) => {
74            use std::hash::{Hash, Hasher};
75            let mut h = std::collections::hash_map::DefaultHasher::new();
76            t.hash(&mut h);
77            format!("{base}:t{:x}", h.finish())
78        }
79        None => base,
80    };
81    // Aggressiveness and the explicit protect list both change lossy output, so
82    // both must change the key (#498). Empty fragments keep pre-feature keys
83    // byte-identical, so unmodified reads still hit their existing cache entries.
84    let mut key = keyed;
85    let aggr_frag = crate::core::aggressiveness::cache_fragment(aggressiveness);
86    if !aggr_frag.is_empty() {
87        key = format!("{key}:{aggr_frag}");
88    }
89    let protect_frag = crate::core::protect::protect_fragment(protect);
90    if !protect_frag.is_empty() {
91        key = format!("{key}:{protect_frag}");
92    }
93    key
94}
95
96fn append_compressed_hint(output: &str, file_path: &str) -> String {
97    if !crate::core::profiles::active_profile()
98        .output_hints
99        .compressed_hint()
100    {
101        return output.to_string();
102    }
103    format!(
104        "{output}\n{COMPRESSED_HINT}\n  ctx_read(\"{file_path}\", mode=\"full\") | ctx_retrieve(\"{file_path}\")"
105    )
106}
107
108/// Reads a file as UTF-8 with lossy fallback, enforcing binary detection and max read size limit.
109/// Defense-in-depth: verifies that the canonical path stays within the process's project root
110/// (if determinable) even though callers SHOULD have already jail-checked the path.
111pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
112    if crate::core::binary_detect::is_binary_file(path) {
113        let msg = crate::core::binary_detect::binary_file_message(path);
114        return Err(std::io::Error::other(msg));
115    }
116
117    {
118        let canonical =
119            crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
120        if let Ok(cwd) = std::env::current_dir() {
121            let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
122            if !canonical.starts_with(&root) {
123                let allow = crate::core::pathjail::allow_paths_from_env_and_config();
124                let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
125                    .ok()
126                    .is_some_and(|d| canonical.starts_with(d));
127                let tmp_ok = canonical.starts_with(std::env::temp_dir());
128                if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
129                    tracing::warn!(
130                        "defense-in-depth: path may escape project root: {}",
131                        canonical.display()
132                    );
133                }
134            }
135        }
136    }
137
138    let cap = crate::core::limits::max_read_bytes();
139
140    let file = open_with_retry(path)?;
141    let meta = file
142        .metadata()
143        .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
144    if meta.len() > cap as u64 {
145        return Err(std::io::Error::other(format!(
146            "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
147             Increase the limit or use a line-range read: mode=\"lines:1-100\"",
148            meta.len(),
149            cap
150        )));
151    }
152
153    use std::io::Read;
154    let mut bytes = Vec::with_capacity(meta.len() as usize);
155    std::io::BufReader::new(file).read_to_end(&mut bytes)?;
156    match String::from_utf8(bytes) {
157        Ok(s) => Ok(s),
158        Err(e) => Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()),
159    }
160}
161
162/// Opens a file, retrying once after a brief pause on NotFound.
163/// Works around overlay/FUSE stat-cache races in container runtimes (Docker, Codex).
164/// Uses O_NOFOLLOW on Unix for TOCTOU symlink protection.
165fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
166    match open_nofollow(path) {
167        Ok(f) => Ok(f),
168        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
169            std::thread::sleep(std::time::Duration::from_millis(50));
170            open_nofollow(path).map_err(|e| {
171                if e.kind() == std::io::ErrorKind::NotFound {
172                    std::io::Error::other(format!(
173                        "file not found: {path} — verify the path with ctx_tree or ctx_search"
174                    ))
175                } else {
176                    e
177                }
178            })
179        }
180        Err(e) => Err(e),
181    }
182}
183
184#[cfg(unix)]
185fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
186    use std::os::unix::fs::OpenOptionsExt;
187    use std::path::Path;
188
189    let p = Path::new(path);
190    // Canonicalize the parent directory (resolving symlinks in the directory path)
191    // but apply O_NOFOLLOW only to the final file component. This prevents
192    // symlink-following attacks on the target file while allowing legitimate
193    // directory symlinks (e.g., /tmp → /private/tmp on macOS).
194    if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
195        && parent.exists()
196    {
197        let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
198        let canonical_path = canonical_parent.join(filename);
199        return std::fs::OpenOptions::new()
200            .read(true)
201            .custom_flags(libc::O_NOFOLLOW)
202            .open(&canonical_path);
203    }
204
205    // Fallback: direct open with O_NOFOLLOW
206    std::fs::OpenOptions::new()
207        .read(true)
208        .custom_flags(libc::O_NOFOLLOW)
209        .open(path)
210}
211
212#[cfg(not(unix))]
213fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
214    std::fs::File::open(path)
215}
216
217/// Reads a file through the cache and applies the requested compression mode.
218pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
219    handle_with_options(cache, path, mode, false, crp_mode, None)
220}
221
222/// Like `handle`, but invalidates the cache first to force a fresh disk read.
223pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
224    handle_with_options(cache, path, mode, true, crp_mode, None)
225}
226
227/// Reads a file with task-aware filtering to prioritize task-relevant content.
228pub fn handle_with_task(
229    cache: &mut SessionCache,
230    path: &str,
231    mode: &str,
232    crp_mode: CrpMode,
233    task: Option<&str>,
234) -> String {
235    handle_with_options(cache, path, mode, false, crp_mode, task)
236}
237
238/// Like `handle_with_task`, also returns the resolved mode name and pre-counted tokens.
239pub fn handle_with_task_resolved(
240    cache: &mut SessionCache,
241    path: &str,
242    mode: &str,
243    crp_mode: CrpMode,
244    task: Option<&str>,
245) -> ReadOutput {
246    handle_with_options_resolved(
247        cache,
248        path,
249        mode,
250        false,
251        crp_mode,
252        task,
253        ReadTuning::resolve(None, &[]),
254    )
255}
256
257/// Like [`handle_with_task_resolved`] but with an explicit per-call
258/// aggressiveness (the `ctx_read` `aggressiveness` arg, #714). `None` falls back
259/// to the `LEAN_CTX_AGGRESSIVENESS` env var / config field.
260pub fn handle_with_task_resolved_tuned(
261    cache: &mut SessionCache,
262    path: &str,
263    mode: &str,
264    crp_mode: CrpMode,
265    task: Option<&str>,
266    aggressiveness: Option<f64>,
267    protect: &[String],
268) -> ReadOutput {
269    handle_with_options_resolved(
270        cache,
271        path,
272        mode,
273        false,
274        crp_mode,
275        task,
276        ReadTuning::resolve(aggressiveness, protect),
277    )
278}
279
280/// Fresh read with task-aware filtering (invalidates cache first).
281pub fn handle_fresh_with_task(
282    cache: &mut SessionCache,
283    path: &str,
284    mode: &str,
285    crp_mode: CrpMode,
286    task: Option<&str>,
287) -> String {
288    handle_with_options(cache, path, mode, true, crp_mode, task)
289}
290
291/// Fresh read with task-aware filtering, also returns the resolved mode name and pre-counted tokens.
292pub fn handle_fresh_with_task_resolved(
293    cache: &mut SessionCache,
294    path: &str,
295    mode: &str,
296    crp_mode: CrpMode,
297    task: Option<&str>,
298) -> ReadOutput {
299    handle_with_options_resolved(
300        cache,
301        path,
302        mode,
303        true,
304        crp_mode,
305        task,
306        ReadTuning::resolve(None, &[]),
307    )
308}
309
310/// Fresh-read variant of [`handle_with_task_resolved_tuned`] (#714).
311pub fn handle_fresh_with_task_resolved_tuned(
312    cache: &mut SessionCache,
313    path: &str,
314    mode: &str,
315    crp_mode: CrpMode,
316    task: Option<&str>,
317    aggressiveness: Option<f64>,
318    protect: &[String],
319) -> ReadOutput {
320    handle_with_options_resolved(
321        cache,
322        path,
323        mode,
324        true,
325        crp_mode,
326        task,
327        ReadTuning::resolve(aggressiveness, protect),
328    )
329}
330
331fn handle_with_options(
332    cache: &mut SessionCache,
333    path: &str,
334    mode: &str,
335    fresh: bool,
336    crp_mode: CrpMode,
337    task: Option<&str>,
338) -> String {
339    handle_with_options_resolved(
340        cache,
341        path,
342        mode,
343        fresh,
344        crp_mode,
345        task,
346        ReadTuning::resolve(None, &[]),
347    )
348    .content
349}
350
351/// Detects if the current execution context is a subagent (forked agent).
352/// Subagents inherit stale parent caches, so force-fresh prevents VERIFY FAIL.
353fn is_subagent_context() -> bool {
354    static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
355    *IS_SUBAGENT.get_or_init(|| {
356        if std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true") {
357            return true;
358        }
359        std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
360    })
361}
362
363fn handle_with_options_resolved(
364    cache: &mut SessionCache,
365    path: &str,
366    mode: &str,
367    fresh: bool,
368    crp_mode: CrpMode,
369    task: Option<&str>,
370    tuning: ReadTuning<'_>,
371) -> ReadOutput {
372    let effective_fresh = fresh || is_subagent_context();
373
374    // Plugin seam: notify listeners before the read resolves. Guarded so the hot
375    // path never allocates or spawns a thread unless a plugin opts into pre_read.
376    if PluginManager::has_listener("pre_read") {
377        PluginManager::fire_hook_background(HookPoint::PreRead {
378            path: path.to_string(),
379        });
380    }
381
382    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
383        bt.next_seq();
384    }
385    let mut result =
386        handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task, tuning);
387
388    if let Some(entry) = cache.get_mut(path) {
389        entry.last_mode.clone_from(&result.resolved_mode);
390    }
391
392    let dedup_allowed = matches!(
393        result.resolved_mode.as_str(),
394        "map" | "signatures" | "aggressive" | "entropy" | "task"
395    );
396    if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
397        let new_tokens = count_tokens(&deduped);
398        if new_tokens < result.output_tokens {
399            result.content = deduped;
400            result.output_tokens = new_tokens;
401        }
402    }
403
404    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
405        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
406        bt.record_read(
407            path,
408            &result.resolved_mode,
409            result.output_tokens,
410            original_tokens,
411        );
412
413        // Quality signals (#538): compressed reads count as clean until a
414        // bounce proves otherwise (the bounce signal outweighs 6:1); large
415        // full reads of never-bouncing extensions are wasted compression
416        // opportunities and push the learned threshold up.
417        let compressed = !matches!(result.resolved_mode.as_str(), "full" | "diff" | "lines");
418        if compressed {
419            crate::core::adaptive_thresholds::record_quality_signal(
420                path,
421                crate::core::threshold_learning::QualitySignal::CleanCompressed,
422            );
423        } else if result.resolved_mode == "full"
424            && result.output_tokens > 2000
425            && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
426        {
427            crate::core::adaptive_thresholds::record_quality_signal(
428                path,
429                crate::core::threshold_learning::QualitySignal::WastedFull,
430            );
431        }
432    }
433
434    // Plugin seam: emit the realized compression stats. Same zero-cost guard.
435    if PluginManager::has_listener("post_compress") {
436        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
437        PluginManager::fire_hook_background(HookPoint::PostCompress {
438            path: path.to_string(),
439            original_tokens,
440            compressed_tokens: result.output_tokens,
441        });
442    }
443
444    // Stigmergy (#540): deposit a Hot scent for this read in the background
445    // (the field file lock may briefly block; never stall the read path). The
446    // foreign-claim hint is intentionally NOT appended to the body: it carries a
447    // relative timestamp ("claimed Nm ago"), which would make the output a
448    // non-pure function of wall-clock time and defeat provider prompt caching
449    // (#498). The deposit remains so the field still reflects active work.
450    {
451        let self_agent = crate::core::scent_field::scent_agent_id();
452        let scent_path = crate::core::pathutil::normalize_tool_path(path);
453        std::thread::spawn(move || {
454            crate::core::scent_field::deposit(
455                self_agent,
456                crate::core::scent_field::ScentKind::Hot,
457                &scent_path,
458                0.3,
459            );
460        });
461    }
462
463    result
464}
465
466/// Attempt to serve a `mode="full"` cache hit (`[unchanged …]`) using only a
467/// shared borrow of the cache.
468///
469/// Returns `None` when the file is not cached, was modified on disk, full
470/// content was never delivered, or the cache policy forbids stubbing — in those
471/// cases the caller must fall back to the write path.
472///
473/// This is the read-locked fast path: it needs no `&mut SessionCache`, so the
474/// dominant "re-read an unchanged file" case proceeds under a shared lock and
475/// parallel reads of distinct files no longer serialize on a global write lock.
476pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
477    let file_ref = cache.get_file_ref_readonly(path)?;
478    let (cached_mtime, cached_hash, line_count) = {
479        let entry = cache.get(path)?;
480        (entry.stored_mtime, entry.hash.clone(), entry.line_count)
481    };
482
483    let no_deg = crate::core::config::Config::load().no_degrade_effective();
484    let prof = crate::core::profiles::active_profile();
485    let force_full = no_deg
486        || (prof.read.default_mode_effective() == "full"
487            && prof.compression.crp_mode_effective() == "off");
488    let policy_allows_stub =
489        crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
490    if !policy_allows_stub
491        || crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
492        || !cache.is_full_delivered(path)
493    {
494        return None;
495    }
496
497    cache.record_cache_hit(path);
498    let short = protocol::shorten_path(path);
499    let out = if crate::core::protocol::meta_visible() {
500        format!(
501            "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
502        )
503    } else {
504        // #498 determinism: the cache-hit stub is a pure function of (content,
505        // path) so identical re-reads stay byte-stable and provider prompt
506        // caching applies. Rotating proof lines and read-count notes are
507        // intentionally omitted from the body.
508        format!("{file_ref}={short} [unchanged {line_count}L]")
509    };
510    let out = crate::core::redaction::redact_text_if_enabled(&out);
511    let sent = count_tokens(&out);
512    Some(ReadOutput {
513        content: out,
514        resolved_mode: "full".into(),
515        output_tokens: sent,
516    })
517}
518
519/// Outcome of [`resolve_explicit_delta_mode`]: the (possibly rewritten) read
520/// mode plus an optional advisory note to surface to the agent.
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct DeltaExplicitDecision {
523    /// The mode the read should proceed with (rewritten only when the feature
524    /// fires; otherwise the caller's mode, unchanged).
525    pub mode: String,
526    /// A byte-stable advisory appended to the read body when the mode was
527    /// rewritten to `diff`. `None` when nothing was rewritten or the collapse
528    /// was a silent `lines:`→`full` stub.
529    pub note: Option<String>,
530}
531
532/// Decide whether an **explicit** `full`/`lines:N-M` re-read of a session-cached
533/// file should be served as a delta instead of re-emitting content the model
534/// already holds (the `delta_explicit` opt-in; env `LCTX_DELTA_EXPLICIT`).
535///
536/// Returns the mode the read should proceed with:
537/// - **Changed on disk** (verified mtime+md5 stale) and full content is cached →
538///   `diff`, plus an advisory note. The diff carries exactly the new
539///   information in a fraction of the tokens.
540/// - **Unchanged** and the request is `lines:` of an already-fully-delivered
541///   file → `full`, so the read collapses to the ~15-token `[unchanged]` stub
542///   instead of re-extracting a window the model has seen.
543/// - Otherwise the caller's `mode` is returned untouched.
544///
545/// First reads (nothing cached) and `fresh=true` are never affected — the
546/// caller gates those before calling. Staleness uses the **verified** variant
547/// ([`crate::core::cache::is_cache_entry_stale_verified`]) so a same-second
548/// write on a coarse-granularity filesystem cannot be mistaken for "unchanged"
549/// and yield a misleading empty diff (#498 determinism).
550///
551/// Pure w.r.t. (cache, path, mode, enabled): no wall-clock, counters, or
552/// randomness enter the result, so identical inputs stay byte-stable.
553pub fn resolve_explicit_delta_mode(
554    cache: &SessionCache,
555    path: &str,
556    mode: &str,
557    explicit_mode: bool,
558    fresh: bool,
559    enabled: bool,
560) -> DeltaExplicitDecision {
561    let unchanged = DeltaExplicitDecision {
562        mode: mode.to_string(),
563        note: None,
564    };
565    if fresh || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
566        return unchanged;
567    }
568    let Some(entry) = cache.get(path) else {
569        // First read this session — nothing to diff against.
570        return unchanged;
571    };
572    let stale =
573        crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
574    if stale {
575        // Only divert to a diff when full content is actually cached: the diff
576        // base is that full content (see `handle_diff`), never a compressed
577        // view. Without it, `handle_diff` would have nothing to compare.
578        if entry.content().is_some() {
579            return DeltaExplicitDecision {
580                mode: "diff".to_string(),
581                note: Some(format!(
582                    "[delta-explicit] requested mode={mode} served as a diff: the file \
583                     changed since your last read and the diff is the new information. \
584                     Pass fresh=true if you need the full content re-emitted."
585                )),
586            };
587        }
588        return unchanged;
589    }
590    // Unchanged on disk: a `lines:` window of a file already delivered in full
591    // re-emits text the model holds — collapse to the full-mode stub
592    // (~15 tokens). A plain `full` re-read already hits that stub downstream.
593    if mode.starts_with("lines:") && cache.is_full_delivered(path) {
594        return DeltaExplicitDecision {
595            mode: "full".to_string(),
596            note: None,
597        };
598    }
599    unchanged
600}
601
602fn handle_with_options_inner(
603    cache: &mut SessionCache,
604    path: &str,
605    mode: &str,
606    fresh: bool,
607    crp_mode: CrpMode,
608    task: Option<&str>,
609    tuning: ReadTuning<'_>,
610) -> ReadOutput {
611    let file_ref = cache.get_file_ref(path);
612    let short = protocol::shorten_path(path);
613    let ext = Path::new(path)
614        .extension()
615        .and_then(|e| e.to_str())
616        .unwrap_or("");
617
618    if fresh {
619        if mode == "diff" {
620            let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
621            return ReadOutput {
622                content: warning.to_string(),
623                resolved_mode: "diff".into(),
624                output_tokens: count_tokens(warning),
625            };
626        }
627        cache.invalidate(path);
628    }
629
630    if mode == "diff" {
631        let (out, _) = handle_diff(cache, path, &file_ref);
632        let out = crate::core::redaction::redact_text_if_enabled(&out);
633        let sent = count_tokens(&out);
634        return ReadOutput {
635            content: out,
636            resolved_mode: "diff".into(),
637            output_tokens: sent,
638        };
639    }
640
641    if mode != "full"
642        && let Some(existing) = cache.get(path)
643    {
644        let stale = crate::core::cache::is_cache_entry_stale_verified(
645            path,
646            existing.stored_mtime,
647            &existing.hash,
648        );
649        if stale {
650            cache.invalidate(path);
651        }
652    }
653
654    // Snapshot the minimal immutable data the miss paths need, then drop the
655    // borrow before any mutable operations (set_compressed, invalidate, store).
656    let cache_snapshot = cache
657        .get(path)
658        .map(|existing| (existing.original_tokens, existing.content()));
659
660    if let Some((original_tokens, content_opt)) = cache_snapshot {
661        if mode == "full" {
662            // Read-locked stub fast path (single source of truth, shared with
663            // the registered handler's concurrent read-lock attempt).
664            if let Some(out) = try_stub_hit_readonly(cache, path) {
665                return out;
666            }
667            let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
668            let out = crate::core::redaction::redact_text_if_enabled(&out);
669            let sent = count_tokens(&out);
670            return ReadOutput {
671                content: out,
672                resolved_mode: "full".into(),
673                output_tokens: sent,
674            };
675        }
676
677        // Resolve mode first so we can check compressed output cache BEFORE
678        // decompressing the full content (avoids ~2-5ms zstd overhead on hits).
679        // The aggressiveness knob (#714) routes `auto` through the density path
680        // so one number drives whole-file intensity; else the learned resolver.
681        let resolved_mode = if mode == "auto" {
682            tuning
683                .auto_density_mode()
684                .unwrap_or_else(|| resolve_auto_mode(path, original_tokens, task))
685        } else {
686            mode.to_string()
687        };
688
689        if is_cacheable_mode(&resolved_mode) {
690            let cache_key = compressed_cache_key(
691                &resolved_mode,
692                crp_mode,
693                task,
694                tuning.aggressiveness,
695                tuning.protect,
696            );
697            let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
698            if let Some(cached_output) = compressed_hit {
699                cache.record_cache_hit(path);
700                let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
701                let sent = count_tokens(&out);
702                return ReadOutput {
703                    content: out,
704                    resolved_mode,
705                    output_tokens: sent,
706                };
707            }
708        }
709
710        if let Some(content) = content_opt {
711            let (out, _) = process_mode_tuned(
712                &content,
713                &resolved_mode,
714                &file_ref,
715                &short,
716                ext,
717                original_tokens,
718                crp_mode,
719                path,
720                task,
721                tuning,
722            );
723            // #361 anti-inflation for lossy whole-file summaries (auto OR
724            // explicit): map/signatures/… must never cost more than the raw file.
725            // Selection/delta views keep their exact shape (see
726            // mode_allows_raw_cap). Cap before caching so re-read hits serve the
727            // same capped, byte-stable body.
728            let out = if mode_allows_raw_cap(&resolved_mode) {
729                let framed_tokens = count_tokens(&out);
730                cap_to_raw(out, framed_tokens, &content, original_tokens)
731            } else {
732                out
733            };
734            if is_cacheable_mode(&resolved_mode) {
735                let cache_key = compressed_cache_key(
736                    &resolved_mode,
737                    crp_mode,
738                    task,
739                    tuning.aggressiveness,
740                    tuning.protect,
741                );
742                cache.set_compressed(path, &cache_key, out.clone());
743            }
744            let out = crate::core::redaction::redact_text_if_enabled(&out);
745            let sent = count_tokens(&out);
746            return ReadOutput {
747                content: out,
748                resolved_mode,
749                output_tokens: sent,
750            };
751        }
752        cache.invalidate(path);
753    }
754
755    let content = match read_file_lossy(path) {
756        Ok(c) => c,
757        Err(e) => {
758            let msg = format!("ERROR: {e}");
759            let tokens = count_tokens(&msg);
760            return ReadOutput {
761                content: msg,
762                resolved_mode: "error".into(),
763                output_tokens: tokens,
764            };
765        }
766    };
767
768    let store_result = cache.store(path, &content);
769
770    // Skip expensive hint computation for line-range reads and first reads.
771    // Hints are only useful from the 2nd read onwards when the file is contextually relevant.
772    let is_line_range = mode.starts_with("lines:");
773    let hints = crate::core::profiles::active_profile().output_hints;
774    let is_repeat_read = store_result.read_count > 1;
775    let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
776        find_similar_and_update_semantic_index(path, &content)
777    } else {
778        None
779    };
780    let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
781        build_graph_related_hint(path)
782    } else {
783        None
784    };
785
786    if mode == "full" {
787        cache.mark_full_delivered(path);
788        let (mut output, _) = format_full_output(
789            &file_ref,
790            &short,
791            ext,
792            &content,
793            store_result.original_tokens,
794            store_result.line_count,
795            task,
796        );
797        if let Some(hint) = &graph_hint {
798            output.push_str(&format!("\n{hint}"));
799        }
800        if let Some(hint) = similar_hint {
801            output.push_str(&format!("\n{hint}"));
802        }
803        let framed_tokens = count_tokens(&output);
804        let output = cap_to_raw(
805            output,
806            framed_tokens,
807            &content,
808            store_result.original_tokens,
809        );
810        let output = crate::core::redaction::redact_text_if_enabled(&output);
811        let sent = count_tokens(&output);
812        return ReadOutput {
813            content: output,
814            resolved_mode: "full".into(),
815            output_tokens: sent,
816        };
817    }
818
819    let resolved_mode = if mode == "auto" {
820        tuning
821            .auto_density_mode()
822            .unwrap_or_else(|| resolve_auto_mode(path, store_result.original_tokens, task))
823    } else {
824        mode.to_string()
825    };
826
827    let (output, _sent) = process_mode_tuned(
828        &content,
829        &resolved_mode,
830        &file_ref,
831        &short,
832        ext,
833        store_result.original_tokens,
834        crp_mode,
835        path,
836        task,
837        tuning,
838    );
839    // #361 anti-inflation for lossy whole-file summaries (auto OR explicit);
840    // selection/delta views keep their exact shape (see mode_allows_raw_cap).
841    // Cap first, then cache the pure capped body so re-reads stay byte-stable
842    // (#498) — the optional, read-state-dependent navigation hints below are
843    // appended to the returned value only, never to the cached body.
844    let mut output = if mode_allows_raw_cap(&resolved_mode) {
845        let framed_tokens = count_tokens(&output);
846        cap_to_raw(
847            output,
848            framed_tokens,
849            &content,
850            store_result.original_tokens,
851        )
852    } else {
853        output
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, output.clone());
864    }
865    if let Some(hint) = &graph_hint {
866        output.push_str(&format!("\n{hint}"));
867    }
868    if let Some(hint) = similar_hint {
869        output.push_str(&format!("\n{hint}"));
870    }
871    let output = crate::core::redaction::redact_text_if_enabled(&output);
872    let final_tokens = count_tokens(&output);
873    ReadOutput {
874        content: output,
875        resolved_mode,
876        output_tokens: final_tokens,
877    }
878}
879
880pub fn is_instruction_file(path: &str) -> bool {
881    let lower = path.to_lowercase();
882    let filename = std::path::Path::new(&lower)
883        .file_name()
884        .and_then(|f| f.to_str())
885        .unwrap_or("");
886
887    matches!(
888        filename,
889        "skill.md"
890            | "agents.md"
891            | "rules.md"
892            | ".cursorrules"
893            | ".clinerules"
894            | "lean-ctx.md"
895            | "lean-ctx.mdc"
896    ) || lower.contains("/skills/")
897        || lower.contains("/.cursor/rules/")
898        || lower.contains("/.claude/rules/")
899        || lower.contains("/agents.md")
900}
901
902/// #361 anti-inflation invariant: a `ctx_read` must never cost more tokens than
903/// reading the raw file would. Framing (file-ref header, deps/exports summary,
904/// savings footer, navigation hints) only earns its keep on large files and
905/// repeated reads — on a cold read of a small file it is pure overhead, the
906/// exact inflation an independent benchmark measured (#361). When the framed
907/// payload exceeds the bare content we ship the content verbatim, so a read is
908/// break-even at worst and a win whenever a compressed mode or a cached re-read
909/// applies. Re-reads are unaffected: the cache keys on path and re-derives the
910/// file ref, so dropping the cold header here costs nothing on the next read.
911///
912/// `framed_tokens` and `raw_tokens` are both measured pre-redaction (redaction
913/// is roughly token-neutral and applied to whichever string wins), so the
914/// comparison is apples-to-apples with `original_tokens`. Empty files
915/// (`raw_tokens == 0`) keep their framing so the reader still gets a signal.
916fn cap_to_raw(
917    framed: String,
918    framed_tokens: usize,
919    raw_content: &str,
920    raw_tokens: usize,
921) -> String {
922    if raw_tokens > 0 && framed_tokens > raw_tokens {
923        raw_content.to_string()
924    } else {
925        framed
926    }
927}
928
929/// Delegates to the unified `auto_mode_resolver::resolve()`.
930fn resolve_auto_mode(file_path: &str, original_tokens: usize, task: Option<&str>) -> String {
931    let ctx = crate::core::auto_mode_resolver::AutoModeContext {
932        path: file_path,
933        token_count: original_tokens,
934        task,
935        cache: None,
936    };
937    crate::core::auto_mode_resolver::resolve(&ctx).mode
938}
939
940fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
941    const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
942
943    if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
944        return None;
945    }
946
947    let cfg = crate::core::config::Config::load();
948    let profile = crate::core::config::MemoryProfile::effective(&cfg);
949    if !profile.semantic_cache_enabled() {
950        return None;
951    }
952
953    let project_root = detect_project_root(path);
954    let session_id = format!("{}", std::process::id());
955    let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
956
957    let similar = index.find_similar(content, 0.7);
958    let relevant: Vec<_> = similar
959        .into_iter()
960        .filter(|(p, _)| p != path)
961        .take(3)
962        .collect();
963
964    index.add_file(path, content, &session_id);
965    if let Err(e) = index.save(&project_root) {
966        tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
967    }
968
969    if relevant.is_empty() {
970        return None;
971    }
972
973    let hints: Vec<String> = relevant
974        .iter()
975        .map(|(p, score)| format!("  {p} ({:.0}% similar)", score * 100.0))
976        .collect();
977
978    Some(format!(
979        "[semantic: {} similar file(s) in cache]\n{}",
980        relevant.len(),
981        hints.join("\n")
982    ))
983}
984
985fn detect_project_root(path: &str) -> String {
986    crate::core::protocol::detect_project_root_or_cwd(path)
987}
988
989fn build_graph_related_hint(path: &str) -> Option<String> {
990    let project_root = detect_project_root(path);
991    crate::core::graph_context::build_related_hint(path, &project_root, 5)
992}
993
994const AUTO_DELTA_THRESHOLD: f64 = 0.6;
995
996/// Re-reads from disk; if content changed and delta is compact, sends auto-delta.
997fn handle_full_with_auto_delta(
998    cache: &mut SessionCache,
999    path: &str,
1000    file_ref: &str,
1001    short: &str,
1002    ext: &str,
1003    task: Option<&str>,
1004) -> (String, usize) {
1005    let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1006    let Ok(disk_content) = read_file_lossy(path) else {
1007        cache.record_cache_hit(path);
1008        if let Some(existing) = cache.get(path) {
1009            if !crate::core::protocol::meta_visible()
1010                && let Some(cached) = existing.content()
1011            {
1012                return format_full_output(
1013                    file_ref,
1014                    short,
1015                    ext,
1016                    &cached,
1017                    existing.original_tokens,
1018                    existing.line_count,
1019                    task,
1020                );
1021            }
1022            let out = format!(
1023                "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1024                existing.read_count(),
1025                existing.line_count
1026            );
1027            let sent = count_tokens(&out);
1028            return (out, sent);
1029        }
1030        let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1031            format!("[file read failed and no cached version available] {file_ref}={short}")
1032        } else {
1033            format!("[file read failed and no cached version available] {short}")
1034        };
1035        let sent = count_tokens(&out);
1036        return (out, sent);
1037    };
1038
1039    let no_deg = crate::core::config::Config::load().no_degrade_effective();
1040    let prof = crate::core::profiles::active_profile();
1041    let force_full = no_deg
1042        || (prof.read.default_mode_effective() == "full"
1043            && prof.compression.crp_mode_effective() == "off");
1044
1045    let old_content = cache
1046        .get(path)
1047        .and_then(crate::core::cache::CacheEntry::content)
1048        .unwrap_or_default();
1049    let store_result = cache.store(path, &disk_content);
1050
1051    if store_result.was_hit {
1052        let policy_allows_stub =
1053            crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1054        if policy_allows_stub && store_result.full_content_delivered {
1055            let out = if crate::core::protocol::meta_visible() {
1056                format!(
1057                    "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1058                    store_result.line_count
1059                )
1060            } else {
1061                // #498 determinism: byte-stable cache-hit stub (see
1062                // try_stub_hit_readonly).
1063                format!(
1064                    "{file_ref}={short} [unchanged {}L]",
1065                    store_result.line_count
1066                )
1067            };
1068            let sent = count_tokens(&out);
1069            return (out, sent);
1070        }
1071        cache.mark_full_delivered(path);
1072        return format_full_output(
1073            file_ref,
1074            short,
1075            ext,
1076            &disk_content,
1077            store_result.original_tokens,
1078            store_result.line_count,
1079            task,
1080        );
1081    }
1082
1083    let diff = compressor::diff_content(&old_content, &disk_content);
1084    let diff_tokens = count_tokens(&diff);
1085    let full_tokens = store_result.original_tokens;
1086
1087    if !force_full
1088        && full_tokens > 0
1089        && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1090    {
1091        let savings = protocol::format_savings(full_tokens, diff_tokens);
1092        let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1093            format!("{file_ref}={short}")
1094        } else {
1095            short.to_string()
1096        };
1097        let out = format!(
1098            "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1099            disk_content.lines().count()
1100        );
1101        return (out, diff_tokens);
1102    }
1103
1104    format_full_output(
1105        file_ref,
1106        short,
1107        ext,
1108        &disk_content,
1109        store_result.original_tokens,
1110        store_result.line_count,
1111        task,
1112    )
1113}
1114
1115fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1116    let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1117    let short = protocol::shorten_path(path);
1118    let old_content = cache
1119        .get(path)
1120        .and_then(crate::core::cache::CacheEntry::content);
1121
1122    let new_content = match read_file_lossy(path) {
1123        Ok(c) => c,
1124        Err(e) => {
1125            let msg = format!("ERROR: {e}");
1126            let tokens = count_tokens(&msg);
1127            return (msg, tokens);
1128        }
1129    };
1130
1131    let original_tokens = count_tokens(&new_content);
1132
1133    let diff_output = if let Some(old) = &old_content {
1134        compressor::diff_content(old, &new_content)
1135    } else {
1136        // No previous version cached — store content for future diffs but
1137        // return a short guidance message instead of dumping the full file.
1138        cache.store(path, &new_content);
1139        let msg = format!(
1140            "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1141        );
1142        let sent = count_tokens(&msg);
1143        return (msg, sent);
1144    };
1145
1146    cache.store(path, &new_content);
1147
1148    let sent = count_tokens(&diff_output);
1149    let savings = protocol::format_savings(original_tokens, sent);
1150    let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1151        format!("{file_ref}={short}")
1152    } else {
1153        short.clone()
1154    };
1155    (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1156}