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