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