Skip to main content

lean_ctx/core/cache/
session.rs

1use std::collections::HashMap;
2use std::sync::atomic::Ordering;
3use std::time::Instant;
4
5use super::entry::{
6    CacheEntry, CacheStats, SharedBlock, StoreResult, eviction_scores_rrf, max_cache_tokens,
7    normalize_key,
8};
9use super::validation::{compute_md5, is_cache_entry_stale_verified};
10use crate::core::tokens::count_tokens;
11
12/// #1287: delivery state of one compressed render variant. `Absent` = no such
13/// variant cached; `UnknownConversation` = stored without a resolvable
14/// conversation id (decided by `conversation_allows_stub` with no delivery id:
15/// served only when the caller has no conversation context either, #1909);
16/// `Conversation` = provably delivered to that conversation.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum VariantDelivery {
19    Absent,
20    UnknownConversation,
21    Conversation(String),
22}
23
24pub(crate) const DEFAULT_FULL_DEGRADATION_THRESHOLD: u32 = 2;
25
26pub fn full_degradation_threshold() -> u32 {
27    std::env::var("LCTX_FULL_DEGRADATION_THRESHOLD")
28        .ok()
29        .and_then(|value| value.trim().parse().ok())
30        .filter(|&threshold| threshold > 0)
31        .unwrap_or(DEFAULT_FULL_DEGRADATION_THRESHOLD)
32}
33
34/// In-memory file cache with segmented LRU eviction (probationary vs protected),
35/// file references, and cross-file dedup.
36pub struct SessionCache {
37    entries: HashMap<String, CacheEntry>,
38    file_refs: HashMap<String, String>,
39    next_ref: usize,
40    stats: CacheStats,
41    shared_blocks: Vec<SharedBlock>,
42}
43
44impl Default for SessionCache {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl SessionCache {
51    /// Creates an empty session cache with default stats.
52    pub fn new() -> Self {
53        Self {
54            entries: HashMap::new(),
55            file_refs: HashMap::new(),
56            next_ref: 1,
57            shared_blocks: Vec::new(),
58            stats: CacheStats::default(),
59        }
60    }
61
62    /// Returns or assigns a short file reference label (F1, F2, ...) for the given path.
63    pub fn get_file_ref(&mut self, path: &str) -> String {
64        let key = normalize_key(path);
65        if let Some(r) = self.file_refs.get(&key) {
66            return r.clone();
67        }
68        let r = format!("F{}", self.next_ref);
69        self.next_ref += 1;
70        self.file_refs.insert(key, r.clone());
71        r
72    }
73
74    /// Returns the file reference label for a path without assigning a new one.
75    pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
76        self.file_refs.get(&normalize_key(path)).cloned()
77    }
78
79    /// Looks up a cached entry by file path.
80    pub fn get(&self, path: &str) -> Option<&CacheEntry> {
81        self.entries.get(&normalize_key(path))
82    }
83
84    /// Mutable lookup of a cached entry by file path.
85    pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
86        self.entries.get_mut(&normalize_key(path))
87    }
88
89    /// Retrieves the full (uncompressed) content for a file path, if cached.
90    /// Used by the CCR (Compress-Cache-Retrieve) mechanism.
91    pub fn get_full_content(&self, path: &str) -> Option<String> {
92        self.entries
93            .get(&normalize_key(path))
94            .and_then(CacheEntry::content)
95    }
96
97    /// Staleness-safe accessor for the *current* full content and its token
98    /// count: returns the cached copy when it is still fresh, or a fresh disk
99    /// re-read when the cached copy is stale (mtime/hash changed since it was
100    /// cached). Returns `None` when there is no cache entry, or the entry is
101    /// stale and the file can no longer be read.
102    ///
103    /// Cross-agent / retrieve paths (`ctx_retrieve`, `ctx_share`) MUST use this
104    /// instead of [`get_full_content`](Self::get_full_content): serving the raw
105    /// cached copy hands an agent a version that may no longer match disk — e.g.
106    /// a handover file edited between two agents — silently feeding it stale
107    /// context. Validation uses the entry's stored absolute `path`, because a
108    /// caller's `path` may be relative and resolve against a different CWD.
109    ///
110    /// Either copy is handed out as a new delivery: it passes the context
111    /// gateway under the *current* policy, with the entry's path (G5, recovery
112    /// is re-authorized). `Some(Err(reason))` is content-free: the gateway
113    /// withholds the content.
114    ///
115    /// A cached copy is never its own source (G04):
116    /// - an entry that came from a file is served only while that file still
117    ///   exists and is readable. A source that was deleted, moved or made
118    ///   unreadable yields a typed refusal, not the last-known copy;
119    /// - an entry that never had a file behind it (a provider result, a
120    ///   virtual key) has no source to re-check. It follows the originless
121    ///   rule: withheld while protection is active in any layer, served
122    ///   re-admitted in Community developer mode;
123    /// - the cached copy itself is served only when its original may be kept
124    ///   in a derived store under the current policy (not restricted).
125    pub fn current_full_content(&self, path: &str) -> Option<Result<(String, usize), String>> {
126        use crate::core::context_admission::carrier::Withheld;
127        let entry = self.entries.get(&normalize_key(path))?;
128        let refused = |withheld: Withheld| Some(Err(withheld.message("cached content")));
129        // A cached copy outlives the Workspace revision it was read under:
130        // its path must still be inside the request's Workspace (#2120).
131        if crate::core::workspace_authority::is_configured()
132            && crate::core::workspace_authority::confine(&entry.path, "", false).is_err()
133        {
134            return refused(Withheld::source_unavailable());
135        }
136        let stale = is_cache_entry_stale_verified(&entry.path, entry.stored_mtime, &entry.hash);
137        let (text, tokens) = if entry.stored_mtime.is_some() {
138            if !std::path::Path::new(&entry.path).is_file() {
139                return refused(Withheld::source_unavailable());
140            }
141            if stale {
142                // Cache is behind disk → serve the current bytes, or nothing.
143                match crate::core::io_boundary::read_file_lossy(&entry.path) {
144                    Ok(fresh) => (fresh, None),
145                    Err(_) => return refused(Withheld::source_unavailable()),
146                }
147            } else {
148                (entry.content()?, Some(entry.original_tokens))
149            }
150        } else {
151            if crate::core::context_admission::stored::source_authority_required() {
152                return refused(Withheld::no_source_authority());
153            }
154            (entry.content()?, Some(entry.original_tokens))
155        };
156        let cached_copy = tokens.is_some();
157        Some(
158            crate::core::context_admission::admit_project_file_with_storage(
159                &text,
160                &entry.path,
161                false,
162                crate::core::context_admission::OutputCompleteness::complete(),
163            )
164            .map_err(|error| error.to_string())
165            .and_then(|admission| {
166                if cached_copy && !admission.storable {
167                    return Err(
168                        Withheld::new(&["store.restricted_content"]).message("cached content")
169                    );
170                }
171                let admitted = admission.text;
172                let tokens = tokens
173                    .filter(|_| admitted == text)
174                    .unwrap_or_else(|| count_tokens(&admitted));
175                Ok((admitted, tokens))
176            }),
177        )
178    }
179
180    /// Records a cache hit, updates access stats, and emits a cache-hit event.
181    ///
182    /// Takes `&self`: the hit counters use interior-mutable atomics, so this
183    /// runs under a shared (read) lock and lets parallel reads of different
184    /// files proceed concurrently instead of serializing on a write lock.
185    pub fn record_cache_hit(&self, path: &str) -> Option<&CacheEntry> {
186        let key = normalize_key(path);
187        let ref_label = self
188            .file_refs
189            .get(&key)
190            .cloned()
191            .unwrap_or_else(|| "F?".to_string());
192        let entry = self.entries.get(&key)?;
193        let new_count = entry.bump_read_count();
194        entry.touch();
195        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
196        self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
197        self.stats
198            .total_original_tokens
199            .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
200        let hit_msg = format!("{ref_label} cached {new_count}t {}L", entry.line_count);
201        let sent_tokens = count_tokens(&hit_msg) as u64;
202        self.stats
203            .total_sent_tokens
204            .fetch_add(sent_tokens, Ordering::Relaxed);
205        crate::core::events::emit_cache_hit(
206            path,
207            (entry.original_tokens as u64).saturating_sub(sent_tokens),
208        );
209        Some(entry)
210    }
211
212    /// Stores file content in the cache; returns a hit if content hash matches.
213    pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
214        let key = normalize_key(path);
215        let hash = compute_md5(content);
216        let line_count = content.lines().count();
217        let original_tokens = count_tokens(content);
218        let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
219        let now = Instant::now();
220
221        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
222        self.stats
223            .total_original_tokens
224            .fetch_add(original_tokens as u64, Ordering::Relaxed);
225
226        if let Some(existing) = self.entries.get_mut(&key) {
227            existing.set_last_access(now);
228            if stored_mtime.is_some() {
229                existing.stored_mtime = stored_mtime;
230            }
231            if existing.hash == hash {
232                let new_count = existing.bump_read_count();
233                self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
234                let hit_msg = format!(
235                    "{} cached {new_count}t {}L",
236                    self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
237                    existing.line_count,
238                );
239                let sent_tokens = count_tokens(&hit_msg) as u64;
240                self.stats
241                    .total_sent_tokens
242                    .fetch_add(sent_tokens, Ordering::Relaxed);
243                return StoreResult {
244                    line_count: existing.line_count,
245                    original_tokens: existing.original_tokens,
246                    read_count: new_count,
247                    was_hit: true,
248                    full_content_delivered: existing.full_content_delivered,
249                };
250            }
251            existing.clear_compressed_outputs();
252            existing.set_content(content);
253            existing.hash = hash;
254            existing.line_count = line_count;
255            existing.original_tokens = original_tokens;
256            let new_count = existing.bump_read_count();
257            existing.full_content_delivered = false;
258            existing.delivered_conversation = None;
259            existing.last_mode.clear();
260            if stored_mtime.is_some() {
261                existing.stored_mtime = stored_mtime;
262            }
263            self.stats
264                .total_sent_tokens
265                .fetch_add(original_tokens as u64, Ordering::Relaxed);
266            return StoreResult {
267                line_count,
268                original_tokens,
269                read_count: new_count,
270                was_hit: false,
271                full_content_delivered: false,
272            };
273        }
274
275        self.evict_if_needed(original_tokens);
276        self.get_file_ref(&key);
277
278        let entry = CacheEntry::new(
279            content,
280            hash,
281            line_count,
282            original_tokens,
283            key.clone(),
284            stored_mtime,
285        );
286
287        self.entries.insert(key, entry);
288        self.stats.files_tracked.fetch_add(1, Ordering::Relaxed);
289        self.stats
290            .total_sent_tokens
291            .fetch_add(original_tokens as u64, Ordering::Relaxed);
292        StoreResult {
293            line_count,
294            original_tokens,
295            read_count: 1,
296            was_hit: false,
297            full_content_delivered: false,
298        }
299    }
300
301    /// Returns the sum of original token counts across all cached entries.
302    pub fn total_cached_tokens(&self) -> usize {
303        self.entries.values().map(|e| e.original_tokens).sum()
304    }
305
306    /// Evict until cache fits within token budget using RRF (Reciprocal Rank Fusion).
307    /// Combines recency, frequency, and size signals to evict least-valuable entries first.
308    pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
309        let max_tokens = max_cache_tokens();
310        let current = self.total_cached_tokens();
311        if current + incoming_tokens <= max_tokens {
312            return;
313        }
314
315        let now = Instant::now();
316        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
317        let mut scores = eviction_scores_rrf(&all, now);
318        // Sort ascending: lowest RRF score = least valuable = evict first
319        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
320
321        let mut freed = 0usize;
322        let target = (current + incoming_tokens).saturating_sub(max_tokens);
323
324        for (path, _score) in &scores {
325            if freed >= target {
326                break;
327            }
328            if let Some(entry) = self.entries.remove(path) {
329                freed += entry.original_tokens;
330                self.file_refs.remove(path);
331            }
332        }
333    }
334
335    /// Returns all cached entries as (path, entry) pairs.
336    pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
337        self.entries.iter().collect()
338    }
339
340    /// Returns a reference to the aggregated cache statistics.
341    pub fn get_stats(&self) -> &CacheStats {
342        &self.stats
343    }
344
345    /// Returns the path-to-file-ref mapping (e.g. "/src/main.rs" → "F1").
346    pub fn file_ref_map(&self) -> &HashMap<String, String> {
347        &self.file_refs
348    }
349
350    /// Replaces the cross-file shared blocks used for deduplication.
351    pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
352        self.shared_blocks = blocks;
353    }
354
355    /// Returns the current set of cross-file shared blocks.
356    pub fn get_shared_blocks(&self) -> &[SharedBlock] {
357        &self.shared_blocks
358    }
359
360    /// Replace shared blocks in content with cross-file references.
361    pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
362        if self.shared_blocks.is_empty() {
363            return None;
364        }
365        let refs: Vec<&SharedBlock> = self
366            .shared_blocks
367            .iter()
368            .filter(|b| b.canonical_path != path && content.contains(&b.content))
369            .collect();
370        if refs.is_empty() {
371            return None;
372        }
373        let mut result = content.to_string();
374        for block in refs {
375            result = result.replacen(
376                &block.content,
377                &format!(
378                    "[= {}:{}-{}]",
379                    block.canonical_ref, block.start_line, block.end_line
380                ),
381                1,
382            );
383        }
384        Some(result)
385    }
386
387    /// Removes a file from the cache, forcing a fresh read on next access.
388    pub fn invalidate(&mut self, path: &str) -> bool {
389        self.entries.remove(&normalize_key(path)).is_some()
390    }
391
392    /// #1287: which conversation first received the given compressed variant,
393    /// WITHOUT recording hit statistics — the caller decides between stub and
394    /// payload delivery and records accordingly.
395    pub fn compressed_delivered_conversation(&self, path: &str, mode_key: &str) -> VariantDelivery {
396        match self
397            .entries
398            .get(&normalize_key(path))
399            .and_then(|entry| entry.compressed_delivered_conversation(mode_key))
400        {
401            None => VariantDelivery::Absent,
402            Some(None) => VariantDelivery::UnknownConversation,
403            Some(Some(conversation)) => VariantDelivery::Conversation(conversation.to_string()),
404        }
405    }
406
407    /// Returns a cached compressed output for a given file and mode key.
408    /// Counts as a cache hit — the caller avoids a full disk read + recompression.
409    pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
410        let key = normalize_key(path);
411        let entry = self.entries.get(&key)?;
412        let result = entry.get_compressed(mode_key)?;
413        entry.bump_read_count();
414        entry.touch();
415        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
416        self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
417        self.stats
418            .total_original_tokens
419            .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
420        let sent = count_tokens(result);
421        self.stats
422            .total_sent_tokens
423            .fetch_add(sent as u64, Ordering::Relaxed);
424        crate::core::events::emit_cache_hit(
425            path,
426            (entry.original_tokens as u64).saturating_sub(sent as u64),
427        );
428        crate::core::stats::record_reread(entry.original_tokens.saturating_sub(sent));
429        Some(result)
430    }
431
432    pub fn was_compressed_variant_evicted(&self, path: &str, mode_key: &str) -> bool {
433        self.entries
434            .get(&normalize_key(path))
435            .is_some_and(|entry| entry.was_compressed_variant_evicted(mode_key))
436    }
437
438    /// Marks that full (uncompressed) content was delivered for this file,
439    /// tagging it with the current conversation so a later re-read only serves
440    /// the `[unchanged]` stub to the same conversation (see
441    /// `crate::core::conversation`).
442    pub fn mark_full_delivered(&mut self, path: &str) {
443        let conversation = crate::core::conversation::current_conversation_id();
444        let key = normalize_key(path);
445        let file_ref = self.file_refs.get(&key).cloned();
446        if let Some(entry) = self.entries.get_mut(&key) {
447            entry.mark_full_delivered(conversation.clone());
448            // Write-through to the persistent stub index so an unchanged re-read
449            // in the same conversation survives a daemon restart / idle clear
450            // (#955). `record` ignores None-conversation deliveries.
451            crate::core::read_stub_index::record(crate::core::read_stub_index::StubRecord::new(
452                key.clone(),
453                entry.hash.clone(),
454                entry.stored_mtime,
455                entry.line_count,
456                file_ref.unwrap_or_default(),
457                conversation,
458            ));
459        }
460    }
461
462    /// Stores a compressed output for a given file and mode key.
463    pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
464        if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
465            entry.set_compressed(mode_key, output);
466        }
467    }
468
469    /// Resets `full_content_delivered` for all entries without removing them.
470    /// Used after host context compaction — forces re-delivery on next read
471    /// while preserving compressed content and file refs.
472    pub fn reset_delivery_flags(&mut self) -> usize {
473        let mut count = 0;
474        for entry in self.entries.values_mut() {
475            if entry.full_content_delivered {
476                entry.full_content_delivered = false;
477                count += 1;
478            }
479        }
480        count
481    }
482
483    /// Returns whether full content was previously delivered for this path.
484    pub fn is_full_delivered(&self, path: &str) -> bool {
485        self.entries
486            .get(&normalize_key(path))
487            .is_some_and(|e| e.full_content_delivered)
488    }
489
490    /// Returns the last successful read mode for an entry, if one was recorded.
491    pub fn last_mode(&self, path: &str) -> Option<String> {
492        self.entries
493            .get(&normalize_key(path))
494            .map(|entry| entry.last_mode.clone())
495            .filter(|mode| !mode.is_empty())
496    }
497
498    /// Removes all compressed output variants (map, signatures, etc.) from every entry,
499    /// keeping the full zstd-compressed content intact. Returns the number of entries trimmed.
500    pub fn trim_compressed_outputs(&mut self) -> usize {
501        let mut trimmed = 0;
502        for entry in self.entries.values_mut() {
503            if entry.evict_compressed_outputs() {
504                trimmed += 1;
505            }
506        }
507        trimmed
508    }
509
510    /// Evicts all entries that have been read at most once (probationary).
511    /// Returns the number of entries removed.
512    pub fn evict_probationary(&mut self) -> usize {
513        let to_remove: Vec<String> = self
514            .entries
515            .iter()
516            .filter(|(_, e)| e.read_count() <= 1)
517            .map(|(k, _)| k.clone())
518            .collect();
519        let count = to_remove.len();
520        for key in &to_remove {
521            self.entries.remove(key);
522            self.file_refs.remove(key);
523        }
524        count
525    }
526
527    /// Evicts entries via RRF scoring until total tokens are at or below `target_tokens`.
528    pub fn evict_to_budget(&mut self, target_tokens: usize) {
529        let current = self.total_cached_tokens();
530        if current <= target_tokens {
531            return;
532        }
533        let now = Instant::now();
534        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
535        let mut scores = eviction_scores_rrf(&all, now);
536        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
537
538        let mut freed = 0usize;
539        let target_free = current.saturating_sub(target_tokens);
540        for (path, _score) in &scores {
541            if freed >= target_free {
542                break;
543            }
544            if let Some(entry) = self.entries.remove(path) {
545                freed += entry.original_tokens;
546                self.file_refs.remove(path);
547            }
548        }
549    }
550
551    /// Estimates the approximate heap memory usage in bytes.
552    pub fn approximate_bytes(&self) -> usize {
553        let entries_bytes: usize = self
554            .entries
555            .values()
556            .map(|e| {
557                e.compressed_content.len()
558                    + e.hash.len()
559                    + e.path.len()
560                    + e.compressed_outputs
561                        .iter()
562                        .map(|(k, v)| k.len() + v.len())
563                        .sum::<usize>()
564                    + 128 // fixed overhead per entry
565            })
566            .sum();
567        let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
568        let blocks_bytes: usize = self
569            .shared_blocks
570            .iter()
571            .map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
572            .sum();
573        entries_bytes + refs_bytes + blocks_bytes
574    }
575
576    const MAX_SHARED_BLOCKS: usize = 100;
577
578    /// Trims shared blocks to a maximum count, keeping the most recent.
579    pub fn trim_shared_blocks(&mut self) {
580        if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
581            let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
582            self.shared_blocks.drain(..excess);
583        }
584    }
585
586    /// Clears all cached entries, file refs, and resets stats. Returns the number of entries removed.
587    pub fn clear(&mut self) -> usize {
588        let count = self.entries.len();
589        self.entries.clear();
590        self.file_refs.clear();
591        self.shared_blocks.clear();
592        self.next_ref = 1;
593        self.stats = CacheStats::default();
594        count
595    }
596}