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