Skip to main content

sqz_engine/
cache_manager.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::time::Duration;
4
5use sha2::{Digest, Sha256};
6
7use crate::delta_encoder::DeltaEncoder;
8use crate::error::Result;
9use crate::pipeline::{CompressionPipeline, SessionContext};
10use crate::preset::Preset;
11use crate::session_store::SessionStore;
12use crate::types::CompressedContent;
13
14/// Outcome of a cache lookup in [`CacheManager`].
15///
16/// The cache has three possible outcomes:
17/// - **Dedup**: exact match, returns a tiny `§ref:HASH§` token (~13 tokens)
18/// - **Delta**: near-duplicate, returns a compact diff against the cached version
19/// - **Fresh**: cache miss, returns the full compressed output
20pub enum CacheResult {
21    /// Previously seen content — returns a short inline reference (~13 tokens).
22    Dedup {
23        /// Inline token of the form `§ref:<hash_prefix>§`.
24        inline_ref: String,
25        /// Approximate token cost of the reference (always 13).
26        token_cost: u32,
27    },
28    /// Near-duplicate of cached content — returns a compact delta.
29    Delta {
30        /// The delta text (header + changed lines).
31        delta_text: String,
32        /// Approximate token cost of the delta.
33        token_cost: u32,
34        /// Similarity to the cached version (0.0–1.0).
35        similarity: f64,
36    },
37    /// Content not seen before — full compression result.
38    Fresh { output: CompressedContent },
39}
40
41/// Result of resolving a dedup-ref prefix via
42/// [`CacheManager::expand_prefix`].
43///
44/// Two shapes because not every stored entry can be round-tripped to the
45/// raw bytes: cache entries written before the `original` column was
46/// introduced (or by a code path that didn't thread originals through)
47/// fall into `CompressedOnly`. The CLI uses this to pick a message for
48/// the user.
49#[derive(Debug, Clone)]
50pub enum ExpandResult {
51    /// Full pre-compression bytes available — this is what `sqz expand`
52    /// was invented to return.
53    Original {
54        /// Full 64-hex SHA-256 of the original content. Lets callers
55        /// print `expanded a1b2c3d4 (full hash a1b2c3d4…)`.
56        hash: String,
57        /// The raw bytes the agent asked to see.
58        bytes: Vec<u8>,
59    },
60    /// Only the compressed form is stored. Agent still gets legible
61    /// output; the CLI attaches a note so the user knows why re-running
62    /// uncompressed may be worthwhile.
63    CompressedOnly {
64        hash: String,
65        /// Compressed text as served to the LLM originally.
66        compressed: String,
67    },
68}
69
70/// A dedup hit with the metadata regret tracking needs, returned by
71/// [`CacheManager::check_dedup_with_meta`].
72#[derive(Debug, Clone)]
73pub struct DedupHit {
74    /// The `§ref:…§` token to serve instead of the full content.
75    pub inline_ref: String,
76    /// Full 64-hex SHA-256 of the content.
77    pub hash: String,
78    /// The entry's `accessed_at` before this lookup bumped it. `None`
79    /// when the column could not be read.
80    pub prev_accessed_at: Option<chrono::DateTime<chrono::Utc>>,
81}
82
83/// A ranged re-read of content the model already has, returned by
84/// [`CacheManager::check_slice_with_meta`]. The agent read a file in
85/// full earlier and is now reading lines `start_line..=end_line` of it.
86#[derive(Debug, Clone)]
87pub struct SliceHit {
88    /// `§ref:<hash_prefix>:L<start>-<end>§`, or a plain `§ref:<hash_prefix>§`
89    /// when the range covers the whole original.
90    pub inline_ref: String,
91    /// Full 64-hex SHA-256 of the containing original.
92    pub hash: String,
93    pub start_line: usize,
94    pub end_line: usize,
95    /// Line count of the containing original.
96    pub total_lines: usize,
97    /// Approximate token cost of the reference.
98    pub token_cost: u32,
99}
100
101/// Smallest slice worth referencing. Below this the ref token costs
102/// about as much as the text, and short multi-line snippets are common
103/// enough to match by coincidence.
104const MIN_SLICE_BYTES: usize = 160;
105/// How many recent originals to scan per lookup.
106const SLICE_CANDIDATES: usize = 32;
107
108/// Tracks when a dedup ref was last sent, so we can detect staleness.
109///
110/// Historically used for an in-memory per-process turn counter; now kept
111/// only for interface compatibility (clear on notify_compaction). Actual
112/// staleness is computed from SQLite `accessed_at` timestamps so it works
113/// across the shell-hook invocation model where each sqz process is short-
114/// lived. See the comment on `is_ref_fresh` for details.
115#[derive(Debug, Clone)]
116#[allow(dead_code)]
117struct RefEntry {
118    /// The turn number when this ref was last sent to the LLM.
119    last_sent_turn: u64,
120}
121
122/// SHA-256 content-hash deduplication cache backed by [`SessionStore`],
123/// with delta encoding for near-duplicate content and compaction awareness.
124///
125/// # Freshness model
126///
127/// A dedup ref is considered fresh (safe to serve instead of the full
128/// content) when the cache entry's `accessed_at` timestamp in SQLite is
129/// within `max_ref_age` of now. When sqz is invoked from shell hooks each
130/// invocation is a short-lived process, so the freshness check must be
131/// persistent — in-memory state is gone the moment the process exits.
132///
133/// The previous turn-counter heuristic was in-memory only and therefore
134/// never registered freshness across hook invocations, which silently
135/// disabled the dedup feature in production. Issue found April 18 2026.
136///
137/// Default TTL: 30 minutes. Empirically matches a typical active coding
138/// session before a context compaction. Use
139/// [`with_ref_age`](CacheManager::with_ref_age) to tune.
140pub struct CacheManager {
141    store: SessionStore,
142    max_size_bytes: u64,
143    delta_encoder: DeltaEncoder,
144    /// Retained for notify_compaction's semantic ("forget all tracked refs"),
145    /// but no longer consulted for freshness checks.
146    #[allow(dead_code)]
147    turn_counter: std::cell::Cell<u64>,
148    /// Retained for notify_compaction; cleared on compaction events.
149    #[allow(dead_code)]
150    ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
151    /// Maximum wall-clock age before a dedup ref is considered stale.
152    /// After this duration we assume the LLM's context window has rolled
153    /// over enough to have dropped the original content, so we re-send the
154    /// full version instead of a dangling ref.
155    max_ref_age: Duration,
156    /// Records the instant at which the in-memory compaction flag was set.
157    /// Any cache entry whose `accessed_at` predates this instant is stale.
158    /// Reset by [`notify_compaction`].
159    compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
160    /// Session-lifetime MinHash LSH index over stored entries, used to find
161    /// near-duplicate candidates for delta encoding beyond the recent-LRU
162    /// window. In-memory only; rebuilt organically as entries are stored.
163    lsh: std::cell::RefCell<crate::minhash_lsh::MinHashLsh>,
164    /// doc_id (index) → content hash for entries in `lsh`.
165    lsh_hashes: std::cell::RefCell<Vec<String>>,
166}
167
168impl CacheManager {
169    /// Create a new cache manager backed by the given session store.
170    ///
171    /// `max_size_bytes` controls when LRU eviction kicks in. A good default
172    /// is 512 MB (`512 * 1024 * 1024`). Dedup refs go stale after 30 minutes
173    /// of wall-clock time by default; use [`with_ref_age`](Self::with_ref_age)
174    /// to tune.
175    pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
176        Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
177    }
178
179    /// Create a CacheManager with a custom ref staleness threshold measured
180    /// in turns. The turn count is converted to wall-clock time by assuming
181    /// ~1 minute per turn (a rough approximation; the real freshness check
182    /// uses SQLite timestamps). This constructor exists for backward
183    /// compatibility with tests that previously advanced a turn counter.
184    #[doc(hidden)]
185    pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
186        Self::with_ref_age_duration(
187            store,
188            max_size_bytes,
189            Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
190        )
191    }
192
193    /// Create a CacheManager with an explicit wall-clock ref-age cap.
194    pub fn with_ref_age_duration(
195        store: SessionStore,
196        max_size_bytes: u64,
197        max_ref_age: Duration,
198    ) -> Self {
199        Self {
200            store,
201            max_size_bytes,
202            delta_encoder: DeltaEncoder::new(),
203            turn_counter: std::cell::Cell::new(0),
204            ref_tracker: std::cell::RefCell::new(HashMap::new()),
205            max_ref_age,
206            compaction_marker: std::cell::Cell::new(None),
207            lsh: std::cell::RefCell::new(crate::minhash_lsh::MinHashLsh::new()),
208            lsh_hashes: std::cell::RefCell::new(Vec::new()),
209        }
210    }
211
212    /// Register a stored entry in the similarity index. Capped so a very
213    /// long session can't grow the index unboundedly; beyond the cap the
214    /// recent-LRU fallback in [`Self::try_delta_encode`] still applies.
215    fn index_for_similarity(&self, hash: &str, data: &str) {
216        const MAX_INDEXED: usize = 512;
217        let mut hashes = self.lsh_hashes.borrow_mut();
218        if hashes.len() >= MAX_INDEXED || hashes.iter().any(|h| h == hash) {
219            return;
220        }
221        let doc_id = hashes.len() as u64;
222        self.lsh.borrow_mut().insert(doc_id, data);
223        hashes.push(hash.to_string());
224    }
225
226    /// Compute the SHA-256 hex digest of `bytes`.
227    pub fn sha256_hex(bytes: &[u8]) -> String {
228        let mut hasher = Sha256::new();
229        hasher.update(bytes);
230        format!("{:x}", hasher.finalize())
231    }
232
233    /// Advance the turn counter. Retained for API compatibility; not used
234    /// for freshness. The context_evictor still reads `current_turn` for
235    /// LRU scoring during `sqz compact`.
236    pub fn advance_turn(&self) {
237        self.turn_counter.set(self.turn_counter.get() + 1);
238    }
239
240    /// Get the current turn number. Used by the context_evictor for scoring.
241    pub fn current_turn(&self) -> u64 {
242        self.turn_counter.get()
243    }
244
245    /// Notify the cache that a context compaction has occurred.
246    ///
247    /// Persists a compaction timestamp into the session store so any cache
248    /// entry whose `accessed_at` predates the marker is considered stale
249    /// by **every subsequent sqz process**, not just this one. The shell-
250    /// hook invocation model means this method is typically called from a
251    /// short-lived `sqz hook precompact` process, and the check runs in a
252    /// different `sqz compress` process milliseconds later.
253    ///
254    /// Call this when:
255    /// - The harness signals a compaction event (PreCompact hook)
256    /// - A session is resumed after being idle
257    /// - The user runs `sqz compact`
258    pub fn notify_compaction(&self) {
259        let now = chrono::Utc::now();
260        self.compaction_marker.set(Some(now));
261        self.ref_tracker.borrow_mut().clear();
262        // Persist the marker so other sqz processes see the invalidation.
263        // Silently swallow a write error: losing the marker means some
264        // refs may survive the compaction and show as dedup hits in the
265        // next few calls — annoying, not wrong (the agent still receives
266        // valid content; it just sees a short-ref it has to resolve).
267        let _ = self
268            .store
269            .set_metadata("last_compaction_at", &now.to_rfc3339());
270    }
271
272    /// Check if a dedup ref for the given hash is still fresh (likely still
273    /// in the LLM's context window).
274    ///
275    /// Uses the SQLite `accessed_at` timestamp rather than the in-memory
276    /// turn counter. This works across sqz process invocations: shell hooks
277    /// spawn a new sqz process per intercepted command, so any in-memory
278    /// counter would reset every time. The database survives.
279    ///
280    /// The compaction marker is read from SQLite on every check so that
281    /// a `sqz hook precompact` call from another process immediately
282    /// invalidates refs in the current process. Without the persistent
283    /// read, the invalidation would only affect the process that called
284    /// notify_compaction — which is never the same process that serves
285    /// dedup hits.
286    fn is_ref_fresh(&self, hash: &str) -> bool {
287        // Pinned entries bypass TTL and compaction-marker checks. They
288        // represent stable knowledge-base content that's persistently
289        // re-injected into every agent, so the original is never gone.
290        if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
291            return true;
292        }
293        let accessed = match self.store.get_cache_entry_accessed_at(hash) {
294            Ok(Some(ts)) => ts,
295            _ => return false,
296        };
297        // In-memory compaction marker (set in this process).
298        if let Some(marker) = self.compaction_marker.get() {
299            if accessed < marker {
300                return false;
301            }
302        }
303        // Persistent compaction marker — set by `sqz hook precompact` in
304        // a different process. Without this read the in-memory marker is
305        // never consulted because each hook invocation is a fresh process.
306        if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
307            if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
308                if accessed < marker {
309                    return false;
310                }
311            }
312        }
313        let age = (chrono::Utc::now() - accessed)
314            .to_std()
315            .unwrap_or(Duration::from_secs(0));
316        age < self.max_ref_age
317    }
318
319    /// Record that a dedup ref was sent for the given hash. Updates the
320    /// persistent `accessed_at` timestamp so subsequent freshness checks
321    /// see this send. Silently swallows SQLite errors — losing a touch
322    /// means the next call may treat the ref as stale and re-send, which
323    /// is strictly worse on tokens but never wrong.
324    fn record_ref_sent(&self, hash: &str) {
325        let _ = self.store.touch_cache_entry(hash);
326    }
327
328    /// Look up `content` in the cache with compaction awareness.
329    ///
330    /// - On exact dedup with fresh ref: return `CacheResult::Dedup` (~13 tokens).
331    /// - On exact dedup with stale ref: re-compress and return `CacheResult::Fresh`
332    ///   (the original content may have been compacted out of the LLM's context).
333    /// - On near-duplicate: return `CacheResult::Delta` with a compact diff.
334    /// - On cache miss: compress via `pipeline`, persist, return `CacheResult::Fresh`.
335    pub fn get_or_compress(
336        &self,
337        path: &Path,
338        content: &[u8],
339        pipeline: &CompressionPipeline,
340    ) -> Result<CacheResult> {
341        let ctx = SessionContext {
342            session_id: "cache".to_string(),
343        };
344        let preset = Preset::default();
345        self.get_or_compress_inner(path, content, &|text| pipeline.compress(text, &ctx, &preset))
346    }
347
348    /// [`get_or_compress`](Self::get_or_compress) with the caller's own
349    /// compressor for misses and stale refs, so a cached result goes through
350    /// the same verification as an uncached one.
351    pub fn get_or_compress_with(
352        &self,
353        path: &Path,
354        content: &[u8],
355        compress: &dyn Fn(&str) -> Result<CompressedContent>,
356    ) -> Result<CacheResult> {
357        self.get_or_compress_inner(path, content, compress)
358    }
359
360    /// Lossless variant used by the file-read MCP tools (`sqz_read_file` /
361    /// `sqz_grep` / `sqz_list_dir`): on a cache miss the content is returned
362    /// faithfully via [`CompressionPipeline::compress_lossless`] (no entropy
363    /// truncation / pruning / re-encoding). The general `compress` tool keeps
364    /// using [`get_or_compress`](Self::get_or_compress) for aggressive
365    /// compression. See issue #32.
366    pub fn get_or_compress_lossless(
367        &self,
368        path: &Path,
369        content: &[u8],
370        pipeline: &CompressionPipeline,
371    ) -> Result<CacheResult> {
372        let ctx = SessionContext {
373            session_id: "cache".to_string(),
374        };
375        let preset = Preset::default();
376        self.get_or_compress_inner(path, content, &|text| {
377            pipeline.compress_lossless(text, &ctx, &preset)
378        })
379    }
380
381    fn get_or_compress_inner(
382        &self,
383        _path: &Path,
384        content: &[u8],
385        compress: &dyn Fn(&str) -> Result<CompressedContent>,
386    ) -> Result<CacheResult> {
387        let hash = Self::sha256_hex(content);
388
389        // Exact match — check if the ref is still fresh
390        // Exact match — probe without touching accessed_at, then check
391        // freshness. Touching on the probe would make every ref appear
392        // fresh immediately (the timestamp we just wrote is `now`).
393        let exists = self.store.cache_entry_exists(&hash)?;
394        if exists {
395            if self.is_ref_fresh(&hash) {
396                // Ref is fresh — the LLM likely still has the original in context
397                let hash_prefix = &hash[..16];
398                let inline_ref = format!("§ref:{hash_prefix}§");
399                // Update the sent timestamp
400                self.record_ref_sent(&hash);
401                let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
402                return Ok(CacheResult::Dedup {
403                    inline_ref,
404                    token_cost: 13,
405                });
406            } else {
407                // Ref is stale — re-send the full compressed content.
408                // The original may have been compacted out of the LLM's context.
409                let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
410                let text = String::from_utf8_lossy(content).into_owned();
411                let compressed = compress(&text)?;
412                // Record that we re-sent this content
413                self.record_ref_sent(&hash);
414                return Ok(CacheResult::Fresh { output: compressed });
415            }
416        }
417
418        // Ranged re-read of a file the model already has in full.
419        if let Some(hit) = self.check_slice_with_meta(content)? {
420            return Ok(CacheResult::Dedup {
421                inline_ref: hit.inline_ref,
422                token_cost: hit.token_cost,
423            });
424        }
425
426        // Near-duplicate check: compare against recent cache entries
427        let text = String::from_utf8_lossy(content).into_owned();
428        let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
429        if let Some(delta_result) = self.try_delta_encode(&text)? {
430            // Store the new content in cache for future exact matches
431            let compressed = compress(&text)?;
432            // Persist the raw bytes so `sqz expand <prefix>` can round-trip.
433            self.store
434                .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
435            self.index_for_similarity(&hash, &compressed.data);
436            self.record_ref_sent(&hash);
437
438            let token_cost = (delta_result.delta_text.len() / 4) as u32;
439            return Ok(CacheResult::Delta {
440                delta_text: delta_result.delta_text,
441                token_cost: token_cost.max(5),
442                similarity: delta_result.similarity,
443            });
444        }
445
446        let compressed = compress(&text)?;
447        self.store
448            .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
449        self.index_for_similarity(&hash, &compressed.data);
450        // Record that this content was sent at the current turn
451        self.record_ref_sent(&hash);
452
453        Ok(CacheResult::Fresh { output: compressed })
454    }
455
456    /// Try to delta-encode content against cached entries.
457    /// Returns Some(DeltaResult) if a near-duplicate was found.
458    ///
459    /// Candidates come from two sources, in order:
460    /// 1. The MinHash LSH index — finds near-duplicates anywhere in the
461    ///    session, not just the recent window. Candidates are ranked by
462    ///    estimated Jaccard similarity and the best 5 are tried.
463    /// 2. Fallback: the 10 most recent LRU entries (covers content stored
464    ///    before this process built its in-memory index).
465    fn try_delta_encode(
466        &self,
467        new_content: &str,
468    ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
469        // LSH path.
470        let candidates: Vec<String> = {
471            let lsh = self.lsh.borrow();
472            let hashes = self.lsh_hashes.borrow();
473            let query_sig = crate::minhash_lsh::MinHashLsh::compute_signature(new_content);
474            let mut scored: Vec<(f64, &String)> = lsh
475                .query(new_content)
476                .into_iter()
477                .filter_map(|doc_id| {
478                    let hash = hashes.get(doc_id as usize)?;
479                    let sim = lsh.get_signature(doc_id)?.jaccard_similarity(&query_sig);
480                    (sim >= 0.5).then_some((sim, hash))
481                })
482                .collect();
483            scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
484            scored.into_iter().take(5).map(|(_, h)| h.clone()).collect()
485        };
486        for hash in &candidates {
487            if let Some(delta) = self.try_delta_against(hash, new_content)? {
488                return Ok(Some(delta));
489            }
490        }
491
492        // Recent-LRU fallback.
493        let entries = self.store.list_cache_entries_lru()?;
494        let check_count = entries.len().min(10);
495        for (hash, _) in entries.iter().rev().take(check_count) {
496            if candidates.iter().any(|c| c == hash) {
497                continue;
498            }
499            if let Some(delta) = self.try_delta_against(hash, new_content)? {
500                return Ok(Some(delta));
501            }
502        }
503
504        Ok(None)
505    }
506
507    fn try_delta_against(
508        &self,
509        hash: &str,
510        new_content: &str,
511    ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
512        if let Some(cached) = self.store.get_cache_entry(hash)? {
513            let hash_prefix = &hash[..hash.len().min(16)];
514            if let Ok(Some(delta)) = self.delta_encoder.encode(&cached.data, new_content, hash_prefix) {
515                if delta.delta_text.len() < new_content.len() {
516                    return Ok(Some(delta));
517                }
518            }
519        }
520        Ok(None)
521    }
522
523    /// Check if `content` is already in the persistent cache (dedup lookup only).
524    ///
525    /// Returns `Some(inline_ref)` if cached AND the ref is still fresh,
526    /// `None` if the content is not cached or the ref is stale.
527    ///
528    /// Unlike [`get_or_compress`](Self::get_or_compress), this method does
529    /// not touch `accessed_at` until after the freshness check; otherwise
530    /// every read would make itself "fresh."
531    pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
532        Ok(self.check_dedup_with_meta(content)?.map(|hit| hit.inline_ref))
533    }
534
535    /// [`check_dedup`](Self::check_dedup) plus the metadata callers need for
536    /// regret tracking: the full content hash and the entry's `accessed_at`
537    /// from *before* this lookup bumped it. A hit whose previous access was
538    /// seconds ago means the agent re-produced content it already had.
539    pub fn check_dedup_with_meta(&self, content: &[u8]) -> Result<Option<DedupHit>> {
540        let hash = Self::sha256_hex(content);
541        // Read the pre-hit timestamp before the freshness probe's
542        // record_ref_sent overwrites it.
543        let prev_accessed_at = self.store.get_cache_entry_accessed_at(&hash).unwrap_or(None);
544        let fresh = self.is_ref_fresh(&hash);
545        if fresh {
546            let hash_prefix = &hash[..16];
547            self.record_ref_sent(&hash);
548            Ok(Some(DedupHit {
549                inline_ref: format!("§ref:{hash_prefix}§"),
550                hash,
551                prev_accessed_at,
552            }))
553        } else {
554            // If the entry exists but is stale, don't return a dangling ref.
555            // If it doesn't exist at all, same result: no dedup.
556            Ok(None)
557        }
558    }
559
560    /// Is `content` a line range of something the model already has?
561    ///
562    /// Exact-hash dedup misses the common case: the agent read a file in
563    /// full, then re-reads lines 40-80 of it. Near-duplicate matching
564    /// misses it too (a 40-line slice of a 500-line file has a Jaccard
565    /// similarity near 0.1). This checks containment instead: the slice
566    /// must appear verbatim, on line boundaries, in a recent cached
567    /// original that is still fresh, and the same bytes must also appear
568    /// in what was actually served for that original, so a ref never
569    /// points at lines a lossy stage dropped. Touches the parent entry
570    /// on a hit.
571    pub fn check_slice_with_meta(&self, content: &[u8]) -> Result<Option<SliceHit>> {
572        let slice = strip_one_trailing_newline(content);
573        if slice.len() < MIN_SLICE_BYTES || !slice.contains(&b'\n') {
574            return Ok(None);
575        }
576        for candidate in self.store.recent_originals(slice.len(), SLICE_CANDIDATES)? {
577            let Some((start_line, end_line)) = find_line_range(&candidate.original, slice) else {
578                continue;
579            };
580            if !self.is_ref_fresh(&candidate.hash) {
581                continue;
582            }
583            let served: CompressedContent = match serde_json::from_str(&candidate.data_json) {
584                Ok(c) => c,
585                Err(_) => continue,
586            };
587            if !served.data.as_bytes().windows(slice.len()).any(|w| w == slice) {
588                continue;
589            }
590            let total_lines = count_lines(&candidate.original);
591            let hash_prefix = &candidate.hash[..16];
592            let inline_ref = if start_line == 1 && end_line == total_lines {
593                format!("§ref:{hash_prefix}§")
594            } else {
595                format!("§ref:{hash_prefix}:L{start_line}-{end_line}§")
596            };
597            let token_cost = 13 + (inline_ref.len().saturating_sub(22) as u32).div_ceil(3);
598            self.record_ref_sent(&candidate.hash);
599            let _ = self.store.record_cache_hit();
600            return Ok(Some(SliceHit {
601                inline_ref,
602                hash: candidate.hash,
603                start_line,
604                end_line,
605                total_lines,
606                token_cost,
607            }));
608        }
609        Ok(None)
610    }
611
612    /// Store a compressed result in the persistent cache, keyed by the
613    /// SHA-256 hash of the original content.
614    ///
615    /// Also records the ref as sent at the current turn for compaction tracking.
616    /// Persists `original_content` alongside `compressed` so that
617    /// `sqz expand <prefix>` can recover the raw bytes for agents that
618    /// cannot parse `§ref:…§` dedup tokens.
619    pub fn store_compressed(
620        &self,
621        original_content: &[u8],
622        compressed: &CompressedContent,
623    ) -> Result<()> {
624        let hash = Self::sha256_hex(original_content);
625        self.store
626            .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
627        self.index_for_similarity(&hash, &compressed.data);
628        self.record_ref_sent(&hash);
629        Ok(())
630    }
631
632    /// Resolve a hex prefix (the 16-char tail of a `§ref:<prefix>§` token,
633    /// or any longer hex string pasted by a user) to the cached content.
634    ///
635    /// Designed for the `sqz expand <prefix>` CLI: the agent sees a ref
636    /// token it can't parse, runs `sqz expand a1b2c3d4e5f6g7h8`, and gets
637    /// back the raw bytes that produced the ref. This is the
638    /// user-visible escape hatch SquireNed asked for.
639    ///
640    /// Three outcomes:
641    ///
642    /// * `Ok(Some(Original))` — the original bytes were captured when
643    ///   the entry was stored (new cache entries from v0.10.0+ always
644    ///   capture the original). Write `bytes` to stdout.
645    /// * `Ok(Some(CompressedOnly))` — the entry exists but its `original`
646    ///   column is `NULL` (pre-migration data). We still return the
647    ///   compressed form — always legible, always useful — and the CLI
648    ///   surfaces a note that tells the user to re-run their original
649    ///   command with `--no-cache` to capture a truly uncompressed copy.
650    /// * `Ok(None)` — no entry matches. Usually means the ref was
651    ///   truncated from a different sqz database (a different machine,
652    ///   a wiped `~/.sqz/sessions.db`, etc.).
653    /// * `Err(_)` — prefix was ambiguous or the DB is broken. The error
654    ///   carries a user-readable message explaining what went wrong.
655    ///
656    /// Touches `accessed_at` on hit (consistent with `get_cache_entry`).
657    pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
658        let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
659            return Ok(None);
660        };
661
662        // Every expand is a recovery round trip the agent paid for —
663        // count it so `sqz stats` can show how often refs get unwound.
664        // Gap is unknown here (the prefix lookup already bumped
665        // accessed_at), so record 0.0.
666        let _ = self.store.log_regret("expand", "", &hash, 0.0);
667
668        // Prefer the original bytes when we have them — that's the
669        // whole point of this feature.
670        if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
671            return Ok(Some(ExpandResult::Original { hash, bytes }));
672        }
673        Ok(Some(ExpandResult::CompressedOnly {
674            hash,
675            compressed: compressed_entry.data,
676        }))
677    }
678
679    /// Resolve a ref token in any of the forms an agent might paste:
680    /// `a1b2c3d4`, `§ref:a1b2c3d4§`, `ref:a1b2c3d4`, or the ranged
681    /// `§ref:a1b2c3d4:L40-80§`. A range narrows the returned bytes to
682    /// those lines (1-based, inclusive).
683    pub fn expand_ref(&self, raw: &str) -> Result<Option<ExpandResult>> {
684        let (prefix, range) = parse_ref_token(raw);
685        let Some(result) = self.expand_prefix(prefix)? else {
686            return Ok(None);
687        };
688        let Some((start, end)) = range else {
689            return Ok(Some(result));
690        };
691        Ok(Some(match result {
692            ExpandResult::Original { hash, bytes } => ExpandResult::Original {
693                hash,
694                bytes: slice_lines(&bytes, start, end).to_vec(),
695            },
696            ExpandResult::CompressedOnly { hash, compressed } => ExpandResult::CompressedOnly {
697                hash,
698                compressed: String::from_utf8_lossy(slice_lines(compressed.as_bytes(), start, end))
699                    .into_owned(),
700            },
701        }))
702    }
703
704    /// Invalidate the cache entry for `path` if its current content is known.
705    ///
706    /// Reads the file at `path`, computes its hash, and removes the matching
707    /// entry from the store.  If the file does not exist the call is a no-op.
708    pub fn invalidate(&self, path: &Path) -> Result<()> {
709        if !path.exists() {
710            return Ok(());
711        }
712        let bytes = std::fs::read(path)?;
713        let hash = Self::sha256_hex(&bytes);
714        self.store.delete_cache_entry(&hash)?;
715        Ok(())
716    }
717
718    /// Evict least-recently-used entries until total cache size is at or below
719    /// `max_size_bytes`.
720    ///
721    /// Returns the number of bytes freed.
722    pub fn evict_lru(&self) -> Result<u64> {
723        let entries = self.store.list_cache_entries_lru()?;
724
725        // Compute current total size.
726        let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
727        if total <= self.max_size_bytes {
728            return Ok(0);
729        }
730
731        let mut freed: u64 = 0;
732        let mut remaining = total;
733
734        for (hash, size) in &entries {
735            if remaining <= self.max_size_bytes {
736                break;
737            }
738            self.store.delete_cache_entry(hash)?;
739            freed += size;
740            remaining -= size;
741        }
742
743        Ok(freed)
744    }
745}
746
747fn strip_one_trailing_newline(bytes: &[u8]) -> &[u8] {
748    match bytes {
749        [rest @ .., b'\n'] => rest,
750        _ => bytes,
751    }
752}
753
754fn count_lines(bytes: &[u8]) -> usize {
755    let body = strip_one_trailing_newline(bytes);
756    if body.is_empty() {
757        0
758    } else {
759        1 + body.iter().filter(|&&b| b == b'\n').count()
760    }
761}
762
763/// Find `slice` in `original` starting at a line start and ending at a line
764/// end. Returns 1-based inclusive line numbers of the first such occurrence.
765fn find_line_range(original: &[u8], slice: &[u8]) -> Option<(usize, usize)> {
766    if slice.is_empty() || slice.len() > original.len() {
767        return None;
768    }
769    let mut from = 0;
770    while from + slice.len() <= original.len() {
771        let rel = original[from..].windows(slice.len()).position(|w| w == slice)?;
772        let at = from + rel;
773        let end = at + slice.len();
774        let starts_line = at == 0 || original[at - 1] == b'\n';
775        let ends_line = end == original.len() || original[end] == b'\n';
776        if starts_line && ends_line {
777            let start_line = 1 + original[..at].iter().filter(|&&b| b == b'\n').count();
778            let end_line = start_line + slice.iter().filter(|&&b| b == b'\n').count();
779            return Some((start_line, end_line));
780        }
781        from = at + 1;
782    }
783    None
784}
785
786/// Split a ref token into its hex prefix and optional `L<start>-<end>` range.
787/// Accepts `a1b2`, `ref:a1b2`, `§ref:a1b2§`, `§ref:a1b2:L40-80§`.
788pub fn parse_ref_token(raw: &str) -> (&str, Option<(usize, usize)>) {
789    let body = raw
790        .trim()
791        .trim_start_matches('§')
792        .trim_start_matches("ref:")
793        .trim_end_matches('§')
794        .trim();
795    let Some((prefix, range)) = body.split_once(":L") else {
796        return (body, None);
797    };
798    let parsed = range
799        .split_once('-')
800        .and_then(|(a, b)| Some((a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)))
801        .filter(|(a, b)| *a >= 1 && b >= a);
802    match parsed {
803        Some(r) => (prefix, Some(r)),
804        None => (body, None),
805    }
806}
807
808/// Bytes of lines `start..=end` (1-based) of `bytes`, newlines included.
809fn slice_lines(bytes: &[u8], start: usize, end: usize) -> &[u8] {
810    let mut line = 1;
811    let mut begin = None;
812    for (i, &b) in bytes.iter().enumerate() {
813        if line == start && begin.is_none() {
814            begin = Some(i);
815        }
816        if b == b'\n' {
817            if line == end {
818                return &bytes[begin.unwrap_or(i)..=i];
819            }
820            line += 1;
821        }
822    }
823    match begin {
824        Some(b) => &bytes[b..],
825        None => &[],
826    }
827}
828
829// ── Tests ─────────────────────────────────────────────────────────────────────
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::preset::{
835        BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
836        CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
837        ToolSelectionConfig, TruncateStringsConfig,
838    };
839    use crate::session_store::SessionStore;
840
841    fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
842        let dir = tempfile::tempdir().unwrap();
843        let path = dir.path().join("test.db");
844        let store = SessionStore::open_or_create(&path).unwrap();
845        (store, dir)
846    }
847
848    #[test]
849    fn check_dedup_with_meta_reports_previous_access() {
850        let (store, _dir) = in_memory_store();
851        let cm = CacheManager::new(store, u64::MAX);
852        let pipeline = make_pipeline();
853        let content = b"meta test content that is unique to this test";
854
855        // Not cached yet: no hit, no metadata.
856        assert!(cm.check_dedup_with_meta(content).unwrap().is_none());
857
858        cm.get_or_compress(Path::new("meta.txt"), content, &pipeline)
859            .unwrap();
860        let before = chrono::Utc::now();
861        let hit = cm
862            .check_dedup_with_meta(content)
863            .unwrap()
864            .expect("expected dedup hit");
865        assert!(hit.inline_ref.starts_with("§ref:"));
866        assert_eq!(hit.hash, CacheManager::sha256_hex(content));
867        // prev_accessed_at is the store-time touch, which happened before
868        // this lookup — so it must exist and not be in the future.
869        let prev = hit.prev_accessed_at.expect("expected prior access time");
870        assert!(prev <= before + chrono::Duration::seconds(1));
871    }
872
873    fn source_file(lines: usize) -> Vec<u8> {
874        let mut s = String::new();
875        for i in 1..=lines {
876            s.push_str(&format!("def handler_{i:03}(request):\n    return verify(request, scope='h{i:03}')\n"));
877        }
878        s.into_bytes()
879    }
880
881    fn lines_of(bytes: &[u8], start: usize, end: usize) -> Vec<u8> {
882        slice_lines(bytes, start, end).to_vec()
883    }
884
885    #[test]
886    fn slice_of_fresh_original_returns_line_range_ref() {
887        let (store, _dir) = in_memory_store();
888        let cm = CacheManager::new(store, u64::MAX);
889        let pipeline = make_pipeline();
890        let file = source_file(60);
891        cm.get_or_compress_lossless(Path::new("auth.py"), &file, &pipeline).unwrap();
892        let hash = CacheManager::sha256_hex(&file);
893
894        let slice = lines_of(&file, 41, 60);
895        let hit = cm.check_slice_with_meta(&slice).unwrap().expect("slice hit");
896        assert_eq!(hit.inline_ref, format!("§ref:{}:L41-60§", &hash[..16]));
897        assert_eq!((hit.start_line, hit.end_line, hit.total_lines), (41, 60, 120));
898        assert!(hit.token_cost > 13 && hit.token_cost < 25, "{}", hit.token_cost);
899
900        // Same answer through the cache-aware compress path.
901        match cm.get_or_compress_lossless(Path::new("auth.py"), &slice, &pipeline).unwrap() {
902            CacheResult::Dedup { inline_ref, .. } => assert_eq!(inline_ref, hit.inline_ref),
903            _ => panic!("expected slice dedup"),
904        }
905        // Without the trailing newline the ref is the same.
906        let mut no_nl = slice.clone();
907        no_nl.pop();
908        assert_eq!(cm.check_slice_with_meta(&no_nl).unwrap().unwrap().inline_ref, hit.inline_ref);
909    }
910
911    #[test]
912    fn slice_ref_requires_line_boundaries_and_size() {
913        let (store, _dir) = in_memory_store();
914        let cm = CacheManager::new(store, u64::MAX);
915        let pipeline = make_pipeline();
916        let file = source_file(60);
917        cm.get_or_compress_lossless(Path::new("auth.py"), &file, &pipeline).unwrap();
918
919        // Starts mid-line: not a line range.
920        let mid = &lines_of(&file, 10, 20)[4..];
921        assert!(cm.check_slice_with_meta(mid).unwrap().is_none());
922        // Single line, even a long one, is not worth a ref.
923        let one = lines_of(&file, 10, 10);
924        assert!(cm.check_slice_with_meta(&one).unwrap().is_none());
925        // Two lines under 160 bytes: below the size floor.
926        let two = lines_of(&file, 10, 11);
927        assert!(two.len() < MIN_SLICE_BYTES);
928        assert!(cm.check_slice_with_meta(&two).unwrap().is_none());
929        // Content that is not in any original.
930        let other = source_file(6).into_iter().map(|b| if b == b'h' { b'x' } else { b }).collect::<Vec<_>>();
931        assert!(cm.check_slice_with_meta(&other).unwrap().is_none());
932    }
933
934    #[test]
935    fn slice_ref_not_served_after_compaction() {
936        let (store, _dir) = in_memory_store();
937        let cm = CacheManager::new(store, u64::MAX);
938        let pipeline = make_pipeline();
939        let file = source_file(60);
940        cm.get_or_compress_lossless(Path::new("auth.py"), &file, &pipeline).unwrap();
941        let slice = lines_of(&file, 1, 30);
942        assert!(cm.check_slice_with_meta(&slice).unwrap().is_some());
943
944        std::thread::sleep(std::time::Duration::from_millis(20));
945        cm.notify_compaction();
946        assert!(cm.check_slice_with_meta(&slice).unwrap().is_none(), "model no longer has the original");
947    }
948
949    #[test]
950    fn slice_ref_only_for_lines_the_model_actually_saw() {
951        let (store, _dir) = in_memory_store();
952        let cm = CacheManager::new(store, u64::MAX);
953        let file = source_file(60);
954        // Simulate a lossy first serving that dropped the second half.
955        let served = String::from_utf8(lines_of(&file, 1, 60)).unwrap();
956        let compressed = CompressedContent {
957            data: served,
958            tokens_compressed: 100,
959            tokens_original: 200,
960            stages_applied: vec!["entropy_truncate".into()],
961            compression_ratio: 0.5,
962            provenance: Default::default(),
963            verify: None,
964        };
965        cm.store_compressed(&file, &compressed).unwrap();
966
967        assert!(cm.check_slice_with_meta(&lines_of(&file, 10, 40)).unwrap().is_some(), "served lines are referenceable");
968        assert!(cm.check_slice_with_meta(&lines_of(&file, 70, 100)).unwrap().is_none(), "dropped lines must be re-sent");
969    }
970
971    #[test]
972    fn whole_file_minus_trailing_newline_gets_plain_ref() {
973        let (store, _dir) = in_memory_store();
974        let cm = CacheManager::new(store, u64::MAX);
975        let pipeline = make_pipeline();
976        let file = source_file(30);
977        cm.get_or_compress_lossless(Path::new("a.py"), &file, &pipeline).unwrap();
978        let hash = CacheManager::sha256_hex(&file);
979        let mut no_nl = file.clone();
980        no_nl.pop();
981        let hit = cm.check_slice_with_meta(&no_nl).unwrap().unwrap();
982        assert_eq!(hit.inline_ref, format!("§ref:{}§", &hash[..16]));
983    }
984
985    #[test]
986    fn expand_ref_parses_every_token_form_and_slices_lines() {
987        assert_eq!(parse_ref_token("a1b2c3d4"), ("a1b2c3d4", None));
988        assert_eq!(parse_ref_token("§ref:a1b2c3d4§"), ("a1b2c3d4", None));
989        assert_eq!(parse_ref_token(" ref:a1b2c3d4 "), ("a1b2c3d4", None));
990        assert_eq!(parse_ref_token("§ref:a1b2c3d4:L40-80§"), ("a1b2c3d4", Some((40, 80))));
991        assert_eq!(parse_ref_token("a1b2c3d4:L7-7"), ("a1b2c3d4", Some((7, 7))));
992        assert_eq!(parse_ref_token("a1b2c3d4:L9-3"), ("a1b2c3d4:L9-3", None), "inverted range is not a range");
993
994        let (store, _dir) = in_memory_store();
995        let cm = CacheManager::new(store, u64::MAX);
996        let pipeline = make_pipeline();
997        let file = source_file(20);
998        cm.get_or_compress_lossless(Path::new("a.py"), &file, &pipeline).unwrap();
999        let hash = CacheManager::sha256_hex(&file);
1000
1001        match cm.expand_ref(&format!("§ref:{}:L5-8§", &hash[..16])).unwrap().unwrap() {
1002            ExpandResult::Original { bytes, .. } => assert_eq!(bytes, lines_of(&file, 5, 8)),
1003            _ => panic!("expected original bytes"),
1004        }
1005        match cm.expand_ref(&hash[..16]).unwrap().unwrap() {
1006            ExpandResult::Original { bytes, .. } => assert_eq!(bytes, file),
1007            _ => panic!("expected original bytes"),
1008        }
1009        // Range past the end returns what exists.
1010        match cm.expand_ref(&format!("{}:L39-99", &hash[..16])).unwrap().unwrap() {
1011            ExpandResult::Original { bytes, .. } => assert_eq!(bytes, lines_of(&file, 39, 40)),
1012            _ => panic!("expected original bytes"),
1013        }
1014    }
1015
1016    #[test]
1017    fn line_helpers() {
1018        let text = b"a\nbb\nccc\ndddd\n";
1019        assert_eq!(count_lines(text), 4);
1020        assert_eq!(count_lines(b"a\nbb"), 2);
1021        assert_eq!(count_lines(b""), 0);
1022        assert_eq!(find_line_range(text, b"bb\nccc"), Some((2, 3)));
1023        assert_eq!(find_line_range(text, b"b\nccc"), None);
1024        assert_eq!(find_line_range(text, b"bb\ncc"), None);
1025        assert_eq!(find_line_range(text, b"a\nbb\nccc\ndddd"), Some((1, 4)));
1026        assert_eq!(slice_lines(text, 2, 3), b"bb\nccc\n");
1027        assert_eq!(slice_lines(text, 4, 9), b"dddd\n");
1028        assert_eq!(slice_lines(text, 5, 9), b"");
1029        assert_eq!(slice_lines(b"x\ny", 2, 2), b"y");
1030    }
1031
1032    #[test]
1033    fn expand_prefix_logs_an_expand_regret() {
1034        let (store, _dir) = in_memory_store();
1035        let cm = CacheManager::new(store, u64::MAX);
1036        let pipeline = make_pipeline();
1037        let content = b"expand regret test content";
1038        cm.get_or_compress(Path::new("regret.txt"), content, &pipeline)
1039            .unwrap();
1040
1041        let hash = CacheManager::sha256_hex(content);
1042        let result = cm.expand_prefix(&hash[..16]).unwrap();
1043        assert!(result.is_some());
1044
1045        let regret = cm.store.regret_stats().unwrap();
1046        assert_eq!(regret.expands, 1);
1047        assert_eq!(regret.reruns, 0);
1048    }
1049
1050    fn test_preset() -> Preset {
1051        Preset {
1052            preset: PresetMeta {
1053                name: "test".into(),
1054                version: "1.0".into(),
1055                description: String::new(),
1056            },
1057            compression: CompressionConfig {
1058                stages: vec![],
1059                keep_fields: None,
1060                strip_fields: None,
1061                condense: Some(CondenseConfig {
1062                    enabled: true,
1063                    max_repeated_lines: 3,
1064                }),
1065                git_diff_fold: None,
1066                strip_nulls: Some(StripNullsConfig { enabled: true }),
1067                flatten: None,
1068                truncate_strings: Some(TruncateStringsConfig {
1069                    enabled: true,
1070                    max_length: 500,
1071                }),
1072                collapse_arrays: Some(CollapseArraysConfig {
1073                    enabled: true,
1074                    max_items: 5,
1075                    max_chars: 32_000,
1076                    summary_template: "... and {remaining} more items".into(),
1077                }),
1078                custom_transforms: Some(CustomTransformsConfig { enabled: true }),
1079            },
1080            tool_selection: ToolSelectionConfig {
1081                max_tools: 5,
1082                similarity_threshold: 0.7,
1083                default_tools: vec![],
1084            },
1085            budget: BudgetConfig {
1086                warning_threshold: 0.70,
1087                ceiling_threshold: 0.85,
1088                default_window_size: 200_000,
1089                agents: Default::default(),
1090            },
1091            terse_mode: TerseModeConfig {
1092                enabled: false,
1093                level: crate::preset::TerseLevel::Moderate,
1094            },
1095            model: ModelConfig {
1096                family: "anthropic".into(),
1097                primary: "claude-sonnet-4-20250514".into(),
1098                local: String::new(),
1099                complexity_threshold: 0.4,
1100                pricing: None,
1101            },
1102        }
1103    }
1104
1105    fn make_pipeline() -> CompressionPipeline {
1106        CompressionPipeline::new(&test_preset())
1107    }
1108
1109    #[test]
1110    fn first_read_is_miss() {
1111        let (store, _dir) = in_memory_store();
1112        let cm = CacheManager::new(store, u64::MAX);
1113        let pipeline = make_pipeline();
1114        let content = b"hello world";
1115        let result = cm
1116            .get_or_compress(Path::new("file.txt"), content, &pipeline)
1117            .unwrap();
1118        assert!(matches!(result, CacheResult::Fresh { .. }));
1119    }
1120
1121    /// Regression for https://github.com/ojuschugh1/sqz/issues/32 — the MCP
1122    /// file-read path (`get_or_compress_lossless`, used by sqz_read_file /
1123    /// sqz_grep / sqz_list_dir) returns content faithfully, while the general
1124    /// `compress` tool keeps using the lossy `get_or_compress`.
1125    #[test]
1126    fn get_or_compress_lossless_does_not_truncate_source() {
1127        let pipeline = make_pipeline();
1128
1129        // >500 bytes of multi-segment non-JSON "source" that the lossy path
1130        // entropy-truncates by roughly half.
1131        let mut segs = Vec::new();
1132        // 24 segments: clears the benign-aware 1000-byte threshold.
1133        for i in 0..24 {
1134            if i % 2 == 0 {
1135                segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
1136            } else {
1137                segs.push(format!(
1138                    "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
1139                ));
1140            }
1141        }
1142        let content = segs.join("\n\n");
1143        assert!(content.len() > 1000);
1144
1145        // Lossy path (the general `compress` tool) still truncates.
1146        let (s1, _d1) = in_memory_store();
1147        let lossy = CacheManager::new(s1, u64::MAX);
1148        match lossy
1149            .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
1150            .unwrap()
1151        {
1152            CacheResult::Fresh { output } => {
1153                assert!(output.data.contains("omitted"), "lossy path should truncate");
1154            }
1155            _ => panic!("expected Fresh on a cold read"),
1156        }
1157
1158        // Lossless path (file tools) keeps every segment.
1159        let (s2, _d2) = in_memory_store();
1160        let cm = CacheManager::new(s2, u64::MAX);
1161        match cm
1162            .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
1163            .unwrap()
1164        {
1165            CacheResult::Fresh { output } => {
1166                assert!(
1167                    !output.data.contains("omitted"),
1168                    "file-read path truncated content: {}",
1169                    output.data
1170                );
1171                for i in 0..10 {
1172                    assert!(
1173                        output.data.contains(&format!("SEG{i}")),
1174                        "file-read path dropped SEG{i}"
1175                    );
1176                }
1177            }
1178            _ => panic!("expected CacheResult::Fresh on a cold read"),
1179        }
1180    }
1181
1182    /// MinHash LSH finds a near-duplicate even after 12 unrelated entries
1183    /// have pushed the original out of the recent-10 LRU fallback window.
1184    #[test]
1185    fn lsh_finds_near_duplicate_beyond_lru_window() {
1186        let (store, _dir) = in_memory_store();
1187        let cm = CacheManager::new(store, u64::MAX);
1188        let pipeline = make_pipeline();
1189
1190        let base: String = (0..40)
1191            .map(|i| format!("stable log line number {i} with steady content here\n"))
1192            .collect();
1193        cm.get_or_compress(Path::new("base"), base.as_bytes(), &pipeline)
1194            .unwrap();
1195
1196        // Bury it under 12 distinct entries.
1197        for n in 0..12 {
1198            let filler: String = (0..30)
1199                .map(|i| format!("filler doc {n} row {i} totally different subject matter\n"))
1200                .collect();
1201            cm.get_or_compress(Path::new("filler"), filler.as_bytes(), &pipeline)
1202                .unwrap();
1203        }
1204
1205        // One line changed: a near-duplicate of the buried base.
1206        let variant = base.replace(
1207            "stable log line number 7 with steady content here",
1208            "stable log line number 7 with slightly altered content",
1209        );
1210        let result = cm
1211            .get_or_compress(Path::new("variant"), variant.as_bytes(), &pipeline)
1212            .unwrap();
1213        let kind = match &result {
1214            CacheResult::Dedup { .. } => "Dedup",
1215            CacheResult::Delta { .. } => "Delta",
1216            CacheResult::Fresh { .. } => "Fresh",
1217        };
1218        assert!(
1219            matches!(result, CacheResult::Delta { .. }),
1220            "near-duplicate beyond the LRU window must resolve via LSH, got {kind}"
1221        );
1222    }
1223
1224    #[test]
1225    fn second_read_is_hit() {
1226        let (store, _dir) = in_memory_store();
1227        let cm = CacheManager::new(store, u64::MAX);
1228        let pipeline = make_pipeline();
1229        let content = b"hello world";
1230        let path = Path::new("file.txt");
1231
1232        // First read — miss
1233        cm.get_or_compress(path, content, &pipeline).unwrap();
1234
1235        // Second read — hit
1236        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1237        match result {
1238            CacheResult::Dedup {
1239                inline_ref,
1240                token_cost,
1241            } => {
1242                assert!(inline_ref.starts_with("§ref:"));
1243                assert!(inline_ref.ends_with('§'));
1244                assert_eq!(token_cost, 13);
1245            }
1246            CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
1247        }
1248    }
1249
1250    #[test]
1251    fn different_content_is_miss() {
1252        let (store, _dir) = in_memory_store();
1253        let cm = CacheManager::new(store, u64::MAX);
1254        let pipeline = make_pipeline();
1255        let path = Path::new("file.txt");
1256
1257        cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
1258        let result = cm
1259            .get_or_compress(path, b"content v2", &pipeline)
1260            .unwrap();
1261        assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
1262    }
1263
1264    #[test]
1265    fn evict_lru_frees_bytes_when_over_limit() {
1266        let (store, _dir) = in_memory_store();
1267        // Very small limit so eviction triggers immediately.
1268        let cm = CacheManager::new(store, 1);
1269        let pipeline = make_pipeline();
1270        let path = Path::new("f.txt");
1271
1272        // Populate cache with a few entries.
1273        cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
1274        cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
1275        cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
1276
1277        let freed = cm.evict_lru().unwrap();
1278        assert!(freed > 0, "expected bytes to be freed");
1279    }
1280
1281    #[test]
1282    fn evict_lru_no_op_when_under_limit() {
1283        let (store, _dir) = in_memory_store();
1284        let cm = CacheManager::new(store, u64::MAX);
1285        let pipeline = make_pipeline();
1286
1287        cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
1288            .unwrap();
1289
1290        let freed = cm.evict_lru().unwrap();
1291        assert_eq!(freed, 0);
1292    }
1293
1294    #[test]
1295    fn invalidate_removes_entry() {
1296        let dir = tempfile::tempdir().unwrap();
1297        let file_path = dir.path().join("test.txt");
1298        std::fs::write(&file_path, b"some content").unwrap();
1299
1300        let store_path = dir.path().join("store.db");
1301        let store = SessionStore::open_or_create(&store_path).unwrap();
1302        let cm = CacheManager::new(store, u64::MAX);
1303        let pipeline = make_pipeline();
1304
1305        // Populate cache.
1306        let content = std::fs::read(&file_path).unwrap();
1307        cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
1308
1309        // Verify it's a hit.
1310        let hit = cm
1311            .get_or_compress(&file_path, &content, &pipeline)
1312            .unwrap();
1313        assert!(matches!(hit, CacheResult::Dedup { .. }));
1314
1315        cm.invalidate(&file_path).unwrap();
1316
1317        let miss = cm
1318            .get_or_compress(&file_path, &content, &pipeline)
1319            .unwrap();
1320        assert!(matches!(miss, CacheResult::Fresh { .. }));
1321    }
1322
1323    #[test]
1324    fn invalidate_nonexistent_path_is_noop() {
1325        let (store, _dir) = in_memory_store();
1326        let cm = CacheManager::new(store, u64::MAX);
1327        // Should not error.
1328        cm.invalidate(Path::new("/nonexistent/path/file.txt"))
1329            .unwrap();
1330    }
1331
1332    // ── Compaction / freshness tests ──────────────────────────────────────
1333    //
1334    // These tests used to exercise an in-memory turn counter. Freshness is
1335    // now computed from SQLite `accessed_at` timestamps so dedup works
1336    // across the shell-hook model (each hook invocation is a fresh
1337    // process). The tests below use wall-clock durations instead.
1338
1339    #[test]
1340    fn stale_ref_returns_fresh_instead_of_dedup() {
1341        let (store, _dir) = in_memory_store();
1342        // Set max_ref_age to 0 — every ref goes stale immediately.
1343        let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
1344        let pipeline = make_pipeline();
1345        let content = b"hello world";
1346        let path = Path::new("file.txt");
1347
1348        // First read — miss. accessed_at recorded.
1349        cm.get_or_compress(path, content, &pipeline).unwrap();
1350
1351        // Second read — with TTL=0 the ref is already stale, should re-send.
1352        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1353        assert!(
1354            matches!(result, CacheResult::Fresh { .. }),
1355            "stale ref (TTL=0) should return Fresh, not Dedup"
1356        );
1357    }
1358
1359    #[test]
1360    fn fresh_ref_returns_dedup() {
1361        let (store, _dir) = in_memory_store();
1362        // Generous TTL: one day. Refs stay fresh for the life of the test.
1363        let cm = CacheManager::with_ref_age_duration(
1364            store,
1365            u64::MAX,
1366            Duration::from_secs(86_400),
1367        );
1368        let pipeline = make_pipeline();
1369        let content = b"hello world";
1370        let path = Path::new("file.txt");
1371
1372        cm.get_or_compress(path, content, &pipeline).unwrap();
1373        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1374        assert!(
1375            matches!(result, CacheResult::Dedup { .. }),
1376            "fresh ref should dedup"
1377        );
1378    }
1379
1380    #[test]
1381    fn notify_compaction_invalidates_all_refs() {
1382        let (store, _dir) = in_memory_store();
1383        let cm = CacheManager::with_ref_age_duration(
1384            store,
1385            u64::MAX,
1386            Duration::from_secs(86_400),
1387        );
1388        let pipeline = make_pipeline();
1389        let path = Path::new("file.txt");
1390
1391        // Populate cache — every subsequent read is a dedup hit.
1392        cm.get_or_compress(path, b"content A", &pipeline).unwrap();
1393        cm.get_or_compress(path, b"content B", &pipeline).unwrap();
1394        assert!(matches!(
1395            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1396            CacheResult::Dedup { .. }
1397        ));
1398        assert!(matches!(
1399            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1400            CacheResult::Dedup { .. }
1401        ));
1402
1403        // Simulate a context compaction. The compaction marker is set to
1404        // `now`; any cache entry whose accessed_at predates this moment is
1405        // treated as stale even though the TTL hasn't expired.
1406        // Sleep 10ms to ensure `now` is strictly after the last touch.
1407        std::thread::sleep(std::time::Duration::from_millis(10));
1408        cm.notify_compaction();
1409
1410        // After compaction, refs predate the marker — re-send full content.
1411        assert!(matches!(
1412            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1413            CacheResult::Fresh { .. }
1414        ));
1415        assert!(matches!(
1416            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1417            CacheResult::Fresh { .. }
1418        ));
1419    }
1420
1421    #[test]
1422    fn ref_refreshed_after_resend() {
1423        let (store, _dir) = in_memory_store();
1424        // Generous TTL so the "fresh after re-send" check is never a timing
1425        // race on a slow CI runner; staleness is forced via compaction below.
1426        let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1427        let pipeline = make_pipeline();
1428        let content = b"hello world";
1429        let path = Path::new("file.txt");
1430
1431        cm.get_or_compress(path, content, &pipeline).unwrap();
1432        // Force staleness deterministically: set the compaction marker strictly
1433        // after the seed's accessed_at, so the ref predates it (no TTL race).
1434        std::thread::sleep(std::time::Duration::from_millis(10));
1435        cm.notify_compaction();
1436
1437        // Stale (predates the marker) — must re-send Fresh. The re-send bumps
1438        // accessed_at past the marker.
1439        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1440        assert!(matches!(result, CacheResult::Fresh { .. }));
1441
1442        // Now fresh (accessed_at after the marker, within the generous TTL).
1443        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1444        assert!(
1445            matches!(result, CacheResult::Dedup { .. }),
1446            "ref should be fresh after re-send"
1447        );
1448    }
1449
1450    #[test]
1451    fn check_dedup_returns_none_for_stale_ref() {
1452        let pipeline = make_pipeline();
1453        let content = b"test content";
1454        let path = Path::new("file.txt");
1455
1456        // Fresh ref (generous TTL): check_dedup returns Some.
1457        let (store, _dir) = in_memory_store();
1458        let fresh =
1459            CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1460        fresh.get_or_compress(path, content, &pipeline).unwrap();
1461        assert!(fresh.check_dedup(content).unwrap().is_some());
1462
1463        // Stale ref (TTL=0): check_dedup returns None — deterministic, no sleep.
1464        let (store2, _dir2) = in_memory_store();
1465        let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
1466        stale.get_or_compress(path, content, &pipeline).unwrap();
1467        assert!(
1468            stale.check_dedup(content).unwrap().is_none(),
1469            "stale ref should not be returned by check_dedup"
1470        );
1471    }
1472
1473    #[test]
1474    fn pinned_ref_stays_fresh_past_ttl() {
1475        let (store, _dir) = in_memory_store();
1476        let cm = CacheManager::with_ref_age_duration(
1477            store,
1478            u64::MAX,
1479            Duration::from_millis(10),
1480        );
1481        let pipeline = make_pipeline();
1482        let content = b"wiki: stable knowledge base content";
1483        let path = Path::new("wiki.md");
1484
1485        // Seed the cache, then pin.
1486        cm.get_or_compress(path, content, &pipeline).unwrap();
1487        let hash = CacheManager::sha256_hex(content);
1488        assert!(cm.store.pin_cache_entry(&hash).unwrap());
1489
1490        // Wait well past the 10ms TTL.
1491        std::thread::sleep(std::time::Duration::from_millis(40));
1492
1493        // Pinned: still dedup, even though accessed_at is way past TTL.
1494        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1495        assert!(
1496            matches!(result, CacheResult::Dedup { .. }),
1497            "pinned entry should stay fresh past TTL"
1498        );
1499        assert!(cm.check_dedup(content).unwrap().is_some());
1500    }
1501
1502    #[test]
1503    fn unpin_returns_to_ttl_behaviour() {
1504        let (store, _dir) = in_memory_store();
1505        let cm = CacheManager::with_ref_age_duration(
1506            store,
1507            u64::MAX,
1508            Duration::from_millis(10),
1509        );
1510        let pipeline = make_pipeline();
1511        let content = b"transient session content";
1512        let path = Path::new("file.txt");
1513
1514        cm.get_or_compress(path, content, &pipeline).unwrap();
1515        let hash = CacheManager::sha256_hex(content);
1516        cm.store.pin_cache_entry(&hash).unwrap();
1517
1518        // Pin keeps it fresh past TTL.
1519        std::thread::sleep(std::time::Duration::from_millis(25));
1520        assert!(matches!(
1521            cm.get_or_compress(path, content, &pipeline).unwrap(),
1522            CacheResult::Dedup { .. }
1523        ));
1524
1525        // Unpin and let TTL expire again.
1526        cm.store.unpin_cache_entry(&hash).unwrap();
1527        std::thread::sleep(std::time::Duration::from_millis(25));
1528        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1529        assert!(
1530            matches!(result, CacheResult::Fresh { .. }),
1531            "unpinned entry should obey TTL"
1532        );
1533    }
1534
1535    #[test]
1536    fn pinned_ref_survives_compaction_marker() {
1537        let (store, _dir) = in_memory_store();
1538        let cm = CacheManager::with_ref_age_duration(
1539            store,
1540            u64::MAX,
1541            Duration::from_secs(3600),
1542        );
1543        let pipeline = make_pipeline();
1544        let content = b"system prompt: shared across agents";
1545        let path = Path::new("system.md");
1546
1547        cm.get_or_compress(path, content, &pipeline).unwrap();
1548        let hash = CacheManager::sha256_hex(content);
1549        cm.store.pin_cache_entry(&hash).unwrap();
1550
1551        // Compaction would normally invalidate every pre-marker entry.
1552        std::thread::sleep(std::time::Duration::from_millis(10));
1553        cm.notify_compaction();
1554
1555        // Pinned entry survives — wiki content is persistent across
1556        // compactions because it's re-injected into every agent.
1557        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1558        assert!(
1559            matches!(result, CacheResult::Dedup { .. }),
1560            "pinned entry should survive compaction"
1561        );
1562    }
1563
1564    #[test]
1565    fn pin_visible_across_cachemanager_instances() {
1566        // Real-world scenario: agent A pins wiki content; agent B in a
1567        // separate process queries the same SQLite store and gets the
1568        // dedup hit because the pin lives in the shared store, not in
1569        // any single CacheManager's memory.
1570        let dir = tempfile::tempdir().unwrap();
1571        let path = dir.path().join("shared.db");
1572
1573        let store_a = SessionStore::open_or_create(&path).unwrap();
1574        let cm_a = CacheManager::with_ref_age_duration(
1575            store_a,
1576            u64::MAX,
1577            Duration::from_millis(5),
1578        );
1579        let pipeline = make_pipeline();
1580        let content = b"shared wiki content for multi-agent dedup";
1581
1582        cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1583            .unwrap();
1584        let hash = CacheManager::sha256_hex(content);
1585        cm_a.store.pin_cache_entry(&hash).unwrap();
1586
1587        // Drop agent A. Wait past TTL. Spin up agent B against the same DB.
1588        drop(cm_a);
1589        std::thread::sleep(std::time::Duration::from_millis(20));
1590
1591        let store_b = SessionStore::open_or_create(&path).unwrap();
1592        let cm_b = CacheManager::with_ref_age_duration(
1593            store_b,
1594            u64::MAX,
1595            Duration::from_millis(5),
1596        );
1597        let result = cm_b
1598            .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1599            .unwrap();
1600        assert!(
1601            matches!(result, CacheResult::Dedup { .. }),
1602            "second agent should see the pin from the shared store"
1603        );
1604    }
1605
1606    #[test]
1607    fn advance_turn_increments_counter() {
1608        // The counter is retained for context_evictor compatibility.
1609        let (store, _dir) = in_memory_store();
1610        let cm = CacheManager::new(store, u64::MAX);
1611        assert_eq!(cm.current_turn(), 0);
1612        cm.advance_turn();
1613        assert_eq!(cm.current_turn(), 1);
1614        cm.advance_turn();
1615        assert_eq!(cm.current_turn(), 2);
1616    }
1617
1618    // ── Expand feature tests ────────────────────────────────────────────
1619
1620    #[test]
1621    fn expand_returns_original_bytes_for_new_entry() {
1622        // New cache entries (written after the `original` column migration)
1623        // must always round-trip back to the exact bytes the agent sent.
1624        // This is the core guarantee `sqz expand` exists to provide.
1625        let (store, _dir) = in_memory_store();
1626        let cm = CacheManager::new(store, u64::MAX);
1627        let pipeline = make_pipeline();
1628        let path = Path::new("x.txt");
1629        let content = b"hello\nworld\nthis is the original\n";
1630
1631        // Seed the cache by running the content through get_or_compress.
1632        let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1633        let CacheResult::Fresh { .. } = first else {
1634            panic!("first read should be a Fresh miss, got something else");
1635        };
1636
1637        // Find the ref prefix by doing a second read (which dedups).
1638        let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1639        let inline_ref = match second {
1640            CacheResult::Dedup { inline_ref, .. } => inline_ref,
1641            _ => panic!("second read should dedup"),
1642        };
1643
1644        // Strip the `§ref:` / `§` wrappers to get the raw prefix.
1645        let prefix = inline_ref
1646            .strip_prefix("§ref:")
1647            .and_then(|s| s.strip_suffix('§'))
1648            .expect("unexpected ref format");
1649
1650        let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1651        match result {
1652            ExpandResult::Original { bytes, hash } => {
1653                assert_eq!(bytes, content, "expand must return exact original bytes");
1654                assert!(
1655                    hash.starts_with(prefix),
1656                    "returned full hash {hash} must start with prefix {prefix}"
1657                );
1658                assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1659            }
1660            ExpandResult::CompressedOnly { .. } => {
1661                panic!("new cache entries must carry the original bytes");
1662            }
1663        }
1664    }
1665
1666    #[test]
1667    fn expand_prefix_rejects_nonhex_input() {
1668        // Agents paste all sorts of things — ref tokens with the §'s still
1669        // attached, mixed-case hashes, stray whitespace. The store layer
1670        // normalises most of this but we also want to reject obvious
1671        // garbage without touching SQLite.
1672        let (store, _dir) = in_memory_store();
1673        let cm = CacheManager::new(store, u64::MAX);
1674        assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1675        assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1676        assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1677        assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1678    }
1679
1680    #[test]
1681    fn expand_prefix_returns_none_for_unknown_prefix() {
1682        // Very common in practice: agent pastes a ref from a different
1683        // machine, a different sqz install, or after a cache wipe.
1684        let (store, _dir) = in_memory_store();
1685        let cm = CacheManager::new(store, u64::MAX);
1686        let pipeline = make_pipeline();
1687        // Seed one entry.
1688        let _ = cm
1689            .get_or_compress(
1690                Path::new("y.txt"),
1691                b"some cached content that won't match the prefix below",
1692                &pipeline,
1693            )
1694            .unwrap();
1695        assert!(
1696            cm.expand_prefix("0000000000000000").unwrap().is_none(),
1697            "prefix that matches nothing must return None, not error"
1698        );
1699    }
1700
1701    #[test]
1702    fn expand_prefix_errors_on_ambiguous_match() {
1703        // Two entries whose hashes both start with the same hex prefix
1704        // must be detected and reported. The caller is expected to
1705        // surface "use a longer prefix" — we don't want to silently
1706        // pick one.
1707        let (store, _dir) = in_memory_store();
1708        // Hand-craft two entries with prefixes that collide on "ab".
1709        let compressed = crate::types::CompressedContent {
1710            data: "compressed-a".to_string(),
1711            tokens_compressed: 1,
1712            tokens_original: 10,
1713            stages_applied: vec![],
1714            compression_ratio: 0.1,
1715            provenance: Default::default(),
1716            verify: None,
1717        };
1718        store
1719            .save_cache_entry_with_original(
1720                &format!("ab{}", "0".repeat(62)),
1721                &compressed,
1722                Some(b"content a"),
1723            )
1724            .unwrap();
1725        store
1726            .save_cache_entry_with_original(
1727                &format!("ab{}", "1".repeat(62)),
1728                &compressed,
1729                Some(b"content b"),
1730            )
1731            .unwrap();
1732
1733        let cm = CacheManager::new(store, u64::MAX);
1734        // Unambiguous 3-char prefixes resolve:
1735        let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1736        assert!(matches!(ok_a, ExpandResult::Original { .. }));
1737        // Ambiguous 2-char prefix errors out.
1738        let err = cm.expand_prefix("ab").unwrap_err();
1739        let msg = err.to_string();
1740        assert!(
1741            msg.contains("multiple entries") || msg.contains("longer prefix"),
1742            "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1743        );
1744    }
1745
1746    #[test]
1747    fn expand_returns_compressed_only_for_pre_migration_entry() {
1748        // Entries written via `save_cache_entry` (without the `_with_original`
1749        // suffix) leave the `original` column NULL. Expand must still return
1750        // something useful — the compressed blob — with a note (handled by
1751        // the CLI). Here we assert the engine returns the right variant.
1752        let (store, _dir) = in_memory_store();
1753        let compressed = crate::types::CompressedContent {
1754            data: "the compressed version".to_string(),
1755            tokens_compressed: 4,
1756            tokens_original: 100,
1757            stages_applied: vec!["condense".to_string()],
1758            compression_ratio: 0.04,
1759            provenance: Default::default(),
1760            verify: None,
1761        };
1762        // Deliberately use the legacy save_cache_entry (no original).
1763        let hash = format!("deadbeef{}", "0".repeat(56));
1764        store.save_cache_entry(&hash, &compressed).unwrap();
1765
1766        let cm = CacheManager::new(store, u64::MAX);
1767        let result = cm
1768            .expand_prefix("deadbeef")
1769            .unwrap()
1770            .expect("deadbeef prefix should match");
1771        match result {
1772            ExpandResult::CompressedOnly { compressed, hash: h } => {
1773                assert_eq!(compressed, "the compressed version");
1774                assert_eq!(h, hash);
1775            }
1776            ExpandResult::Original { .. } => {
1777                panic!("legacy entry without original bytes should return CompressedOnly");
1778            }
1779        }
1780    }
1781
1782    #[test]
1783    fn expand_preserves_non_utf8_original_bytes() {
1784        // Shell output routinely contains non-UTF-8 sequences (terminal
1785        // control codes, binary blobs from `cat` on a wrong file, etc.).
1786        // Expand must round-trip byte-for-byte — not through a UTF-8
1787        // lossy conversion — or agents auditing the cache will see
1788        // spurious `\uFFFD` REPLACEMENT CHARACTER bytes.
1789        let (store, _dir) = in_memory_store();
1790        let cm = CacheManager::new(store, u64::MAX);
1791        let pipeline = make_pipeline();
1792
1793        // Mix of valid UTF-8 and isolated 0xFF bytes.
1794        let content: Vec<u8> = vec![
1795            b'h', b'e', b'l', b'l', b'o', b'\n',
1796            0xFF, 0xFE, 0xFD,
1797            b'\n',
1798        ];
1799        let path = Path::new("bin.dat");
1800        let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1801        // Round-trip via expand_prefix.
1802        let hash = CacheManager::sha256_hex(&content);
1803        let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1804        match result {
1805            ExpandResult::Original { bytes, .. } => {
1806                assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1807            }
1808            ExpandResult::CompressedOnly { .. } => {
1809                panic!("fresh entry should have original bytes");
1810            }
1811        }
1812    }
1813
1814    #[test]
1815    fn dedup_survives_cache_manager_restart() {
1816        // Regression for the April 18 bug: the turn counter was in-memory
1817        // only, so every new sqz process saw an empty ref tracker and the
1818        // dedup feature silently produced Fresh results forever. With
1819        // accessed_at-based freshness, a fresh CacheManager reading the
1820        // same SQLite store picks up the dedup correctly.
1821        let dir = tempfile::tempdir().unwrap();
1822        let db_path = dir.path().join("cache.db");
1823        let pipeline = make_pipeline();
1824        let content = b"a substantial chunk of content to dedup";
1825        let path = Path::new("x.txt");
1826
1827        // First "process": populate cache.
1828        {
1829            let store = SessionStore::open_or_create(&db_path).unwrap();
1830            let cm = CacheManager::with_ref_age_duration(
1831                store,
1832                u64::MAX,
1833                Duration::from_secs(3600),
1834            );
1835            let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1836            assert!(matches!(first, CacheResult::Fresh { .. }));
1837        }
1838
1839        // Second "process": new CacheManager, same DB. Dedup must fire.
1840        {
1841            let store = SessionStore::open_or_create(&db_path).unwrap();
1842            let cm = CacheManager::with_ref_age_duration(
1843                store,
1844                u64::MAX,
1845                Duration::from_secs(3600),
1846            );
1847            let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1848            assert!(
1849                matches!(second, CacheResult::Dedup { .. }),
1850                "second-process read must dedup — this was broken before the April 18 fix"
1851            );
1852        }
1853    }
1854
1855    #[test]
1856    fn compaction_from_one_process_invalidates_refs_in_another() {
1857        // Regression for the PreCompact hook wiring: the host harness
1858        // (e.g. Claude Code) runs `sqz hook precompact` in a short-lived
1859        // process to signal auto-compaction. The actual dedup serving runs
1860        // in a DIFFERENT sqz process (the shell hook). notify_compaction
1861        // must persist through SQLite so the second process sees it.
1862        //
1863        // Before the fix, compaction_marker was Cell<Option<DateTime>>
1864        // in memory only — the precompact process set it, exited, the
1865        // state was lost. Next shell-hook process started with a clean
1866        // marker, served stale refs to the agent, and the agent saw a
1867        // §ref:HASH§ pointing at content no longer in its context.
1868        let dir = tempfile::tempdir().unwrap();
1869        let db_path = dir.path().join("cache.db");
1870        let pipeline = make_pipeline();
1871        let content = b"content that needs stale-marking after compaction";
1872        let path = Path::new("file.txt");
1873        let ttl = Duration::from_secs(3600);
1874
1875        // Process A: populate the cache so the content is dedup-eligible.
1876        {
1877            let store = SessionStore::open_or_create(&db_path).unwrap();
1878            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1879            cm.get_or_compress(path, content, &pipeline).unwrap();
1880        }
1881        // Sleep so the compaction marker is strictly after the touch.
1882        std::thread::sleep(Duration::from_millis(10));
1883
1884        // Process B: simulates `sqz hook precompact`. Just calls
1885        // notify_compaction and exits. No reads.
1886        {
1887            let store = SessionStore::open_or_create(&db_path).unwrap();
1888            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1889            cm.notify_compaction();
1890        }
1891
1892        // Process C: simulates the next `sqz compress` shell-hook call.
1893        // Reads the same content. MUST re-send Fresh, not return a ref
1894        // the agent can no longer resolve.
1895        {
1896            let store = SessionStore::open_or_create(&db_path).unwrap();
1897            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1898            let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1899            assert!(
1900                matches!(result, CacheResult::Fresh { .. }),
1901                "post-compaction read from a fresh process must re-send Fresh; \
1902                 returning Dedup would be a dangling-ref bug"
1903            );
1904        }
1905    }
1906
1907    use proptest::prelude::*;
1908
1909    // ── Property 8: Cache deduplication ──────────────────────────────────────
1910    // **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1911    //
1912    // For any file content, reading the file twice through the CacheManager
1913    // (with no content change between reads) SHALL return a cache hit on the
1914    // second read with a reference token of approximately 13 tokens.
1915
1916    proptest! {
1917        /// **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1918        ///
1919        /// For any file content, the second read through CacheManager SHALL be
1920        /// a cache hit with tokens == 13.
1921        #[test]
1922        fn prop_cache_deduplication(
1923            content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1924        ) {
1925            let (store, _dir) = in_memory_store();
1926            let cm = CacheManager::new(store, u64::MAX);
1927            let pipeline = make_pipeline();
1928            let path = Path::new("file.txt");
1929
1930            // First read — must be a miss.
1931            let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1932            prop_assert!(
1933                matches!(first, CacheResult::Fresh { .. }),
1934                "first read should be a cache miss"
1935            );
1936
1937            let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1938            match second {
1939                CacheResult::Dedup { inline_ref, token_cost } => {
1940                    prop_assert_eq!(
1941                        token_cost, 13,
1942                        "cache hit should report ~13 reference tokens"
1943                    );
1944                    prop_assert!(
1945                        inline_ref.starts_with("§ref:"),
1946                        "reference token should start with §ref:"
1947                    );
1948                    prop_assert!(
1949                        inline_ref.ends_with('§'),
1950                        "reference token should end with §"
1951                    );
1952                }
1953                CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1954                    prop_assert!(false, "second read should be a cache hit, not a miss");
1955                }
1956            }
1957        }
1958    }
1959
1960    // ── Property 9: Cache invalidation on content change ─────────────────────
1961    // **Validates: Requirements 8.3, 18.3**
1962    //
1963    // For any cached file, if the file content changes (producing a different
1964    // SHA-256 hash), the CacheManager SHALL treat the next read as a cache miss
1965    // and re-compress the updated content.
1966
1967    proptest! {
1968        /// **Validates: Requirements 8.3, 18.3**
1969        ///
1970        /// For any two distinct byte sequences, the first read of each is a
1971        /// cache miss — content change always triggers re-compression.
1972        #[test]
1973        fn prop_cache_invalidation_on_content_change(
1974            content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1975            content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1976        ) {
1977            // Only meaningful when the two contents differ (different hashes).
1978            prop_assume!(content_a != content_b);
1979
1980            let (store, _dir) = in_memory_store();
1981            let cm = CacheManager::new(store, u64::MAX);
1982            let pipeline = make_pipeline();
1983            let path = Path::new("file.txt");
1984
1985            // Cache content_a.
1986            let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1987            prop_assert!(
1988                matches!(r1, CacheResult::Fresh { .. }),
1989                "first read of content_a should be a miss"
1990            );
1991
1992            let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1993            prop_assert!(
1994                matches!(r2, CacheResult::Dedup { .. }),
1995                "second read of content_a should be a hit"
1996            );
1997
1998            let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1999            prop_assert!(
2000                matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
2001                "read with changed content should be a cache miss or delta"
2002            );
2003        }
2004    }
2005
2006    // ── Property 10: Cache LRU eviction ──────────────────────────────────────
2007    // **Validates: Requirements 8.5**
2008    //
2009    // For any cache state where total size exceeds the configured maximum, the
2010    // CacheManager SHALL evict entries in LRU order until total size is at or
2011    // below the limit.
2012
2013    proptest! {
2014        /// **Validates: Requirements 8.5**
2015        ///
2016        /// After evict_lru, the total remaining cache size SHALL be at or below
2017        /// max_size_bytes.
2018        #[test]
2019        fn prop_cache_lru_eviction(
2020            // Generate 2-8 distinct content entries.
2021            entries in proptest::collection::vec(
2022                proptest::collection::vec(any::<u8>(), 10..=200usize),
2023                2..=8usize,
2024            ),
2025        ) {
2026            // Deduplicate entries so each has a unique hash.
2027            let mut unique_entries: Vec<Vec<u8>> = Vec::new();
2028            for e in &entries {
2029                if !unique_entries.contains(e) {
2030                    unique_entries.push(e.clone());
2031                }
2032            }
2033            prop_assume!(unique_entries.len() >= 2);
2034
2035            let (store, _dir) = in_memory_store();
2036            // Use a very small limit (1 byte) to guarantee eviction is needed.
2037            let cm = CacheManager::new(store, 1);
2038            let pipeline = make_pipeline();
2039            let path = Path::new("f.txt");
2040
2041            // Populate the cache.
2042            for entry in &unique_entries {
2043                cm.get_or_compress(path, entry, &pipeline).unwrap();
2044            }
2045
2046            // Evict LRU entries.
2047            let freed = cm.evict_lru().unwrap();
2048
2049            // Bytes freed must be > 0 since total > 1 byte.
2050            prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
2051
2052            // After eviction, total remaining size must be <= max_size_bytes (1).
2053            // We verify by checking that evict_lru now returns 0 (nothing left to evict).
2054            let freed_again = cm.evict_lru().unwrap();
2055            prop_assert_eq!(
2056                freed_again, 0,
2057                "second evict_lru call should free 0 bytes (already at or below limit)"
2058            );
2059        }
2060    }
2061
2062    // ── Property 34: Cache persistence across sessions ────────────────────────
2063    // **Validates: Requirements 18.4**
2064    //
2065    // For any set of cache entries saved to the SessionStore, reloading the
2066    // store (opening the same database file) SHALL produce the same cache
2067    // entries, and a subsequent read with the same content hash SHALL return a
2068    // cache hit.
2069
2070    proptest! {
2071        /// **Validates: Requirements 18.4**
2072        ///
2073        /// Cache entries written in one CacheManager instance SHALL survive
2074        /// a store close/reopen. With the wall-clock freshness model
2075        /// (introduced April 18 2026), a subsequent CacheManager reading
2076        /// the same database SHALL see the entry as fresh (within TTL) and
2077        /// return a Dedup hit on the very first read — this is the whole
2078        /// point of the cross-process fix. Previous behavior (Fresh on
2079        /// first read after restart) was a bug that silently disabled the
2080        /// dedup feature in production.
2081        #[test]
2082        fn prop_cache_persistence_across_sessions(
2083            content in proptest::collection::vec(any::<u8>(), 1..=500usize),
2084        ) {
2085            use crate::session_store::SessionStore;
2086
2087            let dir = tempfile::tempdir().unwrap();
2088            let db_path = dir.path().join("cache.db");
2089            let path = Path::new("file.txt");
2090
2091            // Session 1: populate the cache.
2092            {
2093                let store = SessionStore::open_or_create(&db_path).unwrap();
2094                // Explicit long TTL so tests don't race with wall-clock drift.
2095                let cm = CacheManager::with_ref_age_duration(
2096                    store,
2097                    u64::MAX,
2098                    Duration::from_secs(3600),
2099                );
2100                let pipeline = make_pipeline();
2101
2102                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2103                prop_assert!(
2104                    matches!(r, CacheResult::Fresh { .. }),
2105                    "first-ever read should be a miss"
2106                );
2107            }
2108
2109            // Session 2: reopen the same database file.
2110            {
2111                let store = SessionStore::open_or_create(&db_path).unwrap();
2112                let cm = CacheManager::with_ref_age_duration(
2113                    store,
2114                    u64::MAX,
2115                    Duration::from_secs(3600),
2116                );
2117                let pipeline = make_pipeline();
2118
2119                // First read in the new session MUST dedup. The entry was
2120                // just written (within TTL), so the wall-clock freshness
2121                // check finds it fresh. This is what makes sqz's dedup
2122                // actually work across shell-hook invocations.
2123                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2124                match r {
2125                    CacheResult::Dedup { token_cost, .. } => {
2126                        prop_assert_eq!(
2127                            token_cost, 13,
2128                            "first read after restart must be a 13-token dedup ref"
2129                        );
2130                    }
2131                    CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
2132                        prop_assert!(
2133                            false,
2134                            "first read after restart must dedup — this was the \
2135                             April 18 bug and its fix is the whole reason this \
2136                             test exists"
2137                        );
2138                    }
2139                }
2140            }
2141        }
2142    }
2143}