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 [`with_ref_age`] to tune.
139pub struct CacheManager {
140    store: SessionStore,
141    max_size_bytes: u64,
142    delta_encoder: DeltaEncoder,
143    /// Retained for notify_compaction's semantic ("forget all tracked refs"),
144    /// but no longer consulted for freshness checks.
145    #[allow(dead_code)]
146    turn_counter: std::cell::Cell<u64>,
147    /// Retained for notify_compaction; cleared on compaction events.
148    #[allow(dead_code)]
149    ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
150    /// Maximum wall-clock age before a dedup ref is considered stale.
151    /// After this duration we assume the LLM's context window has rolled
152    /// over enough to have dropped the original content, so we re-send the
153    /// full version instead of a dangling ref.
154    max_ref_age: Duration,
155    /// Records the instant at which the in-memory compaction flag was set.
156    /// Any cache entry whose `accessed_at` predates this instant is stale.
157    /// Reset by [`notify_compaction`].
158    compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
159    /// Session-lifetime MinHash LSH index over stored entries, used to find
160    /// near-duplicate candidates for delta encoding beyond the recent-LRU
161    /// window. In-memory only; rebuilt organically as entries are stored.
162    lsh: std::cell::RefCell<crate::minhash_lsh::MinHashLsh>,
163    /// doc_id (index) → content hash for entries in `lsh`.
164    lsh_hashes: std::cell::RefCell<Vec<String>>,
165}
166
167impl CacheManager {
168    /// Create a new cache manager backed by the given session store.
169    ///
170    /// `max_size_bytes` controls when LRU eviction kicks in. A good default
171    /// is 512 MB (`512 * 1024 * 1024`). Dedup refs go stale after 30 minutes
172    /// of wall-clock time by default — use [`with_ref_age`] to tune.
173    pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
174        Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
175    }
176
177    /// Create a CacheManager with a custom ref staleness threshold measured
178    /// in turns. The turn count is converted to wall-clock time by assuming
179    /// ~1 minute per turn (a rough approximation; the real freshness check
180    /// uses SQLite timestamps). This constructor exists for backward
181    /// compatibility with tests that previously advanced a turn counter.
182    #[doc(hidden)]
183    pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
184        Self::with_ref_age_duration(
185            store,
186            max_size_bytes,
187            Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
188        )
189    }
190
191    /// Create a CacheManager with an explicit wall-clock ref-age cap.
192    pub fn with_ref_age_duration(
193        store: SessionStore,
194        max_size_bytes: u64,
195        max_ref_age: Duration,
196    ) -> Self {
197        Self {
198            store,
199            max_size_bytes,
200            delta_encoder: DeltaEncoder::new(),
201            turn_counter: std::cell::Cell::new(0),
202            ref_tracker: std::cell::RefCell::new(HashMap::new()),
203            max_ref_age,
204            compaction_marker: std::cell::Cell::new(None),
205            lsh: std::cell::RefCell::new(crate::minhash_lsh::MinHashLsh::new()),
206            lsh_hashes: std::cell::RefCell::new(Vec::new()),
207        }
208    }
209
210    /// Register a stored entry in the similarity index. Capped so a very
211    /// long session can't grow the index unboundedly; beyond the cap the
212    /// recent-LRU fallback in [`Self::try_delta_encode`] still applies.
213    fn index_for_similarity(&self, hash: &str, data: &str) {
214        const MAX_INDEXED: usize = 512;
215        let mut hashes = self.lsh_hashes.borrow_mut();
216        if hashes.len() >= MAX_INDEXED || hashes.iter().any(|h| h == hash) {
217            return;
218        }
219        let doc_id = hashes.len() as u64;
220        self.lsh.borrow_mut().insert(doc_id, data);
221        hashes.push(hash.to_string());
222    }
223
224    /// Compute the SHA-256 hex digest of `bytes`.
225    pub fn sha256_hex(bytes: &[u8]) -> String {
226        let mut hasher = Sha256::new();
227        hasher.update(bytes);
228        format!("{:x}", hasher.finalize())
229    }
230
231    /// Advance the turn counter. Retained for API compatibility; not used
232    /// for freshness. The context_evictor still reads `current_turn` for
233    /// LRU scoring during `sqz compact`.
234    pub fn advance_turn(&self) {
235        self.turn_counter.set(self.turn_counter.get() + 1);
236    }
237
238    /// Get the current turn number. Used by the context_evictor for scoring.
239    pub fn current_turn(&self) -> u64 {
240        self.turn_counter.get()
241    }
242
243    /// Notify the cache that a context compaction has occurred.
244    ///
245    /// Persists a compaction timestamp into the session store so any cache
246    /// entry whose `accessed_at` predates the marker is considered stale
247    /// by **every subsequent sqz process**, not just this one. The shell-
248    /// hook invocation model means this method is typically called from a
249    /// short-lived `sqz hook precompact` process, and the check runs in a
250    /// different `sqz compress` process milliseconds later.
251    ///
252    /// Call this when:
253    /// - The harness signals a compaction event (PreCompact hook)
254    /// - A session is resumed after being idle
255    /// - The user runs `sqz compact`
256    pub fn notify_compaction(&self) {
257        let now = chrono::Utc::now();
258        self.compaction_marker.set(Some(now));
259        self.ref_tracker.borrow_mut().clear();
260        // Persist the marker so other sqz processes see the invalidation.
261        // Silently swallow a write error: losing the marker means some
262        // refs may survive the compaction and show as dedup hits in the
263        // next few calls — annoying, not wrong (the agent still receives
264        // valid content; it just sees a short-ref it has to resolve).
265        let _ = self
266            .store
267            .set_metadata("last_compaction_at", &now.to_rfc3339());
268    }
269
270    /// Check if a dedup ref for the given hash is still fresh (likely still
271    /// in the LLM's context window).
272    ///
273    /// Uses the SQLite `accessed_at` timestamp rather than the in-memory
274    /// turn counter. This works across sqz process invocations: shell hooks
275    /// spawn a new sqz process per intercepted command, so any in-memory
276    /// counter would reset every time. The database survives.
277    ///
278    /// The compaction marker is read from SQLite on every check so that
279    /// a `sqz hook precompact` call from another process immediately
280    /// invalidates refs in the current process. Without the persistent
281    /// read, the invalidation would only affect the process that called
282    /// notify_compaction — which is never the same process that serves
283    /// dedup hits.
284    fn is_ref_fresh(&self, hash: &str) -> bool {
285        // Pinned entries bypass TTL and compaction-marker checks. They
286        // represent stable knowledge-base content that's persistently
287        // re-injected into every agent, so the original is never gone.
288        if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
289            return true;
290        }
291        let accessed = match self.store.get_cache_entry_accessed_at(hash) {
292            Ok(Some(ts)) => ts,
293            _ => return false,
294        };
295        // In-memory compaction marker (set in this process).
296        if let Some(marker) = self.compaction_marker.get() {
297            if accessed < marker {
298                return false;
299            }
300        }
301        // Persistent compaction marker — set by `sqz hook precompact` in
302        // a different process. Without this read the in-memory marker is
303        // never consulted because each hook invocation is a fresh process.
304        if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
305            if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
306                if accessed < marker {
307                    return false;
308                }
309            }
310        }
311        let age = (chrono::Utc::now() - accessed)
312            .to_std()
313            .unwrap_or(Duration::from_secs(0));
314        age < self.max_ref_age
315    }
316
317    /// Record that a dedup ref was sent for the given hash. Updates the
318    /// persistent `accessed_at` timestamp so subsequent freshness checks
319    /// see this send. Silently swallows SQLite errors — losing a touch
320    /// means the next call may treat the ref as stale and re-send, which
321    /// is strictly worse on tokens but never wrong.
322    fn record_ref_sent(&self, hash: &str) {
323        let _ = self.store.touch_cache_entry(hash);
324    }
325
326    /// Look up `content` in the cache with compaction awareness.
327    ///
328    /// - On exact dedup with fresh ref: return `CacheResult::Dedup` (~13 tokens).
329    /// - On exact dedup with stale ref: re-compress and return `CacheResult::Fresh`
330    ///   (the original content may have been compacted out of the LLM's context).
331    /// - On near-duplicate: return `CacheResult::Delta` with a compact diff.
332    /// - On cache miss: compress via `pipeline`, persist, return `CacheResult::Fresh`.
333    pub fn get_or_compress(
334        &self,
335        path: &Path,
336        content: &[u8],
337        pipeline: &CompressionPipeline,
338    ) -> Result<CacheResult> {
339        self.get_or_compress_inner(path, content, pipeline, false)
340    }
341
342    /// Lossless variant used by the file-read MCP tools (`sqz_read_file` /
343    /// `sqz_grep` / `sqz_list_dir`): on a cache miss the content is returned
344    /// faithfully via [`CompressionPipeline::compress_lossless`] (no entropy
345    /// truncation / pruning / re-encoding). The general `compress` tool keeps
346    /// using [`get_or_compress`] for aggressive compression. See issue #32.
347    pub fn get_or_compress_lossless(
348        &self,
349        path: &Path,
350        content: &[u8],
351        pipeline: &CompressionPipeline,
352    ) -> Result<CacheResult> {
353        self.get_or_compress_inner(path, content, pipeline, true)
354    }
355
356    fn get_or_compress_inner(
357        &self,
358        _path: &Path,
359        content: &[u8],
360        pipeline: &CompressionPipeline,
361        lossless: bool,
362    ) -> Result<CacheResult> {
363        let hash = Self::sha256_hex(content);
364
365        // Exact match — check if the ref is still fresh
366        // Exact match — probe without touching accessed_at, then check
367        // freshness. Touching on the probe would make every ref appear
368        // fresh immediately (the timestamp we just wrote is `now`).
369        let exists = self.store.cache_entry_exists(&hash)?;
370        if exists {
371            if self.is_ref_fresh(&hash) {
372                // Ref is fresh — the LLM likely still has the original in context
373                let hash_prefix = &hash[..16];
374                let inline_ref = format!("§ref:{hash_prefix}§");
375                // Update the sent timestamp
376                self.record_ref_sent(&hash);
377                let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
378                return Ok(CacheResult::Dedup {
379                    inline_ref,
380                    token_cost: 13,
381                });
382            } else {
383                // Ref is stale — re-send the full compressed content.
384                // The original may have been compacted out of the LLM's context.
385                let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
386                let text = String::from_utf8_lossy(content).into_owned();
387                let ctx = SessionContext {
388                    session_id: "cache".to_string(),
389                };
390                let preset = Preset::default();
391                let compressed = if lossless {
392                    pipeline.compress_lossless(&text, &ctx, &preset)?
393                } else {
394                    pipeline.compress(&text, &ctx, &preset)?
395                };
396                // Record that we re-sent this content
397                self.record_ref_sent(&hash);
398                return Ok(CacheResult::Fresh { output: compressed });
399            }
400        }
401
402        // Ranged re-read of a file the model already has in full.
403        if let Some(hit) = self.check_slice_with_meta(content)? {
404            return Ok(CacheResult::Dedup {
405                inline_ref: hit.inline_ref,
406                token_cost: hit.token_cost,
407            });
408        }
409
410        // Near-duplicate check: compare against recent cache entries
411        let text = String::from_utf8_lossy(content).into_owned();
412        let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
413        if let Some(delta_result) = self.try_delta_encode(&text)? {
414            // Store the new content in cache for future exact matches
415            let ctx = SessionContext {
416                session_id: "cache".to_string(),
417            };
418            let preset = Preset::default();
419            let compressed = if lossless {
420                pipeline.compress_lossless(&text, &ctx, &preset)?
421            } else {
422                pipeline.compress(&text, &ctx, &preset)?
423            };
424            // Persist the raw bytes so `sqz expand <prefix>` can round-trip.
425            self.store
426                .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
427            self.index_for_similarity(&hash, &compressed.data);
428            self.record_ref_sent(&hash);
429
430            let token_cost = (delta_result.delta_text.len() / 4) as u32;
431            return Ok(CacheResult::Delta {
432                delta_text: delta_result.delta_text,
433                token_cost: token_cost.max(5),
434                similarity: delta_result.similarity,
435            });
436        }
437
438        let ctx = SessionContext {
439            session_id: "cache".to_string(),
440        };
441        let preset = Preset::default();
442        let compressed = if lossless {
443            pipeline.compress_lossless(&text, &ctx, &preset)?
444        } else {
445            pipeline.compress(&text, &ctx, &preset)?
446        };
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`], this method does not touch `accessed_at`
529    /// until after the freshness check — otherwise every read would make
530    /// 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`] plus the metadata callers need for regret tracking:
536    /// the full content hash and the entry's `accessed_at` from *before*
537    /// this lookup bumped it. A hit whose previous access was seconds ago
538    /// 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                    summary_template: "... and {remaining} more items".into(),
1076                }),
1077                custom_transforms: Some(CustomTransformsConfig { enabled: true }),
1078            },
1079            tool_selection: ToolSelectionConfig {
1080                max_tools: 5,
1081                similarity_threshold: 0.7,
1082                default_tools: vec![],
1083            },
1084            budget: BudgetConfig {
1085                warning_threshold: 0.70,
1086                ceiling_threshold: 0.85,
1087                default_window_size: 200_000,
1088                agents: Default::default(),
1089            },
1090            terse_mode: TerseModeConfig {
1091                enabled: false,
1092                level: crate::preset::TerseLevel::Moderate,
1093            },
1094            model: ModelConfig {
1095                family: "anthropic".into(),
1096                primary: "claude-sonnet-4-20250514".into(),
1097                local: String::new(),
1098                complexity_threshold: 0.4,
1099                pricing: None,
1100            },
1101        }
1102    }
1103
1104    fn make_pipeline() -> CompressionPipeline {
1105        CompressionPipeline::new(&test_preset())
1106    }
1107
1108    #[test]
1109    fn first_read_is_miss() {
1110        let (store, _dir) = in_memory_store();
1111        let cm = CacheManager::new(store, u64::MAX);
1112        let pipeline = make_pipeline();
1113        let content = b"hello world";
1114        let result = cm
1115            .get_or_compress(Path::new("file.txt"), content, &pipeline)
1116            .unwrap();
1117        assert!(matches!(result, CacheResult::Fresh { .. }));
1118    }
1119
1120    /// Regression for https://github.com/ojuschugh1/sqz/issues/32 — the MCP
1121    /// file-read path (`get_or_compress_lossless`, used by sqz_read_file /
1122    /// sqz_grep / sqz_list_dir) returns content faithfully, while the general
1123    /// `compress` tool keeps using the lossy `get_or_compress`.
1124    #[test]
1125    fn get_or_compress_lossless_does_not_truncate_source() {
1126        let pipeline = make_pipeline();
1127
1128        // >500 bytes of multi-segment non-JSON "source" that the lossy path
1129        // entropy-truncates by roughly half.
1130        let mut segs = Vec::new();
1131        // 24 segments: clears the benign-aware 1000-byte threshold.
1132        for i in 0..24 {
1133            if i % 2 == 0 {
1134                segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
1135            } else {
1136                segs.push(format!(
1137                    "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
1138                ));
1139            }
1140        }
1141        let content = segs.join("\n\n");
1142        assert!(content.len() > 1000);
1143
1144        // Lossy path (the general `compress` tool) still truncates.
1145        let (s1, _d1) = in_memory_store();
1146        let lossy = CacheManager::new(s1, u64::MAX);
1147        match lossy
1148            .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
1149            .unwrap()
1150        {
1151            CacheResult::Fresh { output } => {
1152                assert!(output.data.contains("omitted"), "lossy path should truncate");
1153            }
1154            _ => panic!("expected Fresh on a cold read"),
1155        }
1156
1157        // Lossless path (file tools) keeps every segment.
1158        let (s2, _d2) = in_memory_store();
1159        let cm = CacheManager::new(s2, u64::MAX);
1160        match cm
1161            .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
1162            .unwrap()
1163        {
1164            CacheResult::Fresh { output } => {
1165                assert!(
1166                    !output.data.contains("omitted"),
1167                    "file-read path truncated content: {}",
1168                    output.data
1169                );
1170                for i in 0..10 {
1171                    assert!(
1172                        output.data.contains(&format!("SEG{i}")),
1173                        "file-read path dropped SEG{i}"
1174                    );
1175                }
1176            }
1177            _ => panic!("expected CacheResult::Fresh on a cold read"),
1178        }
1179    }
1180
1181    /// MinHash LSH finds a near-duplicate even after 12 unrelated entries
1182    /// have pushed the original out of the recent-10 LRU fallback window.
1183    #[test]
1184    fn lsh_finds_near_duplicate_beyond_lru_window() {
1185        let (store, _dir) = in_memory_store();
1186        let cm = CacheManager::new(store, u64::MAX);
1187        let pipeline = make_pipeline();
1188
1189        let base: String = (0..40)
1190            .map(|i| format!("stable log line number {i} with steady content here\n"))
1191            .collect();
1192        cm.get_or_compress(Path::new("base"), base.as_bytes(), &pipeline)
1193            .unwrap();
1194
1195        // Bury it under 12 distinct entries.
1196        for n in 0..12 {
1197            let filler: String = (0..30)
1198                .map(|i| format!("filler doc {n} row {i} totally different subject matter\n"))
1199                .collect();
1200            cm.get_or_compress(Path::new("filler"), filler.as_bytes(), &pipeline)
1201                .unwrap();
1202        }
1203
1204        // One line changed: a near-duplicate of the buried base.
1205        let variant = base.replace(
1206            "stable log line number 7 with steady content here",
1207            "stable log line number 7 with slightly altered content",
1208        );
1209        let result = cm
1210            .get_or_compress(Path::new("variant"), variant.as_bytes(), &pipeline)
1211            .unwrap();
1212        let kind = match &result {
1213            CacheResult::Dedup { .. } => "Dedup",
1214            CacheResult::Delta { .. } => "Delta",
1215            CacheResult::Fresh { .. } => "Fresh",
1216        };
1217        assert!(
1218            matches!(result, CacheResult::Delta { .. }),
1219            "near-duplicate beyond the LRU window must resolve via LSH, got {kind}"
1220        );
1221    }
1222
1223    #[test]
1224    fn second_read_is_hit() {
1225        let (store, _dir) = in_memory_store();
1226        let cm = CacheManager::new(store, u64::MAX);
1227        let pipeline = make_pipeline();
1228        let content = b"hello world";
1229        let path = Path::new("file.txt");
1230
1231        // First read — miss
1232        cm.get_or_compress(path, content, &pipeline).unwrap();
1233
1234        // Second read — hit
1235        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1236        match result {
1237            CacheResult::Dedup {
1238                inline_ref,
1239                token_cost,
1240            } => {
1241                assert!(inline_ref.starts_with("§ref:"));
1242                assert!(inline_ref.ends_with('§'));
1243                assert_eq!(token_cost, 13);
1244            }
1245            CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
1246        }
1247    }
1248
1249    #[test]
1250    fn different_content_is_miss() {
1251        let (store, _dir) = in_memory_store();
1252        let cm = CacheManager::new(store, u64::MAX);
1253        let pipeline = make_pipeline();
1254        let path = Path::new("file.txt");
1255
1256        cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
1257        let result = cm
1258            .get_or_compress(path, b"content v2", &pipeline)
1259            .unwrap();
1260        assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
1261    }
1262
1263    #[test]
1264    fn evict_lru_frees_bytes_when_over_limit() {
1265        let (store, _dir) = in_memory_store();
1266        // Very small limit so eviction triggers immediately.
1267        let cm = CacheManager::new(store, 1);
1268        let pipeline = make_pipeline();
1269        let path = Path::new("f.txt");
1270
1271        // Populate cache with a few entries.
1272        cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
1273        cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
1274        cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
1275
1276        let freed = cm.evict_lru().unwrap();
1277        assert!(freed > 0, "expected bytes to be freed");
1278    }
1279
1280    #[test]
1281    fn evict_lru_no_op_when_under_limit() {
1282        let (store, _dir) = in_memory_store();
1283        let cm = CacheManager::new(store, u64::MAX);
1284        let pipeline = make_pipeline();
1285
1286        cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
1287            .unwrap();
1288
1289        let freed = cm.evict_lru().unwrap();
1290        assert_eq!(freed, 0);
1291    }
1292
1293    #[test]
1294    fn invalidate_removes_entry() {
1295        let dir = tempfile::tempdir().unwrap();
1296        let file_path = dir.path().join("test.txt");
1297        std::fs::write(&file_path, b"some content").unwrap();
1298
1299        let store_path = dir.path().join("store.db");
1300        let store = SessionStore::open_or_create(&store_path).unwrap();
1301        let cm = CacheManager::new(store, u64::MAX);
1302        let pipeline = make_pipeline();
1303
1304        // Populate cache.
1305        let content = std::fs::read(&file_path).unwrap();
1306        cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
1307
1308        // Verify it's a hit.
1309        let hit = cm
1310            .get_or_compress(&file_path, &content, &pipeline)
1311            .unwrap();
1312        assert!(matches!(hit, CacheResult::Dedup { .. }));
1313
1314        cm.invalidate(&file_path).unwrap();
1315
1316        let miss = cm
1317            .get_or_compress(&file_path, &content, &pipeline)
1318            .unwrap();
1319        assert!(matches!(miss, CacheResult::Fresh { .. }));
1320    }
1321
1322    #[test]
1323    fn invalidate_nonexistent_path_is_noop() {
1324        let (store, _dir) = in_memory_store();
1325        let cm = CacheManager::new(store, u64::MAX);
1326        // Should not error.
1327        cm.invalidate(Path::new("/nonexistent/path/file.txt"))
1328            .unwrap();
1329    }
1330
1331    // ── Compaction / freshness tests ──────────────────────────────────────
1332    //
1333    // These tests used to exercise an in-memory turn counter. Freshness is
1334    // now computed from SQLite `accessed_at` timestamps so dedup works
1335    // across the shell-hook model (each hook invocation is a fresh
1336    // process). The tests below use wall-clock durations instead.
1337
1338    #[test]
1339    fn stale_ref_returns_fresh_instead_of_dedup() {
1340        let (store, _dir) = in_memory_store();
1341        // Set max_ref_age to 0 — every ref goes stale immediately.
1342        let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
1343        let pipeline = make_pipeline();
1344        let content = b"hello world";
1345        let path = Path::new("file.txt");
1346
1347        // First read — miss. accessed_at recorded.
1348        cm.get_or_compress(path, content, &pipeline).unwrap();
1349
1350        // Second read — with TTL=0 the ref is already stale, should re-send.
1351        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1352        assert!(
1353            matches!(result, CacheResult::Fresh { .. }),
1354            "stale ref (TTL=0) should return Fresh, not Dedup"
1355        );
1356    }
1357
1358    #[test]
1359    fn fresh_ref_returns_dedup() {
1360        let (store, _dir) = in_memory_store();
1361        // Generous TTL: one day. Refs stay fresh for the life of the test.
1362        let cm = CacheManager::with_ref_age_duration(
1363            store,
1364            u64::MAX,
1365            Duration::from_secs(86_400),
1366        );
1367        let pipeline = make_pipeline();
1368        let content = b"hello world";
1369        let path = Path::new("file.txt");
1370
1371        cm.get_or_compress(path, content, &pipeline).unwrap();
1372        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1373        assert!(
1374            matches!(result, CacheResult::Dedup { .. }),
1375            "fresh ref should dedup"
1376        );
1377    }
1378
1379    #[test]
1380    fn notify_compaction_invalidates_all_refs() {
1381        let (store, _dir) = in_memory_store();
1382        let cm = CacheManager::with_ref_age_duration(
1383            store,
1384            u64::MAX,
1385            Duration::from_secs(86_400),
1386        );
1387        let pipeline = make_pipeline();
1388        let path = Path::new("file.txt");
1389
1390        // Populate cache — every subsequent read is a dedup hit.
1391        cm.get_or_compress(path, b"content A", &pipeline).unwrap();
1392        cm.get_or_compress(path, b"content B", &pipeline).unwrap();
1393        assert!(matches!(
1394            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1395            CacheResult::Dedup { .. }
1396        ));
1397        assert!(matches!(
1398            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1399            CacheResult::Dedup { .. }
1400        ));
1401
1402        // Simulate a context compaction. The compaction marker is set to
1403        // `now`; any cache entry whose accessed_at predates this moment is
1404        // treated as stale even though the TTL hasn't expired.
1405        // Sleep 10ms to ensure `now` is strictly after the last touch.
1406        std::thread::sleep(std::time::Duration::from_millis(10));
1407        cm.notify_compaction();
1408
1409        // After compaction, refs predate the marker — re-send full content.
1410        assert!(matches!(
1411            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1412            CacheResult::Fresh { .. }
1413        ));
1414        assert!(matches!(
1415            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1416            CacheResult::Fresh { .. }
1417        ));
1418    }
1419
1420    #[test]
1421    fn ref_refreshed_after_resend() {
1422        let (store, _dir) = in_memory_store();
1423        // Generous TTL so the "fresh after re-send" check is never a timing
1424        // race on a slow CI runner; staleness is forced via compaction below.
1425        let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1426        let pipeline = make_pipeline();
1427        let content = b"hello world";
1428        let path = Path::new("file.txt");
1429
1430        cm.get_or_compress(path, content, &pipeline).unwrap();
1431        // Force staleness deterministically: set the compaction marker strictly
1432        // after the seed's accessed_at, so the ref predates it (no TTL race).
1433        std::thread::sleep(std::time::Duration::from_millis(10));
1434        cm.notify_compaction();
1435
1436        // Stale (predates the marker) — must re-send Fresh. The re-send bumps
1437        // accessed_at past the marker.
1438        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1439        assert!(matches!(result, CacheResult::Fresh { .. }));
1440
1441        // Now fresh (accessed_at after the marker, within the generous TTL).
1442        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1443        assert!(
1444            matches!(result, CacheResult::Dedup { .. }),
1445            "ref should be fresh after re-send"
1446        );
1447    }
1448
1449    #[test]
1450    fn check_dedup_returns_none_for_stale_ref() {
1451        let pipeline = make_pipeline();
1452        let content = b"test content";
1453        let path = Path::new("file.txt");
1454
1455        // Fresh ref (generous TTL): check_dedup returns Some.
1456        let (store, _dir) = in_memory_store();
1457        let fresh =
1458            CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1459        fresh.get_or_compress(path, content, &pipeline).unwrap();
1460        assert!(fresh.check_dedup(content).unwrap().is_some());
1461
1462        // Stale ref (TTL=0): check_dedup returns None — deterministic, no sleep.
1463        let (store2, _dir2) = in_memory_store();
1464        let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
1465        stale.get_or_compress(path, content, &pipeline).unwrap();
1466        assert!(
1467            stale.check_dedup(content).unwrap().is_none(),
1468            "stale ref should not be returned by check_dedup"
1469        );
1470    }
1471
1472    #[test]
1473    fn pinned_ref_stays_fresh_past_ttl() {
1474        let (store, _dir) = in_memory_store();
1475        let cm = CacheManager::with_ref_age_duration(
1476            store,
1477            u64::MAX,
1478            Duration::from_millis(10),
1479        );
1480        let pipeline = make_pipeline();
1481        let content = b"wiki: stable knowledge base content";
1482        let path = Path::new("wiki.md");
1483
1484        // Seed the cache, then pin.
1485        cm.get_or_compress(path, content, &pipeline).unwrap();
1486        let hash = CacheManager::sha256_hex(content);
1487        assert!(cm.store.pin_cache_entry(&hash).unwrap());
1488
1489        // Wait well past the 10ms TTL.
1490        std::thread::sleep(std::time::Duration::from_millis(40));
1491
1492        // Pinned: still dedup, even though accessed_at is way past TTL.
1493        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1494        assert!(
1495            matches!(result, CacheResult::Dedup { .. }),
1496            "pinned entry should stay fresh past TTL"
1497        );
1498        assert!(cm.check_dedup(content).unwrap().is_some());
1499    }
1500
1501    #[test]
1502    fn unpin_returns_to_ttl_behaviour() {
1503        let (store, _dir) = in_memory_store();
1504        let cm = CacheManager::with_ref_age_duration(
1505            store,
1506            u64::MAX,
1507            Duration::from_millis(10),
1508        );
1509        let pipeline = make_pipeline();
1510        let content = b"transient session content";
1511        let path = Path::new("file.txt");
1512
1513        cm.get_or_compress(path, content, &pipeline).unwrap();
1514        let hash = CacheManager::sha256_hex(content);
1515        cm.store.pin_cache_entry(&hash).unwrap();
1516
1517        // Pin keeps it fresh past TTL.
1518        std::thread::sleep(std::time::Duration::from_millis(25));
1519        assert!(matches!(
1520            cm.get_or_compress(path, content, &pipeline).unwrap(),
1521            CacheResult::Dedup { .. }
1522        ));
1523
1524        // Unpin and let TTL expire again.
1525        cm.store.unpin_cache_entry(&hash).unwrap();
1526        std::thread::sleep(std::time::Duration::from_millis(25));
1527        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1528        assert!(
1529            matches!(result, CacheResult::Fresh { .. }),
1530            "unpinned entry should obey TTL"
1531        );
1532    }
1533
1534    #[test]
1535    fn pinned_ref_survives_compaction_marker() {
1536        let (store, _dir) = in_memory_store();
1537        let cm = CacheManager::with_ref_age_duration(
1538            store,
1539            u64::MAX,
1540            Duration::from_secs(3600),
1541        );
1542        let pipeline = make_pipeline();
1543        let content = b"system prompt: shared across agents";
1544        let path = Path::new("system.md");
1545
1546        cm.get_or_compress(path, content, &pipeline).unwrap();
1547        let hash = CacheManager::sha256_hex(content);
1548        cm.store.pin_cache_entry(&hash).unwrap();
1549
1550        // Compaction would normally invalidate every pre-marker entry.
1551        std::thread::sleep(std::time::Duration::from_millis(10));
1552        cm.notify_compaction();
1553
1554        // Pinned entry survives — wiki content is persistent across
1555        // compactions because it's re-injected into every agent.
1556        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1557        assert!(
1558            matches!(result, CacheResult::Dedup { .. }),
1559            "pinned entry should survive compaction"
1560        );
1561    }
1562
1563    #[test]
1564    fn pin_visible_across_cachemanager_instances() {
1565        // Real-world scenario: agent A pins wiki content; agent B in a
1566        // separate process queries the same SQLite store and gets the
1567        // dedup hit because the pin lives in the shared store, not in
1568        // any single CacheManager's memory.
1569        let dir = tempfile::tempdir().unwrap();
1570        let path = dir.path().join("shared.db");
1571
1572        let store_a = SessionStore::open_or_create(&path).unwrap();
1573        let cm_a = CacheManager::with_ref_age_duration(
1574            store_a,
1575            u64::MAX,
1576            Duration::from_millis(5),
1577        );
1578        let pipeline = make_pipeline();
1579        let content = b"shared wiki content for multi-agent dedup";
1580
1581        cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1582            .unwrap();
1583        let hash = CacheManager::sha256_hex(content);
1584        cm_a.store.pin_cache_entry(&hash).unwrap();
1585
1586        // Drop agent A. Wait past TTL. Spin up agent B against the same DB.
1587        drop(cm_a);
1588        std::thread::sleep(std::time::Duration::from_millis(20));
1589
1590        let store_b = SessionStore::open_or_create(&path).unwrap();
1591        let cm_b = CacheManager::with_ref_age_duration(
1592            store_b,
1593            u64::MAX,
1594            Duration::from_millis(5),
1595        );
1596        let result = cm_b
1597            .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1598            .unwrap();
1599        assert!(
1600            matches!(result, CacheResult::Dedup { .. }),
1601            "second agent should see the pin from the shared store"
1602        );
1603    }
1604
1605    #[test]
1606    fn advance_turn_increments_counter() {
1607        // The counter is retained for context_evictor compatibility.
1608        let (store, _dir) = in_memory_store();
1609        let cm = CacheManager::new(store, u64::MAX);
1610        assert_eq!(cm.current_turn(), 0);
1611        cm.advance_turn();
1612        assert_eq!(cm.current_turn(), 1);
1613        cm.advance_turn();
1614        assert_eq!(cm.current_turn(), 2);
1615    }
1616
1617    // ── Expand feature tests ────────────────────────────────────────────
1618
1619    #[test]
1620    fn expand_returns_original_bytes_for_new_entry() {
1621        // New cache entries (written after the `original` column migration)
1622        // must always round-trip back to the exact bytes the agent sent.
1623        // This is the core guarantee `sqz expand` exists to provide.
1624        let (store, _dir) = in_memory_store();
1625        let cm = CacheManager::new(store, u64::MAX);
1626        let pipeline = make_pipeline();
1627        let path = Path::new("x.txt");
1628        let content = b"hello\nworld\nthis is the original\n";
1629
1630        // Seed the cache by running the content through get_or_compress.
1631        let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1632        let CacheResult::Fresh { .. } = first else {
1633            panic!("first read should be a Fresh miss, got something else");
1634        };
1635
1636        // Find the ref prefix by doing a second read (which dedups).
1637        let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1638        let inline_ref = match second {
1639            CacheResult::Dedup { inline_ref, .. } => inline_ref,
1640            _ => panic!("second read should dedup"),
1641        };
1642
1643        // Strip the `§ref:` / `§` wrappers to get the raw prefix.
1644        let prefix = inline_ref
1645            .strip_prefix("§ref:")
1646            .and_then(|s| s.strip_suffix('§'))
1647            .expect("unexpected ref format");
1648
1649        let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1650        match result {
1651            ExpandResult::Original { bytes, hash } => {
1652                assert_eq!(bytes, content, "expand must return exact original bytes");
1653                assert!(
1654                    hash.starts_with(prefix),
1655                    "returned full hash {hash} must start with prefix {prefix}"
1656                );
1657                assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1658            }
1659            ExpandResult::CompressedOnly { .. } => {
1660                panic!("new cache entries must carry the original bytes");
1661            }
1662        }
1663    }
1664
1665    #[test]
1666    fn expand_prefix_rejects_nonhex_input() {
1667        // Agents paste all sorts of things — ref tokens with the §'s still
1668        // attached, mixed-case hashes, stray whitespace. The store layer
1669        // normalises most of this but we also want to reject obvious
1670        // garbage without touching SQLite.
1671        let (store, _dir) = in_memory_store();
1672        let cm = CacheManager::new(store, u64::MAX);
1673        assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1674        assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1675        assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1676        assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1677    }
1678
1679    #[test]
1680    fn expand_prefix_returns_none_for_unknown_prefix() {
1681        // Very common in practice: agent pastes a ref from a different
1682        // machine, a different sqz install, or after a cache wipe.
1683        let (store, _dir) = in_memory_store();
1684        let cm = CacheManager::new(store, u64::MAX);
1685        let pipeline = make_pipeline();
1686        // Seed one entry.
1687        let _ = cm
1688            .get_or_compress(
1689                Path::new("y.txt"),
1690                b"some cached content that won't match the prefix below",
1691                &pipeline,
1692            )
1693            .unwrap();
1694        assert!(
1695            cm.expand_prefix("0000000000000000").unwrap().is_none(),
1696            "prefix that matches nothing must return None, not error"
1697        );
1698    }
1699
1700    #[test]
1701    fn expand_prefix_errors_on_ambiguous_match() {
1702        // Two entries whose hashes both start with the same hex prefix
1703        // must be detected and reported. The caller is expected to
1704        // surface "use a longer prefix" — we don't want to silently
1705        // pick one.
1706        let (store, _dir) = in_memory_store();
1707        // Hand-craft two entries with prefixes that collide on "ab".
1708        let compressed = crate::types::CompressedContent {
1709            data: "compressed-a".to_string(),
1710            tokens_compressed: 1,
1711            tokens_original: 10,
1712            stages_applied: vec![],
1713            compression_ratio: 0.1,
1714            provenance: Default::default(),
1715            verify: None,
1716        };
1717        store
1718            .save_cache_entry_with_original(
1719                &format!("ab{}", "0".repeat(62)),
1720                &compressed,
1721                Some(b"content a"),
1722            )
1723            .unwrap();
1724        store
1725            .save_cache_entry_with_original(
1726                &format!("ab{}", "1".repeat(62)),
1727                &compressed,
1728                Some(b"content b"),
1729            )
1730            .unwrap();
1731
1732        let cm = CacheManager::new(store, u64::MAX);
1733        // Unambiguous 3-char prefixes resolve:
1734        let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1735        assert!(matches!(ok_a, ExpandResult::Original { .. }));
1736        // Ambiguous 2-char prefix errors out.
1737        let err = cm.expand_prefix("ab").unwrap_err();
1738        let msg = err.to_string();
1739        assert!(
1740            msg.contains("multiple entries") || msg.contains("longer prefix"),
1741            "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1742        );
1743    }
1744
1745    #[test]
1746    fn expand_returns_compressed_only_for_pre_migration_entry() {
1747        // Entries written via `save_cache_entry` (without the `_with_original`
1748        // suffix) leave the `original` column NULL. Expand must still return
1749        // something useful — the compressed blob — with a note (handled by
1750        // the CLI). Here we assert the engine returns the right variant.
1751        let (store, _dir) = in_memory_store();
1752        let compressed = crate::types::CompressedContent {
1753            data: "the compressed version".to_string(),
1754            tokens_compressed: 4,
1755            tokens_original: 100,
1756            stages_applied: vec!["condense".to_string()],
1757            compression_ratio: 0.04,
1758            provenance: Default::default(),
1759            verify: None,
1760        };
1761        // Deliberately use the legacy save_cache_entry (no original).
1762        let hash = format!("deadbeef{}", "0".repeat(56));
1763        store.save_cache_entry(&hash, &compressed).unwrap();
1764
1765        let cm = CacheManager::new(store, u64::MAX);
1766        let result = cm
1767            .expand_prefix("deadbeef")
1768            .unwrap()
1769            .expect("deadbeef prefix should match");
1770        match result {
1771            ExpandResult::CompressedOnly { compressed, hash: h } => {
1772                assert_eq!(compressed, "the compressed version");
1773                assert_eq!(h, hash);
1774            }
1775            ExpandResult::Original { .. } => {
1776                panic!("legacy entry without original bytes should return CompressedOnly");
1777            }
1778        }
1779    }
1780
1781    #[test]
1782    fn expand_preserves_non_utf8_original_bytes() {
1783        // Shell output routinely contains non-UTF-8 sequences (terminal
1784        // control codes, binary blobs from `cat` on a wrong file, etc.).
1785        // Expand must round-trip byte-for-byte — not through a UTF-8
1786        // lossy conversion — or agents auditing the cache will see
1787        // spurious `\uFFFD` REPLACEMENT CHARACTER bytes.
1788        let (store, _dir) = in_memory_store();
1789        let cm = CacheManager::new(store, u64::MAX);
1790        let pipeline = make_pipeline();
1791
1792        // Mix of valid UTF-8 and isolated 0xFF bytes.
1793        let content: Vec<u8> = vec![
1794            b'h', b'e', b'l', b'l', b'o', b'\n',
1795            0xFF, 0xFE, 0xFD,
1796            b'\n',
1797        ];
1798        let path = Path::new("bin.dat");
1799        let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1800        // Round-trip via expand_prefix.
1801        let hash = CacheManager::sha256_hex(&content);
1802        let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1803        match result {
1804            ExpandResult::Original { bytes, .. } => {
1805                assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1806            }
1807            ExpandResult::CompressedOnly { .. } => {
1808                panic!("fresh entry should have original bytes");
1809            }
1810        }
1811    }
1812
1813    #[test]
1814    fn dedup_survives_cache_manager_restart() {
1815        // Regression for the April 18 bug: the turn counter was in-memory
1816        // only, so every new sqz process saw an empty ref tracker and the
1817        // dedup feature silently produced Fresh results forever. With
1818        // accessed_at-based freshness, a fresh CacheManager reading the
1819        // same SQLite store picks up the dedup correctly.
1820        let dir = tempfile::tempdir().unwrap();
1821        let db_path = dir.path().join("cache.db");
1822        let pipeline = make_pipeline();
1823        let content = b"a substantial chunk of content to dedup";
1824        let path = Path::new("x.txt");
1825
1826        // First "process": populate cache.
1827        {
1828            let store = SessionStore::open_or_create(&db_path).unwrap();
1829            let cm = CacheManager::with_ref_age_duration(
1830                store,
1831                u64::MAX,
1832                Duration::from_secs(3600),
1833            );
1834            let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1835            assert!(matches!(first, CacheResult::Fresh { .. }));
1836        }
1837
1838        // Second "process": new CacheManager, same DB. Dedup must fire.
1839        {
1840            let store = SessionStore::open_or_create(&db_path).unwrap();
1841            let cm = CacheManager::with_ref_age_duration(
1842                store,
1843                u64::MAX,
1844                Duration::from_secs(3600),
1845            );
1846            let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1847            assert!(
1848                matches!(second, CacheResult::Dedup { .. }),
1849                "second-process read must dedup — this was broken before the April 18 fix"
1850            );
1851        }
1852    }
1853
1854    #[test]
1855    fn compaction_from_one_process_invalidates_refs_in_another() {
1856        // Regression for the PreCompact hook wiring: the host harness
1857        // (e.g. Claude Code) runs `sqz hook precompact` in a short-lived
1858        // process to signal auto-compaction. The actual dedup serving runs
1859        // in a DIFFERENT sqz process (the shell hook). notify_compaction
1860        // must persist through SQLite so the second process sees it.
1861        //
1862        // Before the fix, compaction_marker was Cell<Option<DateTime>>
1863        // in memory only — the precompact process set it, exited, the
1864        // state was lost. Next shell-hook process started with a clean
1865        // marker, served stale refs to the agent, and the agent saw a
1866        // §ref:HASH§ pointing at content no longer in its context.
1867        let dir = tempfile::tempdir().unwrap();
1868        let db_path = dir.path().join("cache.db");
1869        let pipeline = make_pipeline();
1870        let content = b"content that needs stale-marking after compaction";
1871        let path = Path::new("file.txt");
1872        let ttl = Duration::from_secs(3600);
1873
1874        // Process A: populate the cache so the content is dedup-eligible.
1875        {
1876            let store = SessionStore::open_or_create(&db_path).unwrap();
1877            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1878            cm.get_or_compress(path, content, &pipeline).unwrap();
1879        }
1880        // Sleep so the compaction marker is strictly after the touch.
1881        std::thread::sleep(Duration::from_millis(10));
1882
1883        // Process B: simulates `sqz hook precompact`. Just calls
1884        // notify_compaction and exits. No reads.
1885        {
1886            let store = SessionStore::open_or_create(&db_path).unwrap();
1887            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1888            cm.notify_compaction();
1889        }
1890
1891        // Process C: simulates the next `sqz compress` shell-hook call.
1892        // Reads the same content. MUST re-send Fresh, not return a ref
1893        // the agent can no longer resolve.
1894        {
1895            let store = SessionStore::open_or_create(&db_path).unwrap();
1896            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1897            let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1898            assert!(
1899                matches!(result, CacheResult::Fresh { .. }),
1900                "post-compaction read from a fresh process must re-send Fresh; \
1901                 returning Dedup would be a dangling-ref bug"
1902            );
1903        }
1904    }
1905
1906    use proptest::prelude::*;
1907
1908    // ── Property 8: Cache deduplication ──────────────────────────────────────
1909    // **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1910    //
1911    // For any file content, reading the file twice through the CacheManager
1912    // (with no content change between reads) SHALL return a cache hit on the
1913    // second read with a reference token of approximately 13 tokens.
1914
1915    proptest! {
1916        /// **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1917        ///
1918        /// For any file content, the second read through CacheManager SHALL be
1919        /// a cache hit with tokens == 13.
1920        #[test]
1921        fn prop_cache_deduplication(
1922            content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1923        ) {
1924            let (store, _dir) = in_memory_store();
1925            let cm = CacheManager::new(store, u64::MAX);
1926            let pipeline = make_pipeline();
1927            let path = Path::new("file.txt");
1928
1929            // First read — must be a miss.
1930            let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1931            prop_assert!(
1932                matches!(first, CacheResult::Fresh { .. }),
1933                "first read should be a cache miss"
1934            );
1935
1936            let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1937            match second {
1938                CacheResult::Dedup { inline_ref, token_cost } => {
1939                    prop_assert_eq!(
1940                        token_cost, 13,
1941                        "cache hit should report ~13 reference tokens"
1942                    );
1943                    prop_assert!(
1944                        inline_ref.starts_with("§ref:"),
1945                        "reference token should start with §ref:"
1946                    );
1947                    prop_assert!(
1948                        inline_ref.ends_with('§'),
1949                        "reference token should end with §"
1950                    );
1951                }
1952                CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1953                    prop_assert!(false, "second read should be a cache hit, not a miss");
1954                }
1955            }
1956        }
1957    }
1958
1959    // ── Property 9: Cache invalidation on content change ─────────────────────
1960    // **Validates: Requirements 8.3, 18.3**
1961    //
1962    // For any cached file, if the file content changes (producing a different
1963    // SHA-256 hash), the CacheManager SHALL treat the next read as a cache miss
1964    // and re-compress the updated content.
1965
1966    proptest! {
1967        /// **Validates: Requirements 8.3, 18.3**
1968        ///
1969        /// For any two distinct byte sequences, the first read of each is a
1970        /// cache miss — content change always triggers re-compression.
1971        #[test]
1972        fn prop_cache_invalidation_on_content_change(
1973            content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1974            content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1975        ) {
1976            // Only meaningful when the two contents differ (different hashes).
1977            prop_assume!(content_a != content_b);
1978
1979            let (store, _dir) = in_memory_store();
1980            let cm = CacheManager::new(store, u64::MAX);
1981            let pipeline = make_pipeline();
1982            let path = Path::new("file.txt");
1983
1984            // Cache content_a.
1985            let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1986            prop_assert!(
1987                matches!(r1, CacheResult::Fresh { .. }),
1988                "first read of content_a should be a miss"
1989            );
1990
1991            let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1992            prop_assert!(
1993                matches!(r2, CacheResult::Dedup { .. }),
1994                "second read of content_a should be a hit"
1995            );
1996
1997            let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1998            prop_assert!(
1999                matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
2000                "read with changed content should be a cache miss or delta"
2001            );
2002        }
2003    }
2004
2005    // ── Property 10: Cache LRU eviction ──────────────────────────────────────
2006    // **Validates: Requirements 8.5**
2007    //
2008    // For any cache state where total size exceeds the configured maximum, the
2009    // CacheManager SHALL evict entries in LRU order until total size is at or
2010    // below the limit.
2011
2012    proptest! {
2013        /// **Validates: Requirements 8.5**
2014        ///
2015        /// After evict_lru, the total remaining cache size SHALL be at or below
2016        /// max_size_bytes.
2017        #[test]
2018        fn prop_cache_lru_eviction(
2019            // Generate 2-8 distinct content entries.
2020            entries in proptest::collection::vec(
2021                proptest::collection::vec(any::<u8>(), 10..=200usize),
2022                2..=8usize,
2023            ),
2024        ) {
2025            // Deduplicate entries so each has a unique hash.
2026            let mut unique_entries: Vec<Vec<u8>> = Vec::new();
2027            for e in &entries {
2028                if !unique_entries.contains(e) {
2029                    unique_entries.push(e.clone());
2030                }
2031            }
2032            prop_assume!(unique_entries.len() >= 2);
2033
2034            let (store, _dir) = in_memory_store();
2035            // Use a very small limit (1 byte) to guarantee eviction is needed.
2036            let cm = CacheManager::new(store, 1);
2037            let pipeline = make_pipeline();
2038            let path = Path::new("f.txt");
2039
2040            // Populate the cache.
2041            for entry in &unique_entries {
2042                cm.get_or_compress(path, entry, &pipeline).unwrap();
2043            }
2044
2045            // Evict LRU entries.
2046            let freed = cm.evict_lru().unwrap();
2047
2048            // Bytes freed must be > 0 since total > 1 byte.
2049            prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
2050
2051            // After eviction, total remaining size must be <= max_size_bytes (1).
2052            // We verify by checking that evict_lru now returns 0 (nothing left to evict).
2053            let freed_again = cm.evict_lru().unwrap();
2054            prop_assert_eq!(
2055                freed_again, 0,
2056                "second evict_lru call should free 0 bytes (already at or below limit)"
2057            );
2058        }
2059    }
2060
2061    // ── Property 34: Cache persistence across sessions ────────────────────────
2062    // **Validates: Requirements 18.4**
2063    //
2064    // For any set of cache entries saved to the SessionStore, reloading the
2065    // store (opening the same database file) SHALL produce the same cache
2066    // entries, and a subsequent read with the same content hash SHALL return a
2067    // cache hit.
2068
2069    proptest! {
2070        /// **Validates: Requirements 18.4**
2071        ///
2072        /// Cache entries written in one CacheManager instance SHALL survive
2073        /// a store close/reopen. With the wall-clock freshness model
2074        /// (introduced April 18 2026), a subsequent CacheManager reading
2075        /// the same database SHALL see the entry as fresh (within TTL) and
2076        /// return a Dedup hit on the very first read — this is the whole
2077        /// point of the cross-process fix. Previous behavior (Fresh on
2078        /// first read after restart) was a bug that silently disabled the
2079        /// dedup feature in production.
2080        #[test]
2081        fn prop_cache_persistence_across_sessions(
2082            content in proptest::collection::vec(any::<u8>(), 1..=500usize),
2083        ) {
2084            use crate::session_store::SessionStore;
2085
2086            let dir = tempfile::tempdir().unwrap();
2087            let db_path = dir.path().join("cache.db");
2088            let path = Path::new("file.txt");
2089
2090            // Session 1: populate the cache.
2091            {
2092                let store = SessionStore::open_or_create(&db_path).unwrap();
2093                // Explicit long TTL so tests don't race with wall-clock drift.
2094                let cm = CacheManager::with_ref_age_duration(
2095                    store,
2096                    u64::MAX,
2097                    Duration::from_secs(3600),
2098                );
2099                let pipeline = make_pipeline();
2100
2101                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2102                prop_assert!(
2103                    matches!(r, CacheResult::Fresh { .. }),
2104                    "first-ever read should be a miss"
2105                );
2106            }
2107
2108            // Session 2: reopen the same database file.
2109            {
2110                let store = SessionStore::open_or_create(&db_path).unwrap();
2111                let cm = CacheManager::with_ref_age_duration(
2112                    store,
2113                    u64::MAX,
2114                    Duration::from_secs(3600),
2115                );
2116                let pipeline = make_pipeline();
2117
2118                // First read in the new session MUST dedup. The entry was
2119                // just written (within TTL), so the wall-clock freshness
2120                // check finds it fresh. This is what makes sqz's dedup
2121                // actually work across shell-hook invocations.
2122                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2123                match r {
2124                    CacheResult::Dedup { token_cost, .. } => {
2125                        prop_assert_eq!(
2126                            token_cost, 13,
2127                            "first read after restart must be a 13-token dedup ref"
2128                        );
2129                    }
2130                    CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
2131                        prop_assert!(
2132                            false,
2133                            "first read after restart must dedup — this was the \
2134                             April 18 bug and its fix is the whole reason this \
2135                             test exists"
2136                        );
2137                    }
2138                }
2139            }
2140        }
2141    }
2142}