Skip to main content

lean_ctx/tools/ctx_read/
mod.rs

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