1use super::{
2 CrpMode, HookPoint, PluginManager, ReadMode, ReadOutput, ReadTuning, SessionCache,
3 count_tokens, dedup_hook, handle_with_options_inner, kernel, protocol,
4};
5
6pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
8 handle_with_options(cache, path, mode, false, crp_mode, None)
9}
10
11pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
13 handle_with_options(cache, path, mode, true, crp_mode, None)
14}
15
16pub fn handle_with_task(
18 cache: &mut SessionCache,
19 path: &str,
20 mode: &str,
21 crp_mode: CrpMode,
22 task: Option<&str>,
23) -> String {
24 let mut result = handle_with_options(cache, path, mode, false, crp_mode, task);
25 kernel::enrich_with_kernel(&mut result, task);
26 result
27}
28
29pub fn handle_with_task_resolved(
31 cache: &mut SessionCache,
32 path: &str,
33 mode: &str,
34 crp_mode: CrpMode,
35 task: Option<&str>,
36) -> ReadOutput {
37 handle_with_options_resolved(
38 cache,
39 path,
40 mode,
41 false,
42 crp_mode,
43 task,
44 ReadTuning::resolve(None, &[]),
45 )
46}
47
48pub fn handle_with_task_resolved_tuned(
52 cache: &mut SessionCache,
53 path: &str,
54 mode: &str,
55 crp_mode: CrpMode,
56 task: Option<&str>,
57 aggressiveness: Option<f64>,
58 protect: &[String],
59) -> ReadOutput {
60 handle_with_options_resolved(
61 cache,
62 path,
63 mode,
64 false,
65 crp_mode,
66 task,
67 ReadTuning::resolve(aggressiveness, protect),
68 )
69}
70
71#[allow(clippy::too_many_arguments)]
74pub fn handle_with_preread(
75 cache: &mut SessionCache,
76 path: &str,
77 mode: &str,
78 fresh: bool,
79 crp_mode: CrpMode,
80 task: Option<&str>,
81 aggressiveness: Option<f64>,
82 protect: &[String],
83 preread: String,
84) -> ReadOutput {
85 handle_with_options_resolved_preread(
86 cache,
87 path,
88 mode,
89 fresh,
90 crp_mode,
91 task,
92 ReadTuning::resolve(aggressiveness, protect),
93 Some(preread),
94 )
95}
96
97pub fn handle_fresh_with_task(
99 cache: &mut SessionCache,
100 path: &str,
101 mode: &str,
102 crp_mode: CrpMode,
103 task: Option<&str>,
104) -> String {
105 handle_with_options(cache, path, mode, true, crp_mode, task)
106}
107
108pub fn handle_fresh_with_task_resolved(
110 cache: &mut SessionCache,
111 path: &str,
112 mode: &str,
113 crp_mode: CrpMode,
114 task: Option<&str>,
115) -> ReadOutput {
116 handle_with_options_resolved(
117 cache,
118 path,
119 mode,
120 true,
121 crp_mode,
122 task,
123 ReadTuning::resolve(None, &[]),
124 )
125}
126
127pub fn handle_fresh_with_task_resolved_tuned(
129 cache: &mut SessionCache,
130 path: &str,
131 mode: &str,
132 crp_mode: CrpMode,
133 task: Option<&str>,
134 aggressiveness: Option<f64>,
135 protect: &[String],
136) -> ReadOutput {
137 handle_with_options_resolved(
138 cache,
139 path,
140 mode,
141 true,
142 crp_mode,
143 task,
144 ReadTuning::resolve(aggressiveness, protect),
145 )
146}
147
148fn handle_with_options(
149 cache: &mut SessionCache,
150 path: &str,
151 mode: &str,
152 fresh: bool,
153 crp_mode: CrpMode,
154 task: Option<&str>,
155) -> String {
156 handle_with_options_resolved(
157 cache,
158 path,
159 mode,
160 fresh,
161 crp_mode,
162 task,
163 ReadTuning::resolve(None, &[]),
164 )
165 .content
166}
167
168pub(crate) fn force_fresh_env() -> bool {
171 static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
172 *FORCE_FRESH.get_or_init(|| {
173 std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
174 })
175}
176
177pub(crate) fn is_subagent_context() -> bool {
191 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
192 *IS_SUBAGENT.get_or_init(|| {
193 std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
194 || std::env::var("CLAUDE_CODE_ENTRYPOINT")
195 .ok()
196 .as_deref()
197 .map(str::trim)
198 == Some("local-agent")
199 })
200}
201
202fn handle_with_options_resolved(
203 cache: &mut SessionCache,
204 path: &str,
205 mode: &str,
206 fresh: bool,
207 crp_mode: CrpMode,
208 task: Option<&str>,
209 tuning: ReadTuning<'_>,
210) -> ReadOutput {
211 handle_with_options_resolved_preread(cache, path, mode, fresh, crp_mode, task, tuning, None)
212}
213
214fn handle_with_options_resolved_preread(
215 cache: &mut SessionCache,
216 path: &str,
217 mode: &str,
218 fresh: bool,
219 crp_mode: CrpMode,
220 task: Option<&str>,
221 tuning: ReadTuning<'_>,
222 preread: Option<String>,
223) -> ReadOutput {
224 let effective_fresh = fresh || force_fresh_env() || is_subagent_context();
228
229 if PluginManager::has_listener("pre_read") {
230 PluginManager::fire_hook_background(HookPoint::PreRead {
231 path: path.to_string(),
232 });
233 }
234
235 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
236 bt.next_seq();
237 }
238 let mut result = handle_with_options_inner(
239 cache,
240 path,
241 mode,
242 effective_fresh,
243 crp_mode,
244 task,
245 tuning,
246 preread,
247 );
248
249 if let Some(entry) = cache.get_mut(path) {
250 entry.last_mode.clone_from(&result.resolved_mode);
251 if !matches!(result.resolved_mode.as_str(), "full" | "full-compact") {
257 entry.full_content_delivered = false;
258 }
259 }
260
261 let dedup_allowed = result
263 .resolved_mode
264 .parse::<ReadMode>()
265 .is_ok_and(|m| m.is_lossy_summary());
266 if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
267 let new_tokens = count_tokens(&deduped);
268 if new_tokens < result.output_tokens {
269 result.content = deduped;
270 result.output_tokens = new_tokens;
271 }
272 }
273
274 if let Some(stub) = dedup_hook::maybe_dedup(path, &result.content, mode) {
276 let stub_tokens = count_tokens(&stub);
277 if stub_tokens < result.output_tokens {
278 result.content = stub;
279 result.output_tokens = stub_tokens;
280 result.is_cache_hit = true;
281 }
282 }
283
284 crate::core::context_kernel::adaptive_hook::update_from_bounce_tracker();
286 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
287 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
288 bt.record_read(
289 path,
290 &result.resolved_mode,
291 result.output_tokens,
292 original_tokens,
293 );
294
295 let compressed = result
305 .resolved_mode
306 .parse::<ReadMode>()
307 .map_or(true, |m| m.counts_as_compressed());
308 if compressed {
309 crate::core::adaptive_thresholds::record_quality_signal(
310 path,
311 crate::core::threshold_learning::QualitySignal::CleanCompressed,
312 );
313 } else if result.resolved_mode == "full"
314 && result.output_tokens > 2000
315 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
316 {
317 crate::core::adaptive_thresholds::record_quality_signal(
318 path,
319 crate::core::threshold_learning::QualitySignal::WastedFull,
320 );
321 }
322 }
323
324 if PluginManager::has_listener("post_compress") {
326 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
327 PluginManager::fire_hook_background(HookPoint::PostCompress {
328 path: path.to_string(),
329 original_tokens,
330 compressed_tokens: result.output_tokens,
331 });
332 }
333
334 {
341 let self_agent = crate::core::scent_field::scent_agent_id();
342 let scent_path = crate::core::pathutil::normalize_tool_path(path);
343 std::thread::spawn(move || {
344 crate::core::scent_field::deposit(
345 self_agent,
346 crate::core::scent_field::ScentKind::Hot,
347 &scent_path,
348 0.3,
349 );
350 });
351 }
352
353 result
354}
355
356pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
367 let current_conversation = crate::core::conversation::current_conversation_id_fresh();
371 try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
372}
373
374pub(crate) fn try_stub_hit_readonly_scoped(
378 cache: &SessionCache,
379 path: &str,
380 current_conversation: Option<&str>,
381) -> Option<ReadOutput> {
382 let no_deg = crate::core::config::Config::load().no_degrade_effective();
383 let prof = crate::core::profiles::active_profile();
384 let force_full = no_deg
385 || (prof.read.default_mode_effective() == "full"
386 && prof.compression.crp_mode_effective() == "off");
387 let policy_allows_stub =
388 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
389 if !policy_allows_stub {
390 return None;
391 }
392
393 if let Some(file_ref) = cache.get_file_ref_readonly(path) {
395 let (cached_mtime, cached_hash, line_count, delivered_conv) = {
396 let entry = cache.get(path)?;
397 (
398 entry.stored_mtime,
399 entry.hash.clone(),
400 entry.line_count,
401 entry.delivered_conversation.clone(),
402 )
403 };
404 if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
405 || !cache.is_full_delivered(path)
406 {
407 return None;
408 }
409 if !crate::core::conversation::conversation_allows_stub(
415 current_conversation,
416 delivered_conv.as_deref(),
417 ) {
418 crate::core::cache_telemetry::record_conversation_mismatch();
419 return None;
420 }
421 cache.record_cache_hit(path);
422 crate::core::telemetry::global_metrics().record_cache(true);
423 return Some(render_unchanged_stub(&file_ref, path, line_count));
424 }
425
426 let rec = crate::core::read_stub_index::lookup(path)?;
432 if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
433 return None;
434 }
435 if !crate::core::conversation::conversation_allows_cold_stub(
436 current_conversation,
437 rec.delivered_conversation.as_deref(),
438 ) {
439 crate::core::cache_telemetry::record_conversation_mismatch();
440 return None;
441 }
442 Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
443}
444
445fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
453 let short = protocol::shorten_path(path);
454 let out = if crate::core::protocol::meta_visible() {
455 format!(
456 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
457 )
458 } else {
459 format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
460 };
461 let out = crate::core::redaction::redact_text_if_enabled(&out);
462 let sent = count_tokens(&out);
463 ReadOutput {
464 content: out,
465 resolved_mode: "full".into(),
466 output_tokens: sent,
467 is_cache_hit: true,
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct DeltaExplicitDecision {
475 pub mode: String,
478 pub note: Option<String>,
482}
483
484pub fn resolve_explicit_delta_mode(
506 cache: &SessionCache,
507 path: &str,
508 mode: &str,
509 explicit_mode: bool,
510 fresh: bool,
511 enabled: bool,
512) -> DeltaExplicitDecision {
513 let unchanged = DeltaExplicitDecision {
514 mode: mode.to_string(),
515 note: None,
516 };
517 if fresh
518 || !enabled
519 || !explicit_mode
520 || !(mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
521 {
522 return unchanged;
523 }
524 let Some(entry) = cache.get(path) else {
525 return unchanged;
527 };
528 let stale =
529 crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
530 if stale {
531 if entry.content().is_some() {
535 return DeltaExplicitDecision {
536 mode: "diff".to_string(),
537 note: Some(format!(
538 "[delta-explicit] requested mode={mode} served as a diff: the file \
539 changed since your last read and the diff is the new information. \
540 Pass fresh=true if you need the full content re-emitted."
541 )),
542 };
543 }
544 return unchanged;
545 }
546 if mode.starts_with("lines:") && cache.is_full_delivered(path) {
550 return DeltaExplicitDecision {
551 mode: "full".to_string(),
552 note: None,
553 };
554 }
555 unchanged
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use std::sync::atomic::Ordering;
562
563 #[test]
564 fn warm_stub_hit_records_central_telemetry() {
565 let dir = tempfile::tempdir().unwrap();
566 let file = dir.path().join("telemetry-hit.rs");
567 std::fs::write(&file, "fn telemetry_hit() {}\n").unwrap();
568 let path = file.to_string_lossy();
569 let mut cache = SessionCache::new();
570 cache.store(&path, "fn telemetry_hit() {}\n");
571 cache.mark_full_delivered(&path);
572
573 let metrics = crate::core::telemetry::global_metrics();
574 let before = metrics.cache_hits.load(Ordering::Relaxed);
575 let output = try_stub_hit_readonly_scoped(&cache, &path, None);
576 let after = metrics.cache_hits.load(Ordering::Relaxed);
577
578 assert!(output.is_some(), "warm re-read must use the stub cache");
579 assert!(
580 after > before,
581 "stub cache hit must increment central telemetry"
582 );
583 }
584}