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 || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) {
700        return unchanged;
701    }
702    let Some(entry) = cache.get(path) else {
703        // First read this session — nothing to diff against.
704        return unchanged;
705    };
706    let stale =
707        crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
708    if stale {
709        // Only divert to a diff when full content is actually cached: the diff
710        // base is that full content (see `handle_diff`), never a compressed
711        // view. Without it, `handle_diff` would have nothing to compare.
712        if entry.content().is_some() {
713            return DeltaExplicitDecision {
714                mode: "diff".to_string(),
715                note: Some(format!(
716                    "[delta-explicit] requested mode={mode} served as a diff: the file \
717                     changed since your last read and the diff is the new information. \
718                     Pass fresh=true if you need the full content re-emitted."
719                )),
720            };
721        }
722        return unchanged;
723    }
724    // Unchanged on disk: a `lines:` window of a file already delivered in full
725    // re-emits text the model holds — collapse to the full-mode stub
726    // (~15 tokens). A plain `full` re-read already hits that stub downstream.
727    if mode.starts_with("lines:") && cache.is_full_delivered(path) {
728        return DeltaExplicitDecision {
729            mode: "full".to_string(),
730            note: None,
731        };
732    }
733    unchanged
734}
735
736fn handle_with_options_inner(
737    cache: &mut SessionCache,
738    path: &str,
739    mode: &str,
740    fresh: bool,
741    crp_mode: CrpMode,
742    task: Option<&str>,
743    tuning: ReadTuning<'_>,
744    preread: Option<String>,
745) -> ReadOutput {
746    let file_ref = cache.get_file_ref(path);
747    let short = protocol::shorten_path(path);
748    let ext = Path::new(path)
749        .extension()
750        .and_then(|e| e.to_str())
751        .unwrap_or("");
752
753    // #1150: a path the operator marked "never compress" is always returned in
754    // full — exact bytes matter more than token savings for these files (golden
755    // snapshots, byte-asserted fixtures, security-sensitive configs). Every lossy
756    // mode (auto, aggressive, signatures, density, diff, …) collapses to the
757    // verbatim full read; `raw` (already verbatim) and explicit `lines:` slices
758    // are left as the user asked. The default config protects nothing, so this is
759    // a fast no-op for everyone who hasn't opted in.
760    let mode = if mode != "raw"
761        && !mode.starts_with("lines:")
762        && crate::core::config::Config::load()
763            .proxy
764            .is_path_compress_protected(path)
765    {
766        "full"
767    } else {
768        mode
769    };
770
771    if fresh {
772        if mode == "diff" {
773            let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
774            return ReadOutput {
775                content: warning.to_string(),
776                resolved_mode: "diff".into(),
777                output_tokens: count_tokens(warning),
778            };
779        }
780        cache.invalidate(path);
781    }
782
783    if mode == "diff" {
784        let (out, _) = handle_diff(cache, path, &file_ref);
785        let out = crate::core::redaction::redact_text_if_enabled(&out);
786        let sent = count_tokens(&out);
787        return ReadOutput {
788            content: out,
789            resolved_mode: "diff".into(),
790            output_tokens: sent,
791        };
792    }
793
794    if mode != "full"
795        && let Some(existing) = cache.get(path)
796    {
797        let stale = crate::core::cache::is_cache_entry_stale_verified(
798            path,
799            existing.stored_mtime,
800            &existing.hash,
801        );
802        if stale {
803            cache.invalidate(path);
804        }
805    }
806
807    // Snapshot the minimal immutable data the miss paths need, then drop the
808    // borrow before any mutable operations (set_compressed, invalidate, store).
809    let cache_snapshot = cache
810        .get(path)
811        .map(|existing| (existing.original_tokens, existing.content()));
812
813    if let Some((original_tokens, content_opt)) = cache_snapshot {
814        // Resolve the read mode first — and *cache-aware* for `auto`. Handing the
815        // live cache to the resolver is what lets an `auto` re-read of an
816        // unchanged, already-fully-delivered file short-circuit to
817        // ("full", "cache_hit") and collapse to the cheap ~13-token `[unchanged]`
818        // stub, exactly like an explicit `full` re-read. The previous call passed
819        // no cache, so that branch was dead code and every `auto` re-read
820        // re-delivered the whole file ("re-reads aren't cached"). Resolving
821        // up-front also lets us hit the compressed-output cache BEFORE
822        // decompressing the full body (avoids ~2-5ms zstd on hits). The
823        // aggressiveness knob (#714) still routes `auto` through the density path.
824        let resolved_mode = if mode == "auto" {
825            tuning
826                .auto_density_mode()
827                .unwrap_or_else(|| resolve_auto_mode(Some(cache), path, original_tokens, task))
828        } else {
829            mode.to_string()
830        };
831
832        if resolved_mode == "full" {
833            // Read-locked stub fast path (single source of truth, shared with
834            // the registered handler's concurrent read-lock attempt). Reached by
835            // an explicit `mode=full` and by `auto` that resolved to a full
836            // cache-hit, so both collapse identically to the `[unchanged]` stub.
837            if let Some(out) = try_stub_hit_readonly(cache, path) {
838                return out;
839            }
840            let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
841            let out = crate::core::redaction::redact_text_if_enabled(&out);
842            let sent = count_tokens(&out);
843            return ReadOutput {
844                content: out,
845                resolved_mode: "full".into(),
846                output_tokens: sent,
847            };
848        }
849
850        if is_cacheable_mode(&resolved_mode) {
851            let cache_key = compressed_cache_key(
852                &resolved_mode,
853                crp_mode,
854                task,
855                tuning.aggressiveness,
856                tuning.protect,
857            );
858            let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
859            if let Some(cached_output) = compressed_hit {
860                cache.record_cache_hit(path);
861                let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
862                let sent = count_tokens(&out);
863                return ReadOutput {
864                    content: out,
865                    resolved_mode,
866                    output_tokens: sent,
867                };
868            }
869        }
870
871        if let Some(content) = content_opt {
872            let (out, _) = process_mode_tuned(
873                &content,
874                &resolved_mode,
875                &file_ref,
876                &short,
877                ext,
878                original_tokens,
879                crp_mode,
880                path,
881                task,
882                tuning,
883            );
884            // #361 anti-inflation for lossy whole-file summaries (auto OR
885            // explicit): map/signatures/… must never cost more than the raw file.
886            // Selection/delta views keep their exact shape (see
887            // mode_allows_raw_cap). Cap before caching so re-read hits serve the
888            // same capped, byte-stable body.
889            let out = if mode_allows_raw_cap(&resolved_mode) {
890                let framed_tokens = count_tokens(&out);
891                cap_to_raw(out, framed_tokens, &content, original_tokens)
892            } else {
893                out
894            };
895            if is_cacheable_mode(&resolved_mode) {
896                let cache_key = compressed_cache_key(
897                    &resolved_mode,
898                    crp_mode,
899                    task,
900                    tuning.aggressiveness,
901                    tuning.protect,
902                );
903                cache.set_compressed(path, &cache_key, out.clone());
904            }
905            let out = crate::core::redaction::redact_text_if_enabled(&out);
906            let sent = count_tokens(&out);
907            return ReadOutput {
908                content: out,
909                resolved_mode,
910                output_tokens: sent,
911            };
912        }
913        cache.invalidate(path);
914    }
915
916    // Two-Phase Read (#1098): when pre-read content was provided (disk I/O
917    // already happened outside the cache lock), use it directly. Otherwise
918    // fall back to reading from disk (legacy path, still used by fast-path
919    // inline calls where the write lock was immediately available).
920    let content = if let Some(pr) = preread {
921        pr
922    } else {
923        match read_file_lossy(path) {
924            Ok(c) => c,
925            Err(e) => {
926                let msg = format!("ERROR: {e}");
927                let tokens = count_tokens(&msg);
928                return ReadOutput {
929                    content: msg,
930                    resolved_mode: "error".into(),
931                    output_tokens: tokens,
932                };
933            }
934        }
935    };
936
937    let store_result = cache.store(path, &content);
938
939    // Skip expensive hint computation for line-range reads and first reads.
940    // Hints are only useful from the 2nd read onwards when the file is contextually relevant.
941    let is_line_range = mode.starts_with("lines:");
942    let hints = crate::core::profiles::active_profile().output_hints;
943    let is_repeat_read = store_result.read_count > 1;
944    let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
945        find_similar_and_update_semantic_index(path, &content)
946    } else {
947        None
948    };
949    // #1098: graph hints moved to background — `graph_related_hint()` does a
950    // SQLite query that can block for 50-200ms on Windows, which is unacceptable
951    // while holding the global cache write-lock. The registered handler calls it
952    // after releasing the lock and appends it to the response.
953    let graph_hint: Option<String> = None;
954
955    if mode == "full" {
956        cache.mark_full_delivered(path);
957        let (mut output, _) = format_full_output(
958            &file_ref,
959            &short,
960            ext,
961            &content,
962            store_result.original_tokens,
963            store_result.line_count,
964            task,
965        );
966        if let Some(hint) = &graph_hint {
967            output.push_str(&format!("\n{hint}"));
968        }
969        if let Some(hint) = similar_hint {
970            output.push_str(&format!("\n{hint}"));
971        }
972        let framed_tokens = count_tokens(&output);
973        let output = cap_to_raw(
974            output,
975            framed_tokens,
976            &content,
977            store_result.original_tokens,
978        );
979        let output = crate::core::redaction::redact_text_if_enabled(&output);
980        let sent = count_tokens(&output);
981        return ReadOutput {
982            content: output,
983            resolved_mode: "full".into(),
984            output_tokens: sent,
985        };
986    }
987
988    let resolved_mode = if mode == "auto" {
989        tuning
990            .auto_density_mode()
991            .unwrap_or_else(|| resolve_auto_mode(None, path, store_result.original_tokens, task))
992    } else {
993        mode.to_string()
994    };
995
996    let (output, _sent) = process_mode_tuned(
997        &content,
998        &resolved_mode,
999        &file_ref,
1000        &short,
1001        ext,
1002        store_result.original_tokens,
1003        crp_mode,
1004        path,
1005        task,
1006        tuning,
1007    );
1008    // #361 anti-inflation for lossy whole-file summaries (auto OR explicit);
1009    // selection/delta views keep their exact shape (see mode_allows_raw_cap).
1010    // Cap first, then cache the pure capped body so re-reads stay byte-stable
1011    // (#498) — the optional, read-state-dependent navigation hints below are
1012    // appended to the returned value only, never to the cached body.
1013    let mut output = if mode_allows_raw_cap(&resolved_mode) {
1014        let framed_tokens = count_tokens(&output);
1015        cap_to_raw(
1016            output,
1017            framed_tokens,
1018            &content,
1019            store_result.original_tokens,
1020        )
1021    } else {
1022        output
1023    };
1024    if is_cacheable_mode(&resolved_mode) {
1025        let cache_key = compressed_cache_key(
1026            &resolved_mode,
1027            crp_mode,
1028            task,
1029            tuning.aggressiveness,
1030            tuning.protect,
1031        );
1032        cache.set_compressed(path, &cache_key, output.clone());
1033    }
1034    if let Some(hint) = &graph_hint {
1035        output.push_str(&format!("\n{hint}"));
1036    }
1037    if let Some(hint) = similar_hint {
1038        output.push_str(&format!("\n{hint}"));
1039    }
1040    let output = crate::core::redaction::redact_text_if_enabled(&output);
1041    let final_tokens = count_tokens(&output);
1042    ReadOutput {
1043        content: output,
1044        resolved_mode,
1045        output_tokens: final_tokens,
1046    }
1047}
1048
1049pub fn is_instruction_file(path: &str) -> bool {
1050    let lower = path.to_lowercase();
1051    let filename = std::path::Path::new(&lower)
1052        .file_name()
1053        .and_then(|f| f.to_str())
1054        .unwrap_or("");
1055
1056    matches!(
1057        filename,
1058        "skill.md"
1059            | "agents.md"
1060            | "rules.md"
1061            | ".cursorrules"
1062            | ".clinerules"
1063            | "lean-ctx.md"
1064            | "lean-ctx.mdc"
1065    ) || lower.contains("/skills/")
1066        || lower.contains("/.cursor/rules/")
1067        || lower.contains("/.claude/rules/")
1068        || lower.contains("/agents.md")
1069}
1070
1071/// #361 anti-inflation invariant: a `ctx_read` must never cost more tokens than
1072/// reading the raw file would. Framing (file-ref header, deps/exports summary,
1073/// savings footer, navigation hints) only earns its keep on large files and
1074/// repeated reads — on a cold read of a small file it is pure overhead, the
1075/// exact inflation an independent benchmark measured (#361). When the framed
1076/// payload exceeds the bare content we ship the content verbatim, so a read is
1077/// break-even at worst and a win whenever a compressed mode or a cached re-read
1078/// applies. Re-reads are unaffected: the cache keys on path and re-derives the
1079/// file ref, so dropping the cold header here costs nothing on the next read.
1080///
1081/// `framed_tokens` and `raw_tokens` are both measured pre-redaction (redaction
1082/// is roughly token-neutral and applied to whichever string wins), so the
1083/// comparison is apples-to-apples with `original_tokens`. Empty files
1084/// (`raw_tokens == 0`) keep their framing so the reader still gets a signal.
1085fn cap_to_raw(
1086    framed: String,
1087    framed_tokens: usize,
1088    raw_content: &str,
1089    raw_tokens: usize,
1090) -> String {
1091    if raw_tokens > 0 && framed_tokens > raw_tokens {
1092        raw_content.to_string()
1093    } else {
1094        framed
1095    }
1096}
1097
1098/// Delegates to the unified `auto_mode_resolver::resolve()`.
1099/// Resolve `auto` to a concrete mode.
1100///
1101/// Pass `Some(cache)` on the warm read path: the resolver then short-circuits an
1102/// unchanged, already-fully-delivered file to `("full", "cache_hit")` so the
1103/// caller can collapse the re-read to the cheap `[unchanged]` stub instead of
1104/// re-delivering the whole body. Pass `None` only where no session cache exists
1105/// (the CLI cold path), which forces a stateless cold resolution.
1106fn resolve_auto_mode(
1107    cache: Option<&SessionCache>,
1108    file_path: &str,
1109    original_tokens: usize,
1110    task: Option<&str>,
1111) -> String {
1112    let ctx = crate::core::auto_mode_resolver::AutoModeContext {
1113        path: file_path,
1114        token_count: original_tokens,
1115        task,
1116        cache,
1117    };
1118    crate::core::auto_mode_resolver::resolve(&ctx).mode
1119}
1120
1121fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
1122    const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
1123
1124    if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
1125        return None;
1126    }
1127
1128    let cfg = crate::core::config::Config::load();
1129    let profile = crate::core::config::MemoryProfile::effective(&cfg);
1130    if !profile.semantic_cache_enabled() {
1131        return None;
1132    }
1133
1134    let project_root = detect_project_root(path);
1135    let session_id = format!("{}", std::process::id());
1136    let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
1137
1138    let similar = index.find_similar(content, 0.7);
1139    let relevant: Vec<_> = similar
1140        .into_iter()
1141        .filter(|(p, _)| p != path)
1142        .take(3)
1143        .collect();
1144
1145    index.add_file(path, content, &session_id);
1146    if let Err(e) = index.save(&project_root) {
1147        tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
1148    }
1149
1150    if relevant.is_empty() {
1151        return None;
1152    }
1153
1154    let hints: Vec<String> = relevant
1155        .iter()
1156        .map(|(p, score)| format!("  {p} ({:.0}% similar)", score * 100.0))
1157        .collect();
1158
1159    Some(format!(
1160        "[semantic: {} similar file(s) in cache]\n{}",
1161        relevant.len(),
1162        hints.join("\n")
1163    ))
1164}
1165
1166fn detect_project_root(path: &str) -> String {
1167    crate::core::protocol::detect_project_root_or_cwd(path)
1168}
1169
1170/// Build graph-related hints (callers/callees) — exported for the registered
1171/// handler to call in a background thread after releasing the cache lock (#1098).
1172pub fn graph_related_hint(path: &str) -> Option<String> {
1173    let project_root = detect_project_root(path);
1174    crate::core::graph_context::build_related_hint(path, &project_root, 5)
1175}
1176
1177const AUTO_DELTA_THRESHOLD: f64 = 0.6;
1178
1179/// Re-reads from disk; if content changed and delta is compact, sends auto-delta.
1180fn handle_full_with_auto_delta(
1181    cache: &mut SessionCache,
1182    path: &str,
1183    file_ref: &str,
1184    short: &str,
1185    ext: &str,
1186    task: Option<&str>,
1187) -> (String, usize) {
1188    let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
1189    let Ok(disk_content) = read_file_lossy(path) else {
1190        cache.record_cache_hit(path);
1191        if let Some(existing) = cache.get(path) {
1192            if !crate::core::protocol::meta_visible()
1193                && let Some(cached) = existing.content()
1194            {
1195                return format_full_output(
1196                    file_ref,
1197                    short,
1198                    ext,
1199                    &cached,
1200                    existing.original_tokens,
1201                    existing.line_count,
1202                    task,
1203                );
1204            }
1205            let out = format!(
1206                "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
1207                existing.read_count(),
1208                existing.line_count
1209            );
1210            let sent = count_tokens(&out);
1211            return (out, sent);
1212        }
1213        let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1214            format!("[file read failed and no cached version available] {file_ref}={short}")
1215        } else {
1216            format!("[file read failed and no cached version available] {short}")
1217        };
1218        let sent = count_tokens(&out);
1219        return (out, sent);
1220    };
1221
1222    let no_deg = crate::core::config::Config::load().no_degrade_effective();
1223    let prof = crate::core::profiles::active_profile();
1224    let force_full = no_deg
1225        || (prof.read.default_mode_effective() == "full"
1226            && prof.compression.crp_mode_effective() == "off");
1227
1228    let old_content = cache
1229        .get(path)
1230        .and_then(crate::core::cache::CacheEntry::content)
1231        .unwrap_or_default();
1232    let store_result = cache.store(path, &disk_content);
1233
1234    if store_result.was_hit {
1235        let policy_allows_stub =
1236            crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
1237        if policy_allows_stub && store_result.full_content_delivered {
1238            let out = if crate::core::protocol::meta_visible() {
1239                format!(
1240                    "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
1241                    store_result.line_count
1242                )
1243            } else {
1244                // #498 determinism: byte-stable cache-hit stub (see
1245                // try_stub_hit_readonly). The `fresh=true` escape is a static
1246                // suffix, so non-meta re-readers still see how to force content (#513).
1247                format!(
1248                    "{file_ref}={short} [unchanged {}L · fresh=true to re-read]",
1249                    store_result.line_count
1250                )
1251            };
1252            let sent = count_tokens(&out);
1253            return (out, sent);
1254        }
1255        cache.mark_full_delivered(path);
1256        return format_full_output(
1257            file_ref,
1258            short,
1259            ext,
1260            &disk_content,
1261            store_result.original_tokens,
1262            store_result.line_count,
1263            task,
1264        );
1265    }
1266
1267    let diff = compressor::diff_content(&old_content, &disk_content);
1268    let diff_tokens = count_tokens(&diff);
1269    let full_tokens = store_result.original_tokens;
1270
1271    if !force_full
1272        && full_tokens > 0
1273        && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
1274    {
1275        let savings = protocol::format_savings(full_tokens, diff_tokens);
1276        let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1277            format!("{file_ref}={short}")
1278        } else {
1279            short.to_string()
1280        };
1281        let out = format!(
1282            "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
1283            disk_content.lines().count()
1284        );
1285        return (out, diff_tokens);
1286    }
1287
1288    format_full_output(
1289        file_ref,
1290        short,
1291        ext,
1292        &disk_content,
1293        store_result.original_tokens,
1294        store_result.line_count,
1295        task,
1296    )
1297}
1298
1299fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
1300    let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
1301    let short = protocol::shorten_path(path);
1302    let old_content = cache
1303        .get(path)
1304        .and_then(crate::core::cache::CacheEntry::content);
1305
1306    let new_content = match read_file_lossy(path) {
1307        Ok(c) => c,
1308        Err(e) => {
1309            let msg = format!("ERROR: {e}");
1310            let tokens = count_tokens(&msg);
1311            return (msg, tokens);
1312        }
1313    };
1314
1315    let original_tokens = count_tokens(&new_content);
1316
1317    let diff_output = if let Some(old) = &old_content {
1318        compressor::diff_content(old, &new_content)
1319    } else {
1320        // No previous version cached — store content for future diffs but
1321        // return a short guidance message instead of dumping the full file.
1322        cache.store(path, &new_content);
1323        let msg = format!(
1324            "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
1325        );
1326        let sent = count_tokens(&msg);
1327        return (msg, sent);
1328    };
1329
1330    cache.store(path, &new_content);
1331
1332    let sent = count_tokens(&diff_output);
1333    let savings = protocol::format_savings(original_tokens, sent);
1334    let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
1335        format!("{file_ref}={short}")
1336    } else {
1337        short
1338    };
1339    (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
1340}