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#[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
34pub 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 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 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 pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
76 self.file_refs.get(&normalize_key(path)).cloned()
77 }
78
79 pub fn get(&self, path: &str) -> Option<&CacheEntry> {
81 self.entries.get(&normalize_key(path))
82 }
83
84 pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
86 self.entries.get_mut(&normalize_key(path))
87 }
88
89 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 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 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 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_source_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 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 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 pub fn total_cached_tokens(&self) -> usize {
303 self.entries.values().map(|e| e.original_tokens).sum()
304 }
305
306 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 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 pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
337 self.entries.iter().collect()
338 }
339
340 pub fn get_stats(&self) -> &CacheStats {
342 &self.stats
343 }
344
345 pub fn file_ref_map(&self) -> &HashMap<String, String> {
347 &self.file_refs
348 }
349
350 pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
352 self.shared_blocks = blocks;
353 }
354
355 pub fn get_shared_blocks(&self) -> &[SharedBlock] {
357 &self.shared_blocks
358 }
359
360 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 pub fn invalidate(&mut self, path: &str) -> bool {
389 self.entries.remove(&normalize_key(path)).is_some()
390 }
391
392 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 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 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 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 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 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 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 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 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 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 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 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 })
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 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 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}