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