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