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::{executor::HookPoint, PluginManager};
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#[cfg(test)]
18mod tests;
19
20/// Pre-counted read output carrying the output string, resolved mode,
21/// and token count computed during mode processing.
22pub struct ReadOutput {
23    pub content: String,
24    pub resolved_mode: String,
25    /// Approximate output token count from mode processing.
26    /// The dispatch layer recounts the final assembled string for accurate savings.
27    pub output_tokens: usize,
28}
29
30const COMPRESSED_HINT: &str = "[compressed — use mode=\"full\" for complete source]";
31
32const CACHEABLE_MODES: &[&str] = &["map", "signatures"];
33
34fn is_cacheable_mode(mode: &str) -> bool {
35    CACHEABLE_MODES.contains(&mode)
36}
37
38fn compressed_cache_key(mode: &str, crp_mode: CrpMode, task: Option<&str>) -> String {
39    // Bump when the rendered map/signatures body changes shape so stale
40    // pre-line-range entries are not served from an older session cache.
41    let versioned_mode = match mode {
42        "map" => "map:v2",
43        "signatures" => "signatures:v2",
44        _ => mode,
45    };
46    let base = if crp_mode.is_tdd() {
47        format!("{versioned_mode}:tdd")
48    } else {
49        versioned_mode.to_string()
50    };
51    // map/signatures output now embeds a task-relevant body, so task-aware and
52    // task-free variants must cache under distinct keys.
53    match task.map(str::trim).filter(|t| !t.is_empty()) {
54        Some(t) => {
55            use std::hash::{Hash, Hasher};
56            let mut h = std::collections::hash_map::DefaultHasher::new();
57            t.hash(&mut h);
58            format!("{base}:t{:x}", h.finish())
59        }
60        None => base,
61    }
62}
63
64/// Extracts a short proof-line from file content to include in cache-hit stubs.
65/// Returns the first non-empty line (truncated to 60 chars) as evidence the cache is valid.
66/// Only shown after 2+ reads to avoid noise on early interactions.
67fn cache_hit_proof_line(content: &str, read_count: u32) -> Option<String> {
68    if read_count < 2 {
69        return None;
70    }
71    let first_line = content.lines().find(|l| !l.trim().is_empty())?;
72    let trimmed = first_line.trim();
73    if trimmed.len() > 60 {
74        let mut end = 57;
75        while end > 0 && !trimmed.is_char_boundary(end) {
76            end -= 1;
77        }
78        Some(format!("{}...", &trimmed[..end]))
79    } else {
80        Some(trimmed.to_string())
81    }
82}
83
84fn append_compressed_hint(output: &str, file_path: &str) -> String {
85    if !crate::core::profiles::active_profile()
86        .output_hints
87        .compressed_hint()
88    {
89        return output.to_string();
90    }
91    format!(
92        "{output}\n{COMPRESSED_HINT}\n  ctx_read(\"{file_path}\", mode=\"full\") | ctx_retrieve(\"{file_path}\")"
93    )
94}
95
96/// Reads a file as UTF-8 with lossy fallback, enforcing binary detection and max read size limit.
97/// Defense-in-depth: verifies that the canonical path stays within the process's project root
98/// (if determinable) even though callers SHOULD have already jail-checked the path.
99pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
100    if crate::core::binary_detect::is_binary_file(path) {
101        let msg = crate::core::binary_detect::binary_file_message(path);
102        return Err(std::io::Error::other(msg));
103    }
104
105    {
106        let canonical =
107            crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
108        if let Ok(cwd) = std::env::current_dir() {
109            let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
110            if !canonical.starts_with(&root) {
111                let allow = crate::core::pathjail::allow_paths_from_env_and_config();
112                let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
113                    .ok()
114                    .is_some_and(|d| canonical.starts_with(d));
115                let tmp_ok = canonical.starts_with(std::env::temp_dir());
116                if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
117                    tracing::warn!(
118                        "defense-in-depth: path may escape project root: {}",
119                        canonical.display()
120                    );
121                }
122            }
123        }
124    }
125
126    let cap = crate::core::limits::max_read_bytes();
127
128    let file = open_with_retry(path)?;
129    let meta = file
130        .metadata()
131        .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
132    if meta.len() > cap as u64 {
133        return Err(std::io::Error::other(format!(
134            "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
135             Increase the limit or use a line-range read: mode=\"lines:1-100\"",
136            meta.len(),
137            cap
138        )));
139    }
140
141    use std::io::Read;
142    let mut bytes = Vec::with_capacity(meta.len() as usize);
143    std::io::BufReader::new(file).read_to_end(&mut bytes)?;
144    match String::from_utf8(bytes) {
145        Ok(s) => Ok(s),
146        Err(e) => Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()),
147    }
148}
149
150/// Opens a file, retrying once after a brief pause on NotFound.
151/// Works around overlay/FUSE stat-cache races in container runtimes (Docker, Codex).
152/// Uses O_NOFOLLOW on Unix for TOCTOU symlink protection.
153fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
154    match open_nofollow(path) {
155        Ok(f) => Ok(f),
156        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
157            std::thread::sleep(std::time::Duration::from_millis(50));
158            open_nofollow(path).map_err(|e| {
159                if e.kind() == std::io::ErrorKind::NotFound {
160                    std::io::Error::other(format!(
161                        "file not found: {path} — verify the path with ctx_tree or ctx_search"
162                    ))
163                } else {
164                    e
165                }
166            })
167        }
168        Err(e) => Err(e),
169    }
170}
171
172#[cfg(unix)]
173fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
174    use std::os::unix::fs::OpenOptionsExt;
175    use std::path::Path;
176
177    let p = Path::new(path);
178    // Canonicalize the parent directory (resolving symlinks in the directory path)
179    // but apply O_NOFOLLOW only to the final file component. This prevents
180    // symlink-following attacks on the target file while allowing legitimate
181    // directory symlinks (e.g., /tmp → /private/tmp on macOS).
182    if let (Some(parent), Some(filename)) = (p.parent(), p.file_name()) {
183        if parent.exists() {
184            let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
185            let canonical_path = canonical_parent.join(filename);
186            return std::fs::OpenOptions::new()
187                .read(true)
188                .custom_flags(libc::O_NOFOLLOW)
189                .open(&canonical_path);
190        }
191    }
192
193    // Fallback: direct open with O_NOFOLLOW
194    std::fs::OpenOptions::new()
195        .read(true)
196        .custom_flags(libc::O_NOFOLLOW)
197        .open(path)
198}
199
200#[cfg(not(unix))]
201fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
202    std::fs::File::open(path)
203}
204
205/// Reads a file through the cache and applies the requested compression mode.
206pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
207    handle_with_options(cache, path, mode, false, crp_mode, None)
208}
209
210/// Like `handle`, but invalidates the cache first to force a fresh disk read.
211pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
212    handle_with_options(cache, path, mode, true, crp_mode, None)
213}
214
215/// Reads a file with task-aware filtering to prioritize task-relevant content.
216pub fn handle_with_task(
217    cache: &mut SessionCache,
218    path: &str,
219    mode: &str,
220    crp_mode: CrpMode,
221    task: Option<&str>,
222) -> String {
223    handle_with_options(cache, path, mode, false, crp_mode, task)
224}
225
226/// Like `handle_with_task`, also returns the resolved mode name and pre-counted tokens.
227pub fn handle_with_task_resolved(
228    cache: &mut SessionCache,
229    path: &str,
230    mode: &str,
231    crp_mode: CrpMode,
232    task: Option<&str>,
233) -> ReadOutput {
234    handle_with_options_resolved(cache, path, mode, false, crp_mode, task)
235}
236
237/// Fresh read with task-aware filtering (invalidates cache first).
238pub fn handle_fresh_with_task(
239    cache: &mut SessionCache,
240    path: &str,
241    mode: &str,
242    crp_mode: CrpMode,
243    task: Option<&str>,
244) -> String {
245    handle_with_options(cache, path, mode, true, crp_mode, task)
246}
247
248/// Fresh read with task-aware filtering, also returns the resolved mode name and pre-counted tokens.
249pub fn handle_fresh_with_task_resolved(
250    cache: &mut SessionCache,
251    path: &str,
252    mode: &str,
253    crp_mode: CrpMode,
254    task: Option<&str>,
255) -> ReadOutput {
256    handle_with_options_resolved(cache, path, mode, true, crp_mode, task)
257}
258
259fn handle_with_options(
260    cache: &mut SessionCache,
261    path: &str,
262    mode: &str,
263    fresh: bool,
264    crp_mode: CrpMode,
265    task: Option<&str>,
266) -> String {
267    handle_with_options_resolved(cache, path, mode, fresh, crp_mode, task).content
268}
269
270/// Detects if the current execution context is a subagent (forked agent).
271/// Subagents inherit stale parent caches, so force-fresh prevents VERIFY FAIL.
272fn is_subagent_context() -> bool {
273    static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
274    *IS_SUBAGENT.get_or_init(|| {
275        if std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true") {
276            return true;
277        }
278        std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
279    })
280}
281
282fn handle_with_options_resolved(
283    cache: &mut SessionCache,
284    path: &str,
285    mode: &str,
286    fresh: bool,
287    crp_mode: CrpMode,
288    task: Option<&str>,
289) -> ReadOutput {
290    let effective_fresh = fresh || is_subagent_context();
291
292    // Plugin seam: notify listeners before the read resolves. Guarded so the hot
293    // path never allocates or spawns a thread unless a plugin opts into pre_read.
294    if PluginManager::has_listener("pre_read") {
295        PluginManager::fire_hook_background(HookPoint::PreRead {
296            path: path.to_string(),
297        });
298    }
299
300    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
301        bt.next_seq();
302    }
303    let mut result = handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task);
304
305    if let Some(entry) = cache.get_mut(path) {
306        entry.last_mode.clone_from(&result.resolved_mode);
307    }
308
309    let dedup_allowed = matches!(
310        result.resolved_mode.as_str(),
311        "map" | "signatures" | "aggressive" | "entropy" | "task"
312    );
313    if dedup_allowed {
314        if let Some(deduped) = cache.apply_dedup(path, &result.content) {
315            let new_tokens = count_tokens(&deduped);
316            if new_tokens < result.output_tokens {
317                result.content = deduped;
318                result.output_tokens = new_tokens;
319            }
320        }
321    }
322
323    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
324        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
325        bt.record_read(
326            path,
327            &result.resolved_mode,
328            result.output_tokens,
329            original_tokens,
330        );
331
332        // Quality signals (#538): compressed reads count as clean until a
333        // bounce proves otherwise (the bounce signal outweighs 6:1); large
334        // full reads of never-bouncing extensions are wasted compression
335        // opportunities and push the learned threshold up.
336        let compressed = !matches!(result.resolved_mode.as_str(), "full" | "diff" | "lines");
337        if compressed {
338            crate::core::adaptive_thresholds::record_quality_signal(
339                path,
340                crate::core::threshold_learning::QualitySignal::CleanCompressed,
341            );
342        } else if result.resolved_mode == "full"
343            && result.output_tokens > 2000
344            && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
345        {
346            crate::core::adaptive_thresholds::record_quality_signal(
347                path,
348                crate::core::threshold_learning::QualitySignal::WastedFull,
349            );
350        }
351    }
352
353    // Plugin seam: emit the realized compression stats. Same zero-cost guard.
354    if PluginManager::has_listener("post_compress") {
355        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
356        PluginManager::fire_hook_background(HookPoint::PostCompress {
357            path: path.to_string(),
358            original_tokens,
359            compressed_tokens: result.output_tokens,
360        });
361    }
362
363    // Stigmergy (#540): deposit a Hot scent for this read in the background
364    // (the field file lock may briefly block; never stall the read path), and
365    // surface an active foreign claim as a one-line hint (~10 tokens) so
366    // parallel agents stop duplicating work.
367    {
368        let self_agent = crate::core::scent_field::scent_agent_id();
369        let scent_path = crate::core::pathutil::normalize_tool_path(path);
370        std::thread::spawn(move || {
371            crate::core::scent_field::deposit(
372                self_agent,
373                crate::core::scent_field::ScentKind::Hot,
374                &scent_path,
375                0.3,
376            );
377        });
378        if let Some(hint) = crate::core::scent_field::read_hint(path, self_agent) {
379            result.content.push('\n');
380            result.content.push_str(&hint);
381        }
382    }
383
384    result
385}
386
387/// Attempt to serve a `mode="full"` cache hit (`[unchanged …]`) using only a
388/// shared borrow of the cache.
389///
390/// Returns `None` when the file is not cached, was modified on disk, full
391/// content was never delivered, or the cache policy forbids stubbing — in those
392/// cases the caller must fall back to the write path.
393///
394/// This is the read-locked fast path: it needs no `&mut SessionCache`, so the
395/// dominant "re-read an unchanged file" case proceeds under a shared lock and
396/// parallel reads of distinct files no longer serialize on a global write lock.
397pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
398    let file_ref = cache.get_file_ref_readonly(path)?;
399    let (cached_mtime, cached_hash, read_count, line_count, content_opt) = {
400        let entry = cache.get(path)?;
401        (
402            entry.stored_mtime,
403            entry.hash.clone(),
404            entry.read_count(),
405            entry.line_count,
406            entry.content(),
407        )
408    };
409
410    let no_deg = crate::core::config::Config::load().no_degrade_effective();
411    let prof = crate::core::profiles::active_profile();
412    let force_full = no_deg
413        || (prof.read.default_mode_effective() == "full"
414            && prof.compression.crp_mode_effective() == "off");
415    let policy_allows_stub =
416        crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
417    if !policy_allows_stub
418        || crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
419        || !cache.is_full_delivered(path)
420    {
421        return None;
422    }
423
424    cache.record_cache_hit(path);
425    let short = protocol::shorten_path(path);
426    let out = if crate::core::protocol::meta_visible() {
427        format!(
428            "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
429        )
430    } else {
431        let proof = content_opt
432            .as_deref()
433            .and_then(|c| cache_hit_proof_line(c, read_count));
434        let reads_note = if read_count > 3 {
435            format!(" (read {}x)", read_count + 1)
436        } else {
437            String::new()
438        };
439        match proof {
440            Some(p) => {
441                format!("{file_ref}={short} [unchanged {line_count}L{reads_note} | \"{p}\"]")
442            }
443            None => format!("{file_ref}={short} [unchanged {line_count}L{reads_note}]"),
444        }
445    };
446    let out = crate::core::redaction::redact_text_if_enabled(&out);
447    let sent = count_tokens(&out);
448    Some(ReadOutput {
449        content: out,
450        resolved_mode: "full".into(),
451        output_tokens: sent,
452    })
453}
454
455fn handle_with_options_inner(
456    cache: &mut SessionCache,
457    path: &str,
458    mode: &str,
459    fresh: bool,
460    crp_mode: CrpMode,
461    task: Option<&str>,
462) -> ReadOutput {
463    let file_ref = cache.get_file_ref(path);
464    let short = protocol::shorten_path(path);
465    let ext = Path::new(path)
466        .extension()
467        .and_then(|e| e.to_str())
468        .unwrap_or("");
469
470    if fresh {
471        if mode == "diff" {
472            let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
473            return ReadOutput {
474                content: warning.to_string(),
475                resolved_mode: "diff".into(),
476                output_tokens: count_tokens(warning),
477            };
478        }
479        cache.invalidate(path);
480    }
481
482    if mode == "diff" {
483        let (out, _) = handle_diff(cache, path, &file_ref);
484        let out = crate::core::redaction::redact_text_if_enabled(&out);
485        let sent = count_tokens(&out);
486        return ReadOutput {
487            content: out,
488            resolved_mode: "diff".into(),
489            output_tokens: sent,
490        };
491    }
492
493    if mode != "full" {
494        if let Some(existing) = cache.get(path) {
495            let stale = crate::core::cache::is_cache_entry_stale_verified(
496                path,
497                existing.stored_mtime,
498                &existing.hash,
499            );
500            if stale {
501                cache.invalidate(path);
502            }
503        }
504    }
505
506    // Snapshot the minimal immutable data the miss paths need, then drop the
507    // borrow before any mutable operations (set_compressed, invalidate, store).
508    let cache_snapshot = cache
509        .get(path)
510        .map(|existing| (existing.original_tokens, existing.content()));
511
512    if let Some((original_tokens, content_opt)) = cache_snapshot {
513        if mode == "full" {
514            // Read-locked stub fast path (single source of truth, shared with
515            // the registered handler's concurrent read-lock attempt).
516            if let Some(out) = try_stub_hit_readonly(cache, path) {
517                return out;
518            }
519            let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
520            let out = crate::core::redaction::redact_text_if_enabled(&out);
521            let sent = count_tokens(&out);
522            return ReadOutput {
523                content: out,
524                resolved_mode: "full".into(),
525                output_tokens: sent,
526            };
527        }
528
529        // Resolve mode first so we can check compressed output cache BEFORE
530        // decompressing the full content (avoids ~2-5ms zstd overhead on hits).
531        let resolved_mode = if mode == "auto" {
532            resolve_auto_mode(path, original_tokens, task)
533        } else {
534            mode.to_string()
535        };
536
537        if is_cacheable_mode(&resolved_mode) {
538            let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
539            let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
540            if let Some(cached_output) = compressed_hit {
541                cache.record_cache_hit(path);
542                let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
543                let sent = count_tokens(&out);
544                return ReadOutput {
545                    content: out,
546                    resolved_mode,
547                    output_tokens: sent,
548                };
549            }
550        }
551
552        if let Some(content) = content_opt {
553            let (out, _) = process_mode(
554                &content,
555                &resolved_mode,
556                &file_ref,
557                &short,
558                ext,
559                original_tokens,
560                crp_mode,
561                path,
562                task,
563            );
564            if is_cacheable_mode(&resolved_mode) {
565                let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
566                cache.set_compressed(path, &cache_key, out.clone());
567            }
568            let out = crate::core::redaction::redact_text_if_enabled(&out);
569            let sent = count_tokens(&out);
570            return ReadOutput {
571                content: out,
572                resolved_mode,
573                output_tokens: sent,
574            };
575        }
576        cache.invalidate(path);
577    }
578
579    let content = match read_file_lossy(path) {
580        Ok(c) => c,
581        Err(e) => {
582            let msg = format!("ERROR: {e}");
583            let tokens = count_tokens(&msg);
584            return ReadOutput {
585                content: msg,
586                resolved_mode: "error".into(),
587                output_tokens: tokens,
588            };
589        }
590    };
591
592    let store_result = cache.store(path, &content);
593
594    // Skip expensive hint computation for line-range reads and first reads.
595    // Hints are only useful from the 2nd read onwards when the file is contextually relevant.
596    let is_line_range = mode.starts_with("lines:");
597    let hints = crate::core::profiles::active_profile().output_hints;
598    let is_repeat_read = store_result.read_count > 1;
599    let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
600        find_similar_and_update_semantic_index(path, &content)
601    } else {
602        None
603    };
604    let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
605        build_graph_related_hint(path)
606    } else {
607        None
608    };
609
610    if mode == "full" {
611        cache.mark_full_delivered(path);
612        let (mut output, _) = format_full_output(
613            &file_ref,
614            &short,
615            ext,
616            &content,
617            store_result.original_tokens,
618            store_result.line_count,
619            task,
620        );
621        if let Some(hint) = &graph_hint {
622            output.push_str(&format!("\n{hint}"));
623        }
624        if let Some(hint) = similar_hint {
625            output.push_str(&format!("\n{hint}"));
626        }
627        let output = crate::core::redaction::redact_text_if_enabled(&output);
628        let sent = count_tokens(&output);
629        return ReadOutput {
630            content: output,
631            resolved_mode: "full".into(),
632            output_tokens: sent,
633        };
634    }
635
636    let resolved_mode = if mode == "auto" {
637        resolve_auto_mode(path, store_result.original_tokens, task)
638    } else {
639        mode.to_string()
640    };
641
642    let (mut output, _sent) = process_mode(
643        &content,
644        &resolved_mode,
645        &file_ref,
646        &short,
647        ext,
648        store_result.original_tokens,
649        crp_mode,
650        path,
651        task,
652    );
653    if let Some(hint) = &graph_hint {
654        output.push_str(&format!("\n{hint}"));
655    }
656    if let Some(hint) = similar_hint {
657        output.push_str(&format!("\n{hint}"));
658    }
659    if is_cacheable_mode(&resolved_mode) {
660        let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
661        cache.set_compressed(path, &cache_key, output.clone());
662    }
663    let output = crate::core::redaction::redact_text_if_enabled(&output);
664    let final_tokens = count_tokens(&output);
665    ReadOutput {
666        content: output,
667        resolved_mode,
668        output_tokens: final_tokens,
669    }
670}
671
672pub fn is_instruction_file(path: &str) -> bool {
673    let lower = path.to_lowercase();
674    let filename = std::path::Path::new(&lower)
675        .file_name()
676        .and_then(|f| f.to_str())
677        .unwrap_or("");
678
679    matches!(
680        filename,
681        "skill.md"
682            | "agents.md"
683            | "rules.md"
684            | ".cursorrules"
685            | ".clinerules"
686            | "lean-ctx.md"
687            | "lean-ctx.mdc"
688    ) || lower.contains("/skills/")
689        || lower.contains("/.cursor/rules/")
690        || lower.contains("/.claude/rules/")
691        || lower.contains("/agents.md")
692}
693
694/// Delegates to the unified `auto_mode_resolver::resolve()`.
695fn resolve_auto_mode(file_path: &str, original_tokens: usize, task: Option<&str>) -> String {
696    let ctx = crate::core::auto_mode_resolver::AutoModeContext {
697        path: file_path,
698        token_count: original_tokens,
699        task,
700        cache: None,
701    };
702    crate::core::auto_mode_resolver::resolve(&ctx).mode
703}
704
705fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
706    const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
707
708    if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
709        return None;
710    }
711
712    let cfg = crate::core::config::Config::load();
713    let profile = crate::core::config::MemoryProfile::effective(&cfg);
714    if !profile.semantic_cache_enabled() {
715        return None;
716    }
717
718    let project_root = detect_project_root(path);
719    let session_id = format!("{}", std::process::id());
720    let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
721
722    let similar = index.find_similar(content, 0.7);
723    let relevant: Vec<_> = similar
724        .into_iter()
725        .filter(|(p, _)| p != path)
726        .take(3)
727        .collect();
728
729    index.add_file(path, content, &session_id);
730    if let Err(e) = index.save(&project_root) {
731        tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
732    }
733
734    if relevant.is_empty() {
735        return None;
736    }
737
738    let hints: Vec<String> = relevant
739        .iter()
740        .map(|(p, score)| format!("  {p} ({:.0}% similar)", score * 100.0))
741        .collect();
742
743    Some(format!(
744        "[semantic: {} similar file(s) in cache]\n{}",
745        relevant.len(),
746        hints.join("\n")
747    ))
748}
749
750fn detect_project_root(path: &str) -> String {
751    crate::core::protocol::detect_project_root_or_cwd(path)
752}
753
754fn build_graph_related_hint(path: &str) -> Option<String> {
755    let project_root = detect_project_root(path);
756    crate::core::graph_context::build_related_hint(path, &project_root, 5)
757}
758
759const AUTO_DELTA_THRESHOLD: f64 = 0.6;
760
761/// Re-reads from disk; if content changed and delta is compact, sends auto-delta.
762fn handle_full_with_auto_delta(
763    cache: &mut SessionCache,
764    path: &str,
765    file_ref: &str,
766    short: &str,
767    ext: &str,
768    task: Option<&str>,
769) -> (String, usize) {
770    let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
771    let Ok(disk_content) = read_file_lossy(path) else {
772        cache.record_cache_hit(path);
773        if let Some(existing) = cache.get(path) {
774            if !crate::core::protocol::meta_visible() {
775                if let Some(cached) = existing.content() {
776                    return format_full_output(
777                        file_ref,
778                        short,
779                        ext,
780                        &cached,
781                        existing.original_tokens,
782                        existing.line_count,
783                        task,
784                    );
785                }
786            }
787            let out = format!(
788                "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
789                existing.read_count(),
790                existing.line_count
791            );
792            let sent = count_tokens(&out);
793            return (out, sent);
794        }
795        let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
796            format!("[file read failed and no cached version available] {file_ref}={short}")
797        } else {
798            format!("[file read failed and no cached version available] {short}")
799        };
800        let sent = count_tokens(&out);
801        return (out, sent);
802    };
803
804    let no_deg = crate::core::config::Config::load().no_degrade_effective();
805    let prof = crate::core::profiles::active_profile();
806    let force_full = no_deg
807        || (prof.read.default_mode_effective() == "full"
808            && prof.compression.crp_mode_effective() == "off");
809
810    let old_content = cache
811        .get(path)
812        .and_then(crate::core::cache::CacheEntry::content)
813        .unwrap_or_default();
814    let store_result = cache.store(path, &disk_content);
815
816    if store_result.was_hit {
817        let policy_allows_stub =
818            crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
819        if policy_allows_stub && store_result.full_content_delivered {
820            let out = if crate::core::protocol::meta_visible() {
821                format!(
822                    "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
823                    store_result.line_count
824                )
825            } else {
826                let proof = cache_hit_proof_line(&disk_content, store_result.read_count);
827                let reads_note = if store_result.read_count > 3 {
828                    format!(" (read {}x)", store_result.read_count)
829                } else {
830                    String::new()
831                };
832                match proof {
833                    Some(p) => format!(
834                        "{file_ref}={short} [unchanged {}L{reads_note} | \"{p}\"]",
835                        store_result.line_count
836                    ),
837                    None => format!(
838                        "{file_ref}={short} [unchanged {}L{reads_note}]",
839                        store_result.line_count
840                    ),
841                }
842            };
843            let sent = count_tokens(&out);
844            return (out, sent);
845        }
846        cache.mark_full_delivered(path);
847        return format_full_output(
848            file_ref,
849            short,
850            ext,
851            &disk_content,
852            store_result.original_tokens,
853            store_result.line_count,
854            task,
855        );
856    }
857
858    let diff = compressor::diff_content(&old_content, &disk_content);
859    let diff_tokens = count_tokens(&diff);
860    let full_tokens = store_result.original_tokens;
861
862    if !force_full
863        && full_tokens > 0
864        && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
865    {
866        let savings = protocol::format_savings(full_tokens, diff_tokens);
867        let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
868            format!("{file_ref}={short}")
869        } else {
870            short.to_string()
871        };
872        let out = format!(
873            "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
874            disk_content.lines().count()
875        );
876        return (out, diff_tokens);
877    }
878
879    format_full_output(
880        file_ref,
881        short,
882        ext,
883        &disk_content,
884        store_result.original_tokens,
885        store_result.line_count,
886        task,
887    )
888}
889
890fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
891    let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
892    let short = protocol::shorten_path(path);
893    let old_content = cache
894        .get(path)
895        .and_then(crate::core::cache::CacheEntry::content);
896
897    let new_content = match read_file_lossy(path) {
898        Ok(c) => c,
899        Err(e) => {
900            let msg = format!("ERROR: {e}");
901            let tokens = count_tokens(&msg);
902            return (msg, tokens);
903        }
904    };
905
906    let original_tokens = count_tokens(&new_content);
907
908    let diff_output = if let Some(old) = &old_content {
909        compressor::diff_content(old, &new_content)
910    } else {
911        // No previous version cached — store content for future diffs but
912        // return a short guidance message instead of dumping the full file.
913        cache.store(path, &new_content);
914        let msg = format!(
915            "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
916        );
917        let sent = count_tokens(&msg);
918        return (msg, sent);
919    };
920
921    cache.store(path, &new_content);
922
923    let sent = count_tokens(&diff_output);
924    let savings = protocol::format_savings(original_tokens, sent);
925    let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
926        format!("{file_ref}={short}")
927    } else {
928        short.clone()
929    };
930    (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
931}