Skip to main content

sqz_engine/
session_store.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use rusqlite::{params, Connection, OpenFlags};
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Result, SqzError};
9use crate::types::{CompressedContent, SessionId, SessionState};
10
11/// A lightweight summary of a session for search results. Doesn't include
12/// the full conversation — just enough to identify and filter sessions.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionSummary {
15    pub id: SessionId,
16    pub project_dir: PathBuf,
17    pub compressed_summary: String,
18    pub created_at: DateTime<Utc>,
19    pub updated_at: DateTime<Utc>,
20}
21
22/// SQLite-backed persistent session and cache store with FTS5 full-text search.
23///
24/// Stores sessions, cache entries, compression logs, and known files in a
25/// single SQLite database. Uses WAL mode for concurrent read access.
26///
27/// ```rust,no_run
28/// use sqz_engine::SessionStore;
29/// use std::path::Path;
30///
31/// let store = SessionStore::open_or_create(Path::new("~/.sqz/sessions.db")).unwrap();
32/// let results = store.search("authentication refactor").unwrap();
33/// for session in &results {
34///     println!("{}: {}", session.id, session.compressed_summary);
35/// }
36/// ```
37pub struct SessionStore {
38    db: Connection,
39}
40
41/// Log rows written by the verbatim MCP read tools whose output count
42/// exceeds their input count. See [`SessionStore::inflated_rows`].
43const INFLATED_ROW_FILTER: &str = "mode IN ('sqz_read_file', 'sqz_grep', 'sqz_list_dir') \
44     AND tokens_compressed > tokens_original";
45
46// ── Schema ────────────────────────────────────────────────────────────────────
47
48const SCHEMA: &str = r#"
49PRAGMA journal_mode = WAL;
50
51CREATE TABLE IF NOT EXISTS sessions (
52    id               TEXT PRIMARY KEY,
53    project_dir      TEXT NOT NULL,
54    compressed_summary TEXT NOT NULL,
55    created_at       TEXT NOT NULL,
56    updated_at       TEXT NOT NULL,
57    data             BLOB NOT NULL
58);
59
60CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
61    id,
62    project_dir,
63    compressed_summary,
64    content='sessions',
65    content_rowid='rowid',
66    tokenize='porter ascii'
67);
68
69CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
70    INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
71    VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
72END;
73
74CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
75    INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
76    VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
77END;
78
79CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
80    INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
81    VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
82    INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
83    VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
84END;
85
86CREATE TABLE IF NOT EXISTS cache_entries (
87    hash        TEXT PRIMARY KEY,
88    data        TEXT NOT NULL,
89    accessed_at TEXT NOT NULL,
90    -- Raw pre-compression bytes so `sqz expand <prefix>` can serve
91    -- truly uncompressed content to agents that cannot parse
92    -- `§ref:…§` dedup tokens. Nullable because the column was added
93    -- in an additive migration; rows written before that migration
94    -- (or via callers that don't have the original bytes) have NULL.
95    original    BLOB,
96    -- Pinned entries bypass the TTL freshness check. Used for stable
97    -- knowledge-base content (a wiki, project documentation, system
98    -- prompts) that's persistently re-injected into every agent and
99    -- therefore never gets compacted out of the LLM's context. See
100    -- `sqz pin` for the user-facing CLI.
101    pinned      INTEGER NOT NULL DEFAULT 0
102);
103
104CREATE TABLE IF NOT EXISTS compression_log (
105    id               INTEGER PRIMARY KEY AUTOINCREMENT,
106    tokens_original  INTEGER NOT NULL,
107    tokens_compressed INTEGER NOT NULL,
108    stages_applied   TEXT NOT NULL,
109    mode             TEXT NOT NULL DEFAULT 'auto',
110    created_at       TEXT NOT NULL
111);
112
113-- Compression-regret signals. A 'rerun' row means the agent re-produced
114-- byte-identical output within a short window of last seeing it (the
115-- repeat bought no new information); an 'expand' row means a §ref:…§
116-- token was expanded back to its original bytes (a recovery round trip).
117-- Both are proxies for "compression dropped something the agent needed",
118-- surfaced by `sqz stats` so aggressiveness can be judged per command.
119CREATE TABLE IF NOT EXISTS regret_log (
120    id          INTEGER PRIMARY KEY AUTOINCREMENT,
121    kind        TEXT NOT NULL,
122    cmd         TEXT NOT NULL,
123    hash        TEXT NOT NULL,
124    gap_secs    REAL NOT NULL,
125    created_at  TEXT NOT NULL
126);
127
128CREATE TABLE IF NOT EXISTS known_files (
129    path        TEXT PRIMARY KEY,
130    added_at    TEXT NOT NULL
131);
132
133-- Small key/value store for engine-wide state that needs to persist across
134-- short-lived sqz processes (each shell-hook invocation is a new process).
135-- Initially used only for the last_compaction_at marker: cache entries with
136-- `accessed_at < last_compaction_at` are treated as stale even if still
137-- within the normal TTL. See cache_manager.rs for the freshness model.
138CREATE TABLE IF NOT EXISTS metadata (
139    key         TEXT PRIMARY KEY,
140    value       TEXT NOT NULL
141);
142
143-- Full-text search over everything that flowed through sqz: cached
144-- command output (kind='output', ref_hash expandable via `sqz expand`)
145-- and session summaries (kind='session', ref_hash = session id). Lets
146-- agents re-find content they have already seen instead of re-running
147-- commands or re-reading files. BM25-ranked via FTS5.
148CREATE VIRTUAL TABLE IF NOT EXISTS recall_index USING fts5(
149    content,
150    ref_hash UNINDEXED,
151    kind UNINDEXED,
152    created_at UNINDEXED
153);
154"#;
155
156// ── Helpers ───────────────────────────────────────────────────────────────────
157
158pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
159    conn.execute_batch(SCHEMA)?;
160    // Additive migration: add `original` BLOB column to cache_entries if
161    // it does not yet exist. Stores the raw pre-compression bytes so
162    // `sqz expand <prefix>` can return truly uncompressed content when
163    // an agent cannot parse `§ref:…§` tokens (reported on-list by
164    // SquireNed for GLM 5.1). `NULL` for rows written by older sqz
165    // versions — `expand` treats those as "original unavailable, fall
166    // back to the compressed blob" so users don't get spurious errors
167    // on pre-migration data.
168    //
169    // Using pragma_table_info rather than a version table because the
170    // rest of sqz does the same — this is the first additive migration.
171    let has_original: bool = conn
172        .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'original'")?
173        .query_row([], |_| Ok(()))
174        .is_ok();
175    if !has_original {
176        conn.execute("ALTER TABLE cache_entries ADD COLUMN original BLOB", [])?;
177    }
178
179    // Additive migration: add `project_dir` TEXT column to compression_log.
180    // Enables per-project stats filtering (issue #13). NULL for rows
181    // written by older sqz versions — aggregate queries treat those as
182    // "unknown project".
183    let has_project_dir: bool = conn
184        .prepare("SELECT 1 FROM pragma_table_info('compression_log') WHERE name = 'project_dir'")?
185        .query_row([], |_| Ok(()))
186        .is_ok();
187    if !has_project_dir {
188        conn.execute("ALTER TABLE compression_log ADD COLUMN project_dir TEXT", [])?;
189    }
190
191    // Additive migration: add `pinned` flag to cache_entries. Pinned
192    // entries bypass the TTL freshness check so stable wiki / knowledge
193    // base content stays dedup-able forever.
194    let has_pinned: bool = conn
195        .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'pinned'")?
196        .query_row([], |_| Ok(()))
197        .is_ok();
198    if !has_pinned {
199        conn.execute(
200            "ALTER TABLE cache_entries ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
201            [],
202        )?;
203    }
204
205    Ok(())
206}
207
208fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
209    let conn = Connection::open(path)?;
210    apply_schema(&conn)?;
211    Ok(conn)
212}
213
214fn row_to_summary(
215    id: String,
216    project_dir: String,
217    compressed_summary: String,
218    created_at: String,
219    updated_at: String,
220) -> Result<SessionSummary> {
221    let created_at = created_at
222        .parse::<DateTime<Utc>>()
223        .map_err(|e| SqzError::Other(format!("invalid created_at timestamp: {e}")))?;
224    let updated_at = updated_at
225        .parse::<DateTime<Utc>>()
226        .map_err(|e| SqzError::Other(format!("invalid updated_at timestamp: {e}")))?;
227    Ok(SessionSummary {
228        id,
229        project_dir: PathBuf::from(project_dir),
230        compressed_summary,
231        created_at,
232        updated_at,
233    })
234}
235
236// ── SessionStore ──────────────────────────────────────────────────────────────
237
238impl SessionStore {
239    /// Construct a `SessionStore` from an already-open `Connection`.
240    /// Intended for testing (e.g., in-memory databases).
241    #[cfg(test)]
242    pub(crate) fn from_connection(conn: Connection) -> Self {
243        Self { db: conn }
244    }
245
246    /// Open an existing database at `path`. Returns an error if the file does
247    /// not exist or cannot be opened.
248    pub fn open(path: &Path) -> Result<Self> {
249        let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
250        apply_schema(&conn)?;
251        Ok(Self { db: conn })
252    }
253
254    /// Open the database at `path`, creating it if it does not exist.
255    /// If the database is corrupted, a fresh database is created at the same
256    /// path and a warning is logged to stderr.
257    pub fn open_or_create(path: &Path) -> Result<Self> {
258        match open_connection(path) {
259            Ok(conn) => Ok(Self { db: conn }),
260            Err(e) => {
261                eprintln!(
262                    "sqz warning: session store at '{}' is corrupted or inaccessible ({e}). \
263                     Creating a new database. Prior session data has been lost.",
264                    path.display()
265                );
266                // Remove the corrupted file so we can start fresh.
267                if path.exists() {
268                    let _ = std::fs::remove_file(path);
269                }
270                let conn = open_connection(path)
271                    .map_err(|e2| SqzError::Other(format!("failed to create new session store: {e2}")))?;
272                Ok(Self { db: conn })
273            }
274        }
275    }
276
277    // ── Session CRUD ──────────────────────────────────────────────────────────
278
279    /// Persist a session. Returns the session id.
280    pub fn save_session(&self, session: &SessionState) -> Result<SessionId> {
281        let data = serde_json::to_vec(session)?;
282        let project_dir = session.project_dir.to_string_lossy().to_string();
283        let created_at = session.created_at.to_rfc3339();
284        let updated_at = session.updated_at.to_rfc3339();
285
286        self.db.execute(
287            r#"INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data)
288               VALUES (?1, ?2, ?3, ?4, ?5, ?6)
289               ON CONFLICT(id) DO UPDATE SET
290                   project_dir        = excluded.project_dir,
291                   compressed_summary = excluded.compressed_summary,
292                   created_at         = excluded.created_at,
293                   updated_at         = excluded.updated_at,
294                   data               = excluded.data"#,
295            params![
296                session.id,
297                project_dir,
298                session.compressed_summary,
299                created_at,
300                updated_at,
301                data,
302            ],
303        )?;
304        if !session.compressed_summary.is_empty() {
305            self.recall_index_upsert(
306                &session.id,
307                "session",
308                session.compressed_summary.as_bytes(),
309            );
310        }
311
312        Ok(session.id.clone())
313    }
314
315    /// Save a lightweight session from just an id and summary for dashboard use.
316    pub fn save_dashboard_session(&self, id: &str, summary: &str) -> Result<()> {
317        let now = Utc::now().to_rfc3339();
318        let project_dir = std::env::current_dir()
319            .unwrap_or_else(|_| std::path::PathBuf::from("."))
320            .to_string_lossy()
321            .to_string();
322        let empty_data = serde_json::to_vec(&serde_json::json!({"conversation":[]}))?;
323
324        self.db
325            .execute(
326                "INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data) \
327                 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
328                 ON CONFLICT(id) DO UPDATE SET \
329                 compressed_summary = excluded.compressed_summary, \
330                 updated_at = excluded.updated_at",
331                params![id, project_dir, summary, now, now, empty_data],
332            )
333            .map_err(SqzError::SessionStore)?;
334        Ok(())
335    }
336
337    /// Load a session by id.
338    pub fn load_session(&self, id: SessionId) -> Result<SessionState> {
339        let data: Vec<u8> = self.db.query_row(
340            "SELECT data FROM sessions WHERE id = ?1",
341            params![id],
342            |row| row.get(0),
343        )?;
344        let session: SessionState = serde_json::from_slice(&data)?;
345        Ok(session)
346    }
347
348    // ── Search ────────────────────────────────────────────────────────────────
349
350    /// Full-text search using FTS5 (porter stemmer, ASCII tokenizer).
351    pub fn search(&self, query: &str) -> Result<Vec<SessionSummary>> {
352        let mut stmt = self.db.prepare(
353            r#"SELECT s.id, s.project_dir, s.compressed_summary, s.created_at, s.updated_at
354               FROM sessions s
355               JOIN sessions_fts f ON s.rowid = f.rowid
356               WHERE sessions_fts MATCH ?1
357               ORDER BY rank"#,
358        )?;
359
360        let rows = stmt.query_map(params![query], |row| {
361            Ok((
362                row.get::<_, String>(0)?,
363                row.get::<_, String>(1)?,
364                row.get::<_, String>(2)?,
365                row.get::<_, String>(3)?,
366                row.get::<_, String>(4)?,
367            ))
368        })?;
369
370        let mut results = Vec::new();
371        for row in rows {
372            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
373            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
374        }
375        Ok(results)
376    }
377
378    /// Query sessions whose `updated_at` falls within `[from, to]`.
379    pub fn search_by_date(
380        &self,
381        from: DateTime<Utc>,
382        to: DateTime<Utc>,
383    ) -> Result<Vec<SessionSummary>> {
384        let mut stmt = self.db.prepare(
385            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
386               FROM sessions
387               WHERE updated_at >= ?1 AND updated_at <= ?2
388               ORDER BY updated_at DESC"#,
389        )?;
390
391        let rows = stmt.query_map(params![from.to_rfc3339(), to.to_rfc3339()], |row| {
392            Ok((
393                row.get::<_, String>(0)?,
394                row.get::<_, String>(1)?,
395                row.get::<_, String>(2)?,
396                row.get::<_, String>(3)?,
397                row.get::<_, String>(4)?,
398            ))
399        })?;
400
401        let mut results = Vec::new();
402        for row in rows {
403            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
404            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
405        }
406        Ok(results)
407    }
408
409    /// Return the most recently updated session, or `None` if no sessions exist.
410    pub fn latest_session(&self) -> Result<Option<SessionSummary>> {
411        let mut stmt = self.db.prepare(
412            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
413               FROM sessions
414               ORDER BY updated_at DESC
415               LIMIT 1"#,
416        ).map_err(SqzError::SessionStore)?;
417
418        let rows = stmt.query_map([], |row| {
419            Ok((
420                row.get::<_, String>(0)?,
421                row.get::<_, String>(1)?,
422                row.get::<_, String>(2)?,
423                row.get::<_, String>(3)?,
424                row.get::<_, String>(4)?,
425            ))
426        }).map_err(SqzError::SessionStore)?;
427
428        for row in rows {
429            let (id, project_dir, compressed_summary, created_at, updated_at) =
430                row.map_err(SqzError::SessionStore)?;
431            return Ok(Some(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?));
432        }
433        Ok(None)
434    }
435
436    /// Query sessions whose `project_dir` matches `dir` exactly.
437    pub fn search_by_project(&self, dir: &Path) -> Result<Vec<SessionSummary>> {
438        let dir_str = dir.to_string_lossy().to_string();
439        let mut stmt = self.db.prepare(
440            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
441               FROM sessions
442               WHERE project_dir = ?1
443               ORDER BY updated_at DESC"#,
444        )?;
445
446        let rows = stmt.query_map(params![dir_str], |row| {
447            Ok((
448                row.get::<_, String>(0)?,
449                row.get::<_, String>(1)?,
450                row.get::<_, String>(2)?,
451                row.get::<_, String>(3)?,
452                row.get::<_, String>(4)?,
453            ))
454        })?;
455
456        let mut results = Vec::new();
457        for row in rows {
458            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
459            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
460        }
461        Ok(results)
462    }
463
464    // ── Cache entries ─────────────────────────────────────────────────────────
465
466    /// Persist a cache entry keyed by content hash.
467    ///
468    /// Stores both the compressed JSON (`data`) used for dedup-hit
469    /// responses AND the raw uncompressed bytes (`original`) so that
470    /// `sqz expand <prefix>` can serve truly uncompressed content. See
471    /// [`save_cache_entry_with_original`](Self::save_cache_entry_with_original)
472    /// for the original-aware version; this convenience wrapper exists for
473    /// callers that do not (yet) have the pre-compression bytes handy. Rows
474    /// written through this path leave `original` as `NULL`, and `expand`
475    /// will degrade to returning the compressed blob with a note.
476    pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
477        self.save_cache_entry_with_original(hash, compressed, None)
478    }
479
480    /// Persist a cache entry with both compressed and original content.
481    ///
482    /// `original` must be the exact bytes that produced `compressed`, so
483    /// that `expand` is a true inverse of dedup. We store the raw bytes
484    /// (not the UTF-8 string) because command output may include
485    /// non-UTF-8 sequences — storing the text would lose them.
486    pub fn save_cache_entry_with_original(
487        &self,
488        hash: &str,
489        compressed: &CompressedContent,
490        original: Option<&[u8]>,
491    ) -> Result<()> {
492        let data = serde_json::to_string(compressed)?;
493        let now = Utc::now().to_rfc3339();
494        self.db.execute(
495            r#"INSERT INTO cache_entries (hash, data, accessed_at, original)
496               VALUES (?1, ?2, ?3, ?4)
497               ON CONFLICT(hash) DO UPDATE
498                   SET data = excluded.data,
499                       accessed_at = excluded.accessed_at,
500                       -- Don't overwrite a previously-stored `original`
501                       -- with NULL. Older callers (that go through
502                       -- save_cache_entry rather than the _with_original
503                       -- variant) shouldn't erase the expand-able bytes.
504                       original = COALESCE(excluded.original, original)"#,
505            params![hash, data, now, original],
506        )?;
507        if let Some(bytes) = original {
508            self.recall_index_upsert(hash, "output", bytes);
509        }
510        Ok(())
511    }
512
513    /// Index content for `sqz recall`. Binary content (NUL bytes) and
514    /// empty content are skipped; oversized content is indexed up to a
515    /// cap so one giant log can't bloat the index.
516    fn recall_index_upsert(&self, ref_hash: &str, kind: &str, content: &[u8]) {
517        const MAX_INDEXED_BYTES: usize = 262_144;
518        if content.is_empty() || content.contains(&0) {
519            return;
520        }
521        let text = String::from_utf8_lossy(content);
522        let capped: &str = if text.len() > MAX_INDEXED_BYTES {
523            let mut end = MAX_INDEXED_BYTES;
524            while !text.is_char_boundary(end) {
525                end -= 1;
526            }
527            &text[..end]
528        } else {
529            &text
530        };
531        let now = Utc::now().to_rfc3339();
532        let _ = self.db.execute(
533            "DELETE FROM recall_index WHERE ref_hash = ?1",
534            params![ref_hash],
535        );
536        let _ = self.db.execute(
537            "INSERT INTO recall_index (content, ref_hash, kind, created_at) VALUES (?1, ?2, ?3, ?4)",
538            params![capped, ref_hash, kind, now],
539        );
540    }
541
542    /// BM25-ranked full-text search over indexed content. Returns the
543    /// best `limit` hits with a highlighted snippet each.
544    pub fn recall_search(&self, query: &str, limit: u32) -> Result<Vec<RecallHit>> {
545        let match_expr = fts_query(query);
546        if match_expr.is_empty() {
547            return Ok(Vec::new());
548        }
549        let mut stmt = self.db.prepare(
550            "SELECT ref_hash, kind, snippet(recall_index, 0, '>>', '<<', ' … ', 24), created_at \
551             FROM recall_index WHERE recall_index MATCH ?1 \
552             ORDER BY bm25(recall_index) LIMIT ?2",
553        ).map_err(SqzError::SessionStore)?;
554        let rows = stmt.query_map(params![match_expr, limit], |row| {
555            Ok(RecallHit {
556                ref_hash: row.get(0)?,
557                kind: row.get(1)?,
558                snippet: row.get(2)?,
559                created_at: row.get(3)?,
560            })
561        }).map_err(SqzError::SessionStore)?;
562        let mut hits = Vec::new();
563        for row in rows {
564            hits.push(row.map_err(SqzError::SessionStore)?);
565        }
566        Ok(hits)
567    }
568
569    /// First and last compression timestamps (RFC 3339), or `None` when
570    /// nothing has been logged yet.
571    pub fn compression_period(&self) -> Result<Option<(String, String)>> {
572        let row: Option<(Option<String>, Option<String>)> = self
573            .db
574            .query_row(
575                "SELECT MIN(created_at), MAX(created_at) FROM compression_log",
576                [],
577                |row| Ok((row.get(0)?, row.get(1)?)),
578            )
579            .ok();
580        Ok(match row {
581            Some((Some(a), Some(b))) => Some((a, b)),
582            _ => None,
583        })
584    }
585
586    /// Retrieve the stored original bytes for a cached hash, if the
587    /// caller populated them via `save_cache_entry_with_original`.
588    ///
589    /// Returns `Ok(None)` for missing entries AND for entries that were
590    /// saved by an older call site that did not pass `original`. The
591    /// caller should fall back to the compressed blob in the latter case
592    /// and surface a note to the user so they know this specific entry
593    /// wasn't round-trippable.
594    pub fn get_cache_entry_original(&self, hash: &str) -> Result<Option<Vec<u8>>> {
595        let result: rusqlite::Result<Option<Vec<u8>>> = self.db.query_row(
596            "SELECT original FROM cache_entries WHERE hash = ?1",
597            params![hash],
598            |row| row.get(0),
599        );
600        match result {
601            Ok(v) => Ok(v),
602            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
603            Err(e) => Err(SqzError::SessionStore(e)),
604        }
605    }
606
607    /// Most recently accessed entries that still hold their original bytes
608    /// and are at least `min_len` bytes long, newest first. Candidates for
609    /// slice matching: a ranged re-read can only be a slice of something
610    /// larger than itself.
611    pub fn recent_originals(&self, min_len: usize, limit: usize) -> Result<Vec<SliceCandidate>> {
612        let mut stmt = self.db.prepare(
613            "SELECT hash, original, data FROM cache_entries \
614             WHERE original IS NOT NULL AND length(original) > ?1 \
615             ORDER BY accessed_at DESC LIMIT ?2",
616        )?;
617        let rows = stmt.query_map(params![min_len as i64, limit as i64], |row| {
618            Ok(SliceCandidate {
619                hash: row.get(0)?,
620                original: row.get(1)?,
621                data_json: row.get(2)?,
622            })
623        })?;
624        let mut out = Vec::new();
625        for row in rows {
626            out.push(row?);
627        }
628        Ok(out)
629    }
630
631    /// Delete a cache entry by content hash.
632    pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
633        self.db.execute(
634            "DELETE FROM cache_entries WHERE hash = ?1",
635            params![hash],
636        )?;
637        let _ = self.db.execute(
638            "DELETE FROM recall_index WHERE ref_hash = ?1",
639            params![hash],
640        );
641        Ok(())
642    }
643
644    /// Return all cache entries ordered by `accessed_at` ASC (oldest first),
645    /// as `(hash, size_bytes)` pairs where `size_bytes` is the byte length of
646    /// the stored JSON data.
647    pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
648        let mut stmt = self.db.prepare(
649            "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
650        )?;
651        let rows = stmt.query_map([], |row| {
652            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
653        })?;
654        let mut entries = Vec::new();
655        for row in rows {
656            let (hash, size) = row?;
657            entries.push((hash, size as u64));
658        }
659        Ok(entries)
660    }
661
662    /// Retrieve a cache entry by content hash, updating `accessed_at`.
663    pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
664        let result: rusqlite::Result<String> = self.db.query_row(
665            "SELECT data FROM cache_entries WHERE hash = ?1",
666            params![hash],
667            |row| row.get(0),
668        );
669
670        match result {
671            Ok(data) => {
672                // Touch accessed_at for LRU tracking.
673                let now = Utc::now().to_rfc3339();
674                let _ = self.db.execute(
675                    "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
676                    params![now, hash],
677                );
678                let entry: CompressedContent = serde_json::from_str(&data)?;
679                Ok(Some(entry))
680            }
681            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
682            Err(e) => Err(SqzError::SessionStore(e)),
683        }
684    }
685
686    /// Look up a cache entry by a **prefix** of the content hash.
687    ///
688    /// The inline dedup refs we hand to the LLM carry only the first 16
689    /// hex chars of the SHA-256 (`§ref:<16-hex>§`), so when an agent runs
690    /// `sqz expand <prefix>` we need to resolve those 16 chars back to the
691    /// full 64-char key. Uses `LIKE 'prefix%'` with an index-friendly
692    /// anchored pattern so the query stays O(log n) on the primary key.
693    ///
694    /// Returns `Ok(Some((full_hash, entry)))` on unique match.
695    /// Returns `Ok(None)` if no entries match.
696    /// Returns `Err(_)` if the prefix is ambiguous (2+ matches) — the
697    /// caller should tell the user to use a longer prefix. 16-hex
698    /// collisions are astronomically unlikely (one in 2^64) but we
699    /// refuse to guess rather than quietly serve a surprise file.
700    ///
701    /// The prefix is validated as lowercase hex. Non-hex input returns
702    /// `None` without touching the database — this is how we handle the
703    /// common user-error case of pasting the ref with the `§` markers
704    /// still attached (they get rejected before we query SQLite).
705    pub fn get_cache_entry_by_prefix(
706        &self,
707        prefix: &str,
708    ) -> Result<Option<(String, CompressedContent)>> {
709        // Reject anything that isn't pure lowercase hex. The inline refs
710        // we emit are always lowercase so there's no reason to case-fold
711        // here; accidentally matching uppercase input would also match
712        // unrelated entries if someone hand-crafted a collision.
713        if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
714            return Ok(None);
715        }
716        let pattern = format!("{prefix}%");
717        let mut stmt = self
718            .db
719            .prepare("SELECT hash, data FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
720        let mut rows = stmt.query(params![pattern])?;
721
722        let first = match rows.next()? {
723            Some(r) => {
724                let hash: String = r.get(0)?;
725                let data: String = r.get(1)?;
726                (hash, data)
727            }
728            None => return Ok(None),
729        };
730
731        // Two or more hits — refuse. Prefix ambiguity is user-recoverable:
732        // rerun with a longer prefix. Matching one arbitrarily would be
733        // a silent data surprise.
734        if rows.next()?.is_some() {
735            return Err(SqzError::Other(format!(
736                "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
737            )));
738        }
739        drop(rows);
740        drop(stmt);
741
742        // Touch accessed_at for LRU tracking — symmetric with get_cache_entry.
743        let now = Utc::now().to_rfc3339();
744        let _ = self.db.execute(
745            "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
746            params![now, first.0],
747        );
748        let entry: CompressedContent = serde_json::from_str(&first.1)?;
749        Ok(Some((first.0, entry)))
750    }
751
752    /// Read the `accessed_at` timestamp for a cached hash without updating
753    /// it. Returns `None` if the hash is not cached.
754    ///
755    /// Used by the dedup freshness check: if `accessed_at` is recent, the
756    /// LLM likely still has the original content in its context window, so
757    /// returning a ref is safe. If it's old, re-send the full content.
758    pub fn get_cache_entry_accessed_at(&self, hash: &str) -> Result<Option<DateTime<Utc>>> {
759        let result: rusqlite::Result<String> = self.db.query_row(
760            "SELECT accessed_at FROM cache_entries WHERE hash = ?1",
761            params![hash],
762            |row| row.get(0),
763        );
764        match result {
765            Ok(s) => {
766                let ts = s
767                    .parse::<DateTime<Utc>>()
768                    .map_err(|e| SqzError::Other(format!("invalid accessed_at: {e}")))?;
769                Ok(Some(ts))
770            }
771            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
772            Err(e) => Err(SqzError::SessionStore(e)),
773        }
774    }
775
776    /// Check if a cache entry exists without updating `accessed_at`.
777    pub fn cache_entry_exists(&self, hash: &str) -> Result<bool> {
778        let result: rusqlite::Result<i64> = self.db.query_row(
779            "SELECT 1 FROM cache_entries WHERE hash = ?1",
780            params![hash],
781            |row| row.get(0),
782        );
783        match result {
784            Ok(_) => Ok(true),
785            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
786            Err(e) => Err(SqzError::SessionStore(e)),
787        }
788    }
789
790    /// Update `accessed_at` for a cached hash to the current time. Called by
791    /// the cache manager when a ref is served so the next staleness check
792    /// sees the recent send.
793    pub fn touch_cache_entry(&self, hash: &str) -> Result<()> {
794        let now = Utc::now().to_rfc3339();
795        self.db.execute(
796            "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
797            params![now, hash],
798        )?;
799        Ok(())
800    }
801
802    /// Resolve a hash prefix (or full hash) to the unique full hash of
803    /// the matching cache entry. Returns `Ok(None)` for non-existent
804    /// entries. Returns an error if the prefix is ambiguous (matches
805    /// more than one entry) so the caller can ask for a longer prefix.
806    /// Does not touch `accessed_at`.
807    pub fn resolve_cache_hash_by_prefix(&self, prefix: &str) -> Result<Option<String>> {
808        if prefix.is_empty()
809            || !prefix
810                .chars()
811                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
812        {
813            return Ok(None);
814        }
815        let pattern = format!("{prefix}%");
816        let mut stmt = self
817            .db
818            .prepare("SELECT hash FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
819        let mut rows = stmt.query(params![pattern])?;
820        let first: Option<String> = match rows.next()? {
821            Some(r) => Some(r.get(0)?),
822            None => None,
823        };
824        if rows.next()?.is_some() {
825            return Err(SqzError::Other(format!(
826                "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
827            )));
828        }
829        Ok(first)
830    }
831
832    /// Mark a cache entry as pinned so it bypasses the TTL freshness
833    /// check. Returns `Ok(true)` if a row was updated, `Ok(false)` if the
834    /// hash is not in the cache.
835    pub fn pin_cache_entry(&self, hash: &str) -> Result<bool> {
836        let n = self.db.execute(
837            "UPDATE cache_entries SET pinned = 1 WHERE hash = ?1",
838            params![hash],
839        )?;
840        Ok(n > 0)
841    }
842
843    /// Unpin a cache entry so it returns to normal TTL-based freshness.
844    /// Returns `Ok(true)` if a row was updated, `Ok(false)` otherwise.
845    pub fn unpin_cache_entry(&self, hash: &str) -> Result<bool> {
846        let n = self.db.execute(
847            "UPDATE cache_entries SET pinned = 0 WHERE hash = ?1",
848            params![hash],
849        )?;
850        Ok(n > 0)
851    }
852
853    /// Unpin every cache entry. Returns the count of rows that were
854    /// previously pinned.
855    pub fn unpin_all_cache_entries(&self) -> Result<usize> {
856        let n = self
857            .db
858            .execute("UPDATE cache_entries SET pinned = 0 WHERE pinned = 1", [])?;
859        Ok(n)
860    }
861
862    /// Whether the given hash is pinned. Returns `false` for unpinned
863    /// entries AND for hashes not in the cache.
864    pub fn is_cache_entry_pinned(&self, hash: &str) -> Result<bool> {
865        let result: rusqlite::Result<i64> = self.db.query_row(
866            "SELECT pinned FROM cache_entries WHERE hash = ?1",
867            params![hash],
868            |row| row.get(0),
869        );
870        match result {
871            Ok(v) => Ok(v != 0),
872            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
873            Err(e) => Err(SqzError::SessionStore(e)),
874        }
875    }
876
877    /// List every pinned cache entry. Returns `(hash, accessed_at, size_bytes)`
878    /// tuples so the CLI can render a useful table.
879    pub fn list_pinned_cache_entries(
880        &self,
881    ) -> Result<Vec<(String, DateTime<Utc>, u64)>> {
882        let mut stmt = self
883            .db
884            .prepare(
885                "SELECT hash, accessed_at, length(data) FROM cache_entries \
886                 WHERE pinned = 1 ORDER BY accessed_at DESC",
887            )
888            .map_err(SqzError::SessionStore)?;
889        let rows = stmt
890            .query_map([], |row| {
891                Ok((
892                    row.get::<_, String>(0)?,
893                    row.get::<_, String>(1)?,
894                    row.get::<_, i64>(2)? as u64,
895                ))
896            })
897            .map_err(SqzError::SessionStore)?;
898        let mut out = Vec::new();
899        for row in rows {
900            let (hash, accessed_at_str, size) = row?;
901            let accessed_at = accessed_at_str.parse::<DateTime<Utc>>().map_err(|e| {
902                SqzError::Other(format!("invalid accessed_at on pinned entry: {e}"))
903            })?;
904            out.push((hash, accessed_at, size));
905        }
906        Ok(out)
907    }
908
909    /// Set a metadata key/value. Persists across sqz process boundaries
910    /// (each shell-hook invocation is a short-lived process).
911    pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
912        self.db.execute(
913            "INSERT INTO metadata (key, value) VALUES (?1, ?2)
914             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
915            params![key, value],
916        )?;
917        Ok(())
918    }
919
920    /// Get a metadata value. Returns `None` if the key has never been set.
921    pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
922        let result: rusqlite::Result<String> = self.db.query_row(
923            "SELECT value FROM metadata WHERE key = ?1",
924            params![key],
925            |row| row.get(0),
926        );
927        match result {
928            Ok(v) => Ok(Some(v)),
929            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
930            Err(e) => Err(SqzError::SessionStore(e)),
931        }
932    }
933
934    /// Log a compression event for cumulative stats tracking.
935    pub fn log_compression(
936        &self,
937        tokens_original: u32,
938        tokens_compressed: u32,
939        stages: &[String],
940        mode: &str,
941    ) -> Result<()> {
942        self.log_compression_with_project(tokens_original, tokens_compressed, stages, mode, None)
943    }
944
945    /// Log a compression event tagged with a project directory.
946    pub fn log_compression_with_project(
947        &self,
948        tokens_original: u32,
949        tokens_compressed: u32,
950        stages: &[String],
951        mode: &str,
952        project_dir: Option<&str>,
953    ) -> Result<()> {
954        let now = Utc::now().to_rfc3339();
955        let stages_str = stages.join(",");
956        self.db.execute(
957            "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at, project_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
958            params![tokens_original, tokens_compressed, stages_str, mode, now, project_dir],
959        ).map_err(SqzError::SessionStore)?;
960        Ok(())
961    }
962
963    /// Log a compression-regret signal. `kind` is `"rerun"` (byte-identical
964    /// output re-produced within the regret window) or `"expand"` (a
965    /// `§ref:…§` token expanded back to original bytes). `gap_secs` is the
966    /// time since the content was last seen; 0.0 when unknown.
967    pub fn log_regret(&self, kind: &str, cmd: &str, hash: &str, gap_secs: f64) -> Result<()> {
968        let now = Utc::now().to_rfc3339();
969        self.db.execute(
970            "INSERT INTO regret_log (kind, cmd, hash, gap_secs, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
971            params![kind, cmd, hash, gap_secs, now],
972        ).map_err(SqzError::SessionStore)?;
973        Ok(())
974    }
975
976    /// Aggregate regret counts for `sqz stats`.
977    pub fn regret_stats(&self) -> Result<RegretStats> {
978        let mut stmt = self.db.prepare(
979            "SELECT \
980               COALESCE(SUM(CASE WHEN kind = 'rerun' THEN 1 ELSE 0 END), 0), \
981               COALESCE(SUM(CASE WHEN kind = 'expand' THEN 1 ELSE 0 END), 0), \
982               COALESCE(AVG(CASE WHEN kind = 'rerun' THEN gap_secs END), 0.0) \
983             FROM regret_log",
984        ).map_err(SqzError::SessionStore)?;
985        let stats = stmt.query_row([], |row| {
986            Ok(RegretStats {
987                reruns: row.get::<_, u32>(0)?,
988                expands: row.get::<_, u32>(1)?,
989                avg_rerun_gap_secs: row.get::<_, f64>(2)?,
990            })
991        }).map_err(SqzError::SessionStore)?;
992        Ok(stats)
993    }
994
995    /// Command families where compression is barely helping: meaningful
996    /// volume, under 25% reduction, ranked by tokens that still reached
997    /// the model. This is the formatter backlog, computed from real
998    /// history instead of guesses. Dedup rows are excluded (a ref is
999    /// already the best possible outcome).
1000    pub fn formatter_gaps(&self, days: u32, limit: u32) -> Result<Vec<CommandStats>> {
1001        let offset = format!("-{days} days");
1002        let mut stmt = self.db.prepare(
1003            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1004             FROM compression_log \
1005             WHERE created_at >= date('now', ?1) AND mode NOT IN ('dedup', 'slice') \
1006             GROUP BY mode \
1007             HAVING SUM(tokens_original) >= 500 \
1008                AND (SUM(tokens_original) - SUM(tokens_compressed)) * 100.0 \
1009                    / SUM(tokens_original) < 25.0 \
1010             ORDER BY SUM(tokens_compressed) DESC \
1011             LIMIT ?2",
1012        ).map_err(SqzError::SessionStore)?;
1013        let rows = stmt.query_map(params![offset, limit], |row| {
1014            let tokens_in: u64 = row.get(2)?;
1015            let tokens_out: u64 = row.get(3)?;
1016            Ok(CommandStats {
1017                command: row.get(0)?,
1018                invocations: row.get(1)?,
1019                tokens_in,
1020                tokens_out,
1021                tokens_saved: tokens_in.saturating_sub(tokens_out),
1022            })
1023        }).map_err(SqzError::SessionStore)?;
1024        let mut results = Vec::new();
1025        for row in rows {
1026            results.push(row.map_err(SqzError::SessionStore)?);
1027        }
1028        Ok(results)
1029    }
1030
1031    /// Rerun-regret counts grouped by command, most regretted first.
1032    pub fn regret_by_command(&self, limit: u32) -> Result<Vec<(String, u32)>> {
1033        let mut stmt = self.db.prepare(
1034            "SELECT cmd, COUNT(*) FROM regret_log \
1035             WHERE kind = 'rerun' AND cmd != '' \
1036             GROUP BY cmd ORDER BY COUNT(*) DESC LIMIT ?1",
1037        ).map_err(SqzError::SessionStore)?;
1038        let rows = stmt.query_map(params![limit], |row| {
1039            Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1040        }).map_err(SqzError::SessionStore)?;
1041        let mut results = Vec::new();
1042        for row in rows {
1043            results.push(row.map_err(SqzError::SessionStore)?);
1044        }
1045        Ok(results)
1046    }
1047
1048    /// Get cumulative compression stats from the log.
1049    pub fn compression_stats(&self) -> Result<CompressionStats> {
1050        let mut stmt = self.db.prepare(
1051            "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
1052        ).map_err(SqzError::SessionStore)?;
1053
1054        let stats = stmt.query_row([], |row| {
1055            Ok(CompressionStats {
1056                total_compressions: row.get::<_, u32>(0)?,
1057                total_tokens_in: row.get::<_, u64>(1)?,
1058                total_tokens_out: row.get::<_, u64>(2)?,
1059            })
1060        }).map_err(SqzError::SessionStore)?;
1061
1062        Ok(stats)
1063    }
1064
1065    /// Rows from the MCP read tools that record their output as larger
1066    /// than their input. Those tools serve content verbatim, so this can't
1067    /// be real: sqz-mcp before 1.8.0 counted the input with chars/4 and the
1068    /// output with the BPE tokenizer. The output count is the accurate one.
1069    /// Returns (rows, tokens by which they understate the saved total).
1070    pub fn inflated_rows(&self) -> Result<(u32, u64)> {
1071        let sql = format!(
1072            "SELECT COUNT(*), COALESCE(SUM(tokens_compressed - tokens_original), 0) \
1073             FROM compression_log WHERE {INFLATED_ROW_FILTER}"
1074        );
1075        let (count, inflation): (u32, i64) = self
1076            .db
1077            .query_row(&sql, [], |row| Ok((row.get(0)?, row.get(1)?)))
1078            .map_err(SqzError::SessionStore)?;
1079        Ok((count, inflation.max(0) as u64))
1080    }
1081
1082    /// Write a consistent copy of the database to `dest`. Unlike copying the
1083    /// file, this includes transactions still sitting in the WAL. Fails if
1084    /// `dest` already exists.
1085    pub fn backup_to(&self, dest: &Path) -> Result<()> {
1086        self.db
1087            .execute("VACUUM INTO ?1", params![dest.to_string_lossy()])
1088            .map_err(SqzError::SessionStore)?;
1089        Ok(())
1090    }
1091
1092    /// Correct the rows [`inflated_rows`](Self::inflated_rows) reports, in
1093    /// place: raise the input count to the output count. The call keeps its
1094    /// real volume and is recorded as saving nothing, which is what a
1095    /// verbatim read saves.
1096    /// Returns the number of rows changed.
1097    pub fn clamp_inflated_rows(&self) -> Result<usize> {
1098        let sql = format!(
1099            "UPDATE compression_log SET tokens_original = tokens_compressed \
1100             WHERE {INFLATED_ROW_FILTER}"
1101        );
1102        self.db.execute(&sql, []).map_err(SqzError::SessionStore)
1103    }
1104
1105    /// Get daily compression gains for the last N days.
1106    pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
1107        let mut stmt = self.db.prepare(
1108            "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1109             FROM compression_log \
1110             WHERE created_at >= date('now', ?1) \
1111             GROUP BY d ORDER BY d",
1112        ).map_err(SqzError::SessionStore)?;
1113
1114        let offset = format!("-{days} days");
1115        let rows = stmt.query_map(params![offset], |row| {
1116            let tokens_in: u64 = row.get(2)?;
1117            let tokens_out: u64 = row.get(3)?;
1118            Ok(DailyGain {
1119                date: row.get(0)?,
1120                compressions: row.get(1)?,
1121                tokens_in,
1122                tokens_saved: tokens_in.saturating_sub(tokens_out),
1123            })
1124        }).map_err(SqzError::SessionStore)?;
1125
1126        let mut gains = Vec::new();
1127        for row in rows {
1128            gains.push(row.map_err(SqzError::SessionStore)?);
1129        }
1130        Ok(gains)
1131    }
1132
1133    /// Get cumulative compression stats for a specific project directory.
1134    pub fn compression_stats_for_project(&self, project_dir: &str) -> Result<CompressionStats> {
1135        let mut stmt = self.db.prepare(
1136            "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) \
1137             FROM compression_log WHERE project_dir = ?1",
1138        ).map_err(SqzError::SessionStore)?;
1139
1140        let stats = stmt.query_row(params![project_dir], |row| {
1141            Ok(CompressionStats {
1142                total_compressions: row.get::<_, u32>(0)?,
1143                total_tokens_in: row.get::<_, u64>(1)?,
1144                total_tokens_out: row.get::<_, u64>(2)?,
1145            })
1146        }).map_err(SqzError::SessionStore)?;
1147
1148        Ok(stats)
1149    }
1150
1151    /// Get daily compression gains for a specific project directory.
1152    pub fn daily_gains_for_project(&self, days: u32, project_dir: &str) -> Result<Vec<DailyGain>> {
1153        let mut stmt = self.db.prepare(
1154            "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1155             FROM compression_log \
1156             WHERE created_at >= date('now', ?1) AND project_dir = ?2 \
1157             GROUP BY d ORDER BY d",
1158        ).map_err(SqzError::SessionStore)?;
1159
1160        let offset = format!("-{days} days");
1161        let rows = stmt.query_map(params![offset, project_dir], |row| {
1162            let tokens_in: u64 = row.get(2)?;
1163            let tokens_out: u64 = row.get(3)?;
1164            Ok(DailyGain {
1165                date: row.get(0)?,
1166                compressions: row.get(1)?,
1167                tokens_in,
1168                tokens_saved: tokens_in.saturating_sub(tokens_out),
1169            })
1170        }).map_err(SqzError::SessionStore)?;
1171
1172        let mut gains = Vec::new();
1173        for row in rows {
1174            gains.push(row.map_err(SqzError::SessionStore)?);
1175        }
1176        Ok(gains)
1177    }
1178
1179    /// List distinct project directories that have compression data.
1180    pub fn list_projects(&self) -> Result<Vec<(String, u32, u64)>> {
1181        let mut stmt = self.db.prepare(
1182            "SELECT project_dir, COUNT(*), COALESCE(SUM(tokens_original) - SUM(tokens_compressed), 0) \
1183             FROM compression_log \
1184             WHERE project_dir IS NOT NULL \
1185             GROUP BY project_dir \
1186             ORDER BY COUNT(*) DESC",
1187        ).map_err(SqzError::SessionStore)?;
1188
1189        let rows = stmt.query_map([], |row| {
1190            Ok((
1191                row.get::<_, String>(0)?,
1192                row.get::<_, u32>(1)?,
1193                row.get::<_, u64>(2)?,
1194            ))
1195        }).map_err(SqzError::SessionStore)?;
1196
1197        let mut projects = Vec::new();
1198        for row in rows {
1199            projects.push(row.map_err(SqzError::SessionStore)?);
1200        }
1201        Ok(projects)
1202    }
1203
1204    /// Per-command breakdown of token usage. Returns top N commands by
1205    /// total tokens consumed, with compression stats for each.
1206    /// Used by `sqz stats --breakdown` to show where tokens are going.
1207    pub fn command_breakdown(&self, limit: u32) -> Result<Vec<CommandStats>> {
1208        let mut stmt = self.db.prepare(
1209            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1210             FROM compression_log \
1211             GROUP BY mode \
1212             ORDER BY SUM(tokens_original) DESC \
1213             LIMIT ?1",
1214        ).map_err(SqzError::SessionStore)?;
1215
1216        let rows = stmt.query_map(params![limit], |row| {
1217            let tokens_in: u64 = row.get(2)?;
1218            let tokens_out: u64 = row.get(3)?;
1219            Ok(CommandStats {
1220                command: row.get(0)?,
1221                invocations: row.get(1)?,
1222                tokens_in,
1223                tokens_out,
1224                tokens_saved: tokens_in.saturating_sub(tokens_out),
1225            })
1226        }).map_err(SqzError::SessionStore)?;
1227
1228        let mut results = Vec::new();
1229        for row in rows {
1230            results.push(row.map_err(SqzError::SessionStore)?);
1231        }
1232        Ok(results)
1233    }
1234
1235    /// Per-command breakdown filtered to a specific project.
1236    pub fn command_breakdown_for_project(&self, limit: u32, project_dir: &str) -> Result<Vec<CommandStats>> {
1237        let mut stmt = self.db.prepare(
1238            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1239             FROM compression_log \
1240             WHERE project_dir = ?1 \
1241             GROUP BY mode \
1242             ORDER BY SUM(tokens_original) DESC \
1243             LIMIT ?2",
1244        ).map_err(SqzError::SessionStore)?;
1245
1246        let rows = stmt.query_map(params![project_dir, limit], |row| {
1247            let tokens_in: u64 = row.get(2)?;
1248            let tokens_out: u64 = row.get(3)?;
1249            Ok(CommandStats {
1250                command: row.get(0)?,
1251                invocations: row.get(1)?,
1252                tokens_in,
1253                tokens_out,
1254                tokens_saved: tokens_in.saturating_sub(tokens_out),
1255            })
1256        }).map_err(SqzError::SessionStore)?;
1257
1258        let mut results = Vec::new();
1259        for row in rows {
1260            results.push(row.map_err(SqzError::SessionStore)?);
1261        }
1262        Ok(results)
1263    }
1264
1265    /// Get the total tokens injected in the current session (since last
1266    /// compaction or start). Used by the adaptive pressure system to
1267    /// decide whether to escalate compression intensity.
1268    pub fn session_pressure(&self, since_minutes: u32) -> Result<u64> {
1269        let mut stmt = self.db.prepare(
1270            "SELECT COALESCE(SUM(tokens_compressed), 0) \
1271             FROM compression_log \
1272             WHERE created_at >= datetime('now', ?1)",
1273        ).map_err(SqzError::SessionStore)?;
1274
1275        let offset = format!("-{since_minutes} minutes");
1276        let total: u64 = stmt.query_row(params![offset], |row| {
1277            row.get(0)
1278        }).map_err(SqzError::SessionStore)?;
1279
1280        Ok(total)
1281    }
1282
1283    // ── Known files (persistent cross-command context tracking) ───────────
1284
1285    /// Record a file path as "known" (its content is in the dedup cache).
1286    /// Used by cross-command context refs to annotate error messages.
1287    pub fn add_known_file(&self, path: &str) -> Result<()> {
1288        let now = Utc::now().to_rfc3339();
1289        self.db.execute(
1290            "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
1291            params![path, now],
1292        ).map_err(SqzError::SessionStore)?;
1293        Ok(())
1294    }
1295
1296    /// Load all known file paths from the persistent store.
1297    pub fn known_files(&self) -> Result<Vec<String>> {
1298        let mut stmt = self.db.prepare(
1299            "SELECT path FROM known_files ORDER BY added_at DESC",
1300        ).map_err(SqzError::SessionStore)?;
1301
1302        let rows = stmt.query_map([], |row| {
1303            row.get::<_, String>(0)
1304        }).map_err(SqzError::SessionStore)?;
1305
1306        let mut files = Vec::new();
1307        for row in rows {
1308            files.push(row.map_err(SqzError::SessionStore)?);
1309        }
1310        Ok(files)
1311    }
1312    /// Clear all known files (e.g. on session reset).
1313    pub fn clear_known_files(&self) -> Result<()> {
1314        self.db
1315            .execute("DELETE FROM known_files", [])
1316            .map_err(SqzError::SessionStore)?;
1317        Ok(())
1318    }
1319
1320    /// Number of entries currently in the dedup cache.
1321    pub fn cache_entry_count(&self) -> Result<u64> {
1322        self.db.query_row(
1323            "SELECT COUNT(*) FROM cache_entries", [],
1324            |row| row.get(0),
1325        ).map_err(SqzError::SessionStore)
1326    }
1327
1328    /// Increment the cache-hit counter (persisted in metadata).
1329    pub fn record_cache_hit(&self) -> Result<()> {
1330        let count: u64 = self.db.query_row(
1331            "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1332            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1333        ).unwrap_or(0);
1334        let new = count + 1;
1335        self.db.execute(
1336            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_hits', ?1)",
1337            params![new.to_string()],
1338        )?;
1339        Ok(())
1340    }
1341
1342    /// Increment the cache-miss counter (persisted in metadata).
1343    pub fn record_cache_miss(&self) -> Result<()> {
1344        let count: u64 = self.db.query_row(
1345            "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1346            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1347        ).unwrap_or(0);
1348        let new = count + 1;
1349        self.db.execute(
1350            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_misses', ?1)",
1351            params![new.to_string()],
1352        )?;
1353        Ok(())
1354    }
1355
1356    /// Return (cache_hits, cache_misses) from metadata counters.
1357    pub fn cache_hit_miss_counts(&self) -> Result<(u64, u64)> {
1358        let hits: u64 = self.db.query_row(
1359            "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1360            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1361        ).unwrap_or(0);
1362        let misses: u64 = self.db.query_row(
1363            "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1364            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1365        ).unwrap_or(0);
1366        Ok((hits, misses))
1367    }
1368
1369    /// Clear the dedup cache. After this, all future reads will be treated
1370    /// as cache misses and compressed fresh. Does NOT clear compression
1371    /// stats or session history.
1372    pub fn clear_cache(&self) -> Result<u64> {
1373        let count: u64 = self.db.query_row(
1374            "SELECT COUNT(*) FROM cache_entries", [],
1375            |row| row.get(0),
1376        ).unwrap_or(0);
1377        self.db.execute("DELETE FROM cache_entries", [])
1378            .map_err(SqzError::SessionStore)?;
1379        let _ = self.db.execute(
1380            "DELETE FROM recall_index WHERE kind = 'output'", [],
1381        );
1382        // Also clear the compaction marker so freshness checks don't
1383        // reference timestamps from the deleted entries.
1384        let _ = self.db.execute(
1385            "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1386        );
1387        Ok(count)
1388    }
1389
1390    /// Clear compression stats (the `compression_log` table). After this,
1391    /// `sqz stats` and `sqz gain` will show zero until new compressions
1392    /// happen.
1393    pub fn clear_stats(&self) -> Result<u64> {
1394        let count: u64 = self.db.query_row(
1395            "SELECT COUNT(*) FROM compression_log", [],
1396            |row| row.get(0),
1397        ).unwrap_or(0);
1398        self.db.execute("DELETE FROM compression_log", [])
1399            .map_err(SqzError::SessionStore)?;
1400        Ok(count)
1401    }
1402
1403    /// Clear compression stats for a specific project directory only.
1404    pub fn clear_stats_for_project(&self, project_dir: &str) -> Result<u64> {
1405        let count: u64 = self.db.query_row(
1406            "SELECT COUNT(*) FROM compression_log WHERE project_dir = ?1",
1407            rusqlite::params![project_dir],
1408            |row| row.get(0),
1409        ).unwrap_or(0);
1410        self.db.execute(
1411            "DELETE FROM compression_log WHERE project_dir = ?1",
1412            rusqlite::params![project_dir],
1413        ).map_err(SqzError::SessionStore)?;
1414        Ok(count)
1415    }
1416
1417    /// Full reset: clear cache, stats, sessions, known files, and metadata.
1418    /// Equivalent to deleting sessions.db and letting sqz recreate it.
1419    pub fn reset_all(&self) -> Result<()> {
1420        self.db.execute("DELETE FROM cache_entries", []).map_err(SqzError::SessionStore)?;
1421        self.db.execute("DELETE FROM compression_log", []).map_err(SqzError::SessionStore)?;
1422        self.db.execute("DELETE FROM sessions", []).map_err(SqzError::SessionStore)?;
1423        self.db.execute("DELETE FROM known_files", []).map_err(SqzError::SessionStore)?;
1424        self.db.execute("DELETE FROM metadata", []).map_err(SqzError::SessionStore)?;
1425        let _ = self.db.execute("DELETE FROM recall_index", []);
1426        // VACUUM to reclaim disk space.
1427        let _ = self.db.execute("VACUUM", []);
1428        Ok(())
1429    }
1430
1431    /// Return all sessions ordered by most recently updated, limited to `limit`.
1432    pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionSummary>> {
1433        let mut stmt = self
1434            .db
1435            .prepare(
1436                "SELECT id, project_dir, compressed_summary, created_at, updated_at \
1437             FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1438            )
1439            .map_err(SqzError::SessionStore)?;
1440
1441        let rows = stmt
1442            .query_map(params![limit], |row| {
1443                Ok((
1444                    row.get::<_, String>(0)?,
1445                    row.get::<_, String>(1)?,
1446                    row.get::<_, String>(2)?,
1447                    row.get::<_, String>(3)?,
1448                    row.get::<_, String>(4)?,
1449                ))
1450            })
1451            .map_err(SqzError::SessionStore)?;
1452
1453        let mut results = Vec::new();
1454        for row in rows {
1455            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
1456            results.push(row_to_summary(
1457                id,
1458                project_dir,
1459                compressed_summary,
1460                created_at,
1461                updated_at,
1462            )?);
1463        }
1464        Ok(results)
1465    }
1466
1467    /// Per-tool breakdown: group compression_log by stages_applied (comma-split).
1468    pub fn per_tool_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1469        let mut stmt = self
1470            .db
1471            .prepare(
1472                "SELECT stages_applied, SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1473             FROM compression_log GROUP BY stages_applied ORDER BY COUNT(*) DESC LIMIT 20",
1474            )
1475            .map_err(SqzError::SessionStore)?;
1476
1477        let rows = stmt
1478            .query_map([], |row| {
1479                Ok((
1480                    row.get::<_, String>(0)?,
1481                    row.get::<_, u64>(1)?,
1482                    row.get::<_, u64>(2)?,
1483                    row.get::<_, u32>(3)?,
1484                ))
1485            })
1486            .map_err(SqzError::SessionStore)?;
1487
1488        let mut tool_map: std::collections::HashMap<String, (u64, u64, u32)> =
1489            std::collections::HashMap::new();
1490        for row in rows {
1491            let (stages, tokens_in, tokens_out, count) = row?;
1492            for stage in stages.split(',') {
1493                let stage = stage.trim();
1494                if stage.is_empty() {
1495                    continue;
1496                }
1497                let entry = tool_map.entry(stage.to_string()).or_default();
1498                entry.0 += tokens_in;
1499                entry.1 += tokens_out;
1500                entry.2 += count;
1501            }
1502        }
1503
1504        let mut result: Vec<_> = tool_map.into_iter().collect();
1505        result.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
1506        Ok(result
1507            .into_iter()
1508            .map(|(name, (tin, tout, cnt))| (name, tin, tout, cnt))
1509            .collect())
1510    }
1511
1512    /// Per-command breakdown: group compression_log by mode.
1513    pub fn per_command_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1514        let mut stmt = self
1515            .db
1516            .prepare(
1517                "SELECT COALESCE(mode, 'auto'), SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1518             FROM compression_log GROUP BY mode ORDER BY COUNT(*) DESC LIMIT 20",
1519            )
1520            .map_err(SqzError::SessionStore)?;
1521
1522        let rows = stmt
1523            .query_map([], |row| {
1524                Ok((
1525                    row.get::<_, String>(0)?,
1526                    row.get::<_, u64>(1)?,
1527                    row.get::<_, u64>(2)?,
1528                    row.get::<_, u32>(3)?,
1529                ))
1530            })
1531            .map_err(SqzError::SessionStore)?;
1532
1533        let mut result = Vec::new();
1534        for row in rows {
1535            result.push(row?);
1536        }
1537        Ok(result)
1538    }
1539}
1540
1541/// Cumulative compression statistics.
1542#[derive(Debug, Clone, Default)]
1543pub struct CompressionStats {
1544    pub total_compressions: u32,
1545    pub total_tokens_in: u64,
1546    pub total_tokens_out: u64,
1547}
1548
1549impl CompressionStats {
1550    pub fn tokens_saved(&self) -> u64 {
1551        self.total_tokens_in.saturating_sub(self.total_tokens_out)
1552    }
1553
1554    /// True when the logged output exceeds the logged input in aggregate.
1555    /// `tokens_saved` saturates to 0 in that case, which reads as "sqz did
1556    /// nothing" when the real story is "the log has bad rows".
1557    pub fn is_net_negative(&self) -> bool {
1558        self.total_tokens_out > self.total_tokens_in
1559    }
1560
1561    pub fn reduction_pct(&self) -> f64 {
1562        if self.total_tokens_in == 0 {
1563            0.0
1564        } else {
1565            (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
1566        }
1567    }
1568}
1569
1570/// A single day's compression gain.
1571#[derive(Debug, Clone)]
1572pub struct DailyGain {
1573    pub date: String,
1574    pub compressions: u32,
1575    pub tokens_saved: u64,
1576    pub tokens_in: u64,
1577}
1578
1579/// Per-command token usage breakdown.
1580#[derive(Debug, Clone)]
1581pub struct CommandStats {
1582    pub command: String,
1583    pub invocations: u32,
1584    pub tokens_in: u64,
1585    pub tokens_out: u64,
1586    pub tokens_saved: u64,
1587}
1588
1589/// A cache entry with its original bytes and the served form (JSON
1590/// `CompressedContent`), as returned by [`SessionStore::recent_originals`].
1591#[derive(Debug, Clone)]
1592pub struct SliceCandidate {
1593    pub hash: String,
1594    pub original: Vec<u8>,
1595    pub data_json: String,
1596}
1597
1598/// One `sqz recall` search hit.
1599#[derive(Debug, Clone)]
1600pub struct RecallHit {
1601    /// Content hash (kind `output`, expandable via `sqz expand`) or
1602    /// session id (kind `session`).
1603    pub ref_hash: String,
1604    pub kind: String,
1605    /// Match context with `>>`/`<<` around matched terms.
1606    pub snippet: String,
1607    pub created_at: String,
1608}
1609
1610/// Build a safe FTS5 MATCH expression from free-form user input: each
1611/// whitespace-separated term becomes a quoted phrase (implicit AND), so
1612/// FTS5 operator characters in the input can't break the query syntax.
1613fn fts_query(raw: &str) -> String {
1614    raw.split_whitespace()
1615        .filter_map(|term| {
1616            let cleaned = term.replace('"', "");
1617            if cleaned.is_empty() {
1618                None
1619            } else {
1620                Some(format!("\"{cleaned}\""))
1621            }
1622        })
1623        .collect::<Vec<_>>()
1624        .join(" ")
1625}
1626
1627/// Aggregated compression-regret signals (see `regret_log`).
1628#[derive(Debug, Clone, Default)]
1629pub struct RegretStats {
1630    /// Byte-identical output re-produced within the regret window.
1631    pub reruns: u32,
1632    /// `§ref:…§` tokens expanded back to original bytes.
1633    pub expands: u32,
1634    /// Mean seconds between last sighting and the rerun.
1635    pub avg_rerun_gap_secs: f64,
1636}
1637
1638impl CommandStats {
1639    pub fn reduction_pct(&self) -> f64 {
1640        if self.tokens_in == 0 {
1641            0.0
1642        } else {
1643            (1.0 - self.tokens_out as f64 / self.tokens_in as f64) * 100.0
1644        }
1645    }
1646}
1647
1648// ── Tests ─────────────────────────────────────────────────────────────────────
1649
1650#[cfg(test)]
1651mod tests {
1652    use super::*;
1653    use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
1654    use chrono::Utc;
1655    use proptest::prelude::*;
1656    use std::path::PathBuf;
1657
1658    fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
1659        let now = Utc::now();
1660        SessionState {
1661            id: id.to_string(),
1662            project_dir: PathBuf::from(project_dir),
1663            conversation: vec![],
1664            corrections: CorrectionLog::default(),
1665            pins: vec![],
1666            learnings: vec![],
1667            compressed_summary: summary.to_string(),
1668            budget: BudgetState {
1669                window_size: 200_000,
1670                consumed: 0,
1671                pinned: 0,
1672                model_family: ModelFamily::AnthropicClaude,
1673            },
1674            tool_usage: vec![],
1675            created_at: now,
1676            updated_at: now,
1677        }
1678    }
1679
1680    fn in_memory_store() -> SessionStore {
1681        let conn = Connection::open_in_memory().unwrap();
1682        apply_schema(&conn).unwrap();
1683        SessionStore { db: conn }
1684    }
1685
1686    fn test_content(data: &str) -> CompressedContent {
1687        CompressedContent {
1688            data: data.to_string(),
1689            tokens_compressed: 1,
1690            tokens_original: 2,
1691            stages_applied: vec![],
1692            compression_ratio: 0.5,
1693            provenance: crate::types::Provenance::default(),
1694            verify: None,
1695        }
1696    }
1697
1698    #[test]
1699    fn backup_to_captures_wal_contents() {
1700        let dir = tempfile::tempdir().unwrap();
1701        let db = dir.path().join("live.db");
1702        let store = SessionStore::open_or_create(&db).unwrap();
1703        store.log_compression(900, 100, &[], "auto").unwrap();
1704        // The row may still be in the WAL; a plain file copy can miss it.
1705        let backup = dir.path().join("live.db.bak");
1706        store.backup_to(&backup).unwrap();
1707        let restored = SessionStore::open_or_create(&backup).unwrap();
1708        assert_eq!(restored.compression_stats().unwrap().total_tokens_in, 900);
1709        assert!(store.backup_to(&backup).is_err(), "never overwrites an existing file");
1710    }
1711
1712    #[test]
1713    fn inflated_rows_are_clamped_not_deleted() {
1714        let store = in_memory_store();
1715        // Normal compression.
1716        store.log_compression(1000, 400, &[], "auto").unwrap();
1717        // A 13-token ref replacing a 5-token output really is larger, and
1718        // comes from a mode that compresses, so it stays as recorded.
1719        store.log_compression(5, 13, &[], "dedup").unwrap();
1720        store.log_compression(300, 340, &[], "auto").unwrap();
1721        // The pre-1.8 MCP read-tool pattern: verbatim output logged larger.
1722        store.log_compression(200, 230, &[], "sqz_read_file").unwrap();
1723        store.log_compression(655_870, 1_091_993, &[], "sqz_grep").unwrap();
1724        store.log_compression(38_382, 60_919, &[], "sqz_grep").unwrap();
1725        // A verbatim read logged correctly is left alone.
1726        store.log_compression(500, 500, &[], "sqz_list_dir").unwrap();
1727
1728        let (count, inflation) = store.inflated_rows().unwrap();
1729        assert_eq!(count, 3);
1730        assert_eq!(inflation, 30 + (1_091_993 - 655_870) + (60_919 - 38_382));
1731
1732        let stats = store.compression_stats().unwrap();
1733        assert!(stats.is_net_negative(), "garbage rows push the total negative");
1734        assert_eq!(stats.tokens_saved(), 0, "saturating_sub hides it as zero");
1735
1736        assert_eq!(store.clamp_inflated_rows().unwrap(), 3);
1737        assert_eq!(store.inflated_rows().unwrap(), (0, 0));
1738        assert_eq!(store.clamp_inflated_rows().unwrap(), 0, "idempotent");
1739
1740        let stats = store.compression_stats().unwrap();
1741        assert!(!stats.is_net_negative());
1742        assert_eq!(stats.total_compressions, 7, "no row is deleted");
1743        // The real volume of the big grep stays in the totals.
1744        assert_eq!(
1745            stats.total_tokens_in,
1746            1000 + 5 + 300 + 230 + 1_091_993 + 60_919 + 500
1747        );
1748        assert_eq!(stats.tokens_saved(), (1000 - 400) + (300 + 5) - (340 + 13));
1749    }
1750
1751    #[test]
1752    fn recall_indexes_cache_originals_and_searches() {
1753        let store = in_memory_store();
1754        let content = test_content("compressed");
1755        store
1756            .save_cache_entry_with_original(
1757                "hash-auth",
1758                &content,
1759                Some(b"error: auth middleware rejected the session token"),
1760            )
1761            .unwrap();
1762        store
1763            .save_cache_entry_with_original(
1764                "hash-build",
1765                &content,
1766                Some(b"cargo build finished in 32s with zero warnings"),
1767            )
1768            .unwrap();
1769
1770        let hits = store.recall_search("auth middleware", 5).unwrap();
1771        assert_eq!(hits.len(), 1);
1772        assert_eq!(hits[0].ref_hash, "hash-auth");
1773        assert_eq!(hits[0].kind, "output");
1774        assert!(hits[0].snippet.contains(">>auth<<"), "snippet: {}", hits[0].snippet);
1775
1776        // Re-saving the same hash must not duplicate index rows.
1777        store
1778            .save_cache_entry_with_original(
1779                "hash-auth",
1780                &content,
1781                Some(b"error: auth middleware rejected the session token"),
1782            )
1783            .unwrap();
1784        assert_eq!(store.recall_search("middleware", 5).unwrap().len(), 1);
1785    }
1786
1787    #[test]
1788    fn recall_skips_binary_and_survives_odd_queries() {
1789        let store = in_memory_store();
1790        let content = test_content("c");
1791        store
1792            .save_cache_entry_with_original("hash-bin", &content, Some(b"binary\x00payload"))
1793            .unwrap();
1794        assert!(store.recall_search("binary", 5).unwrap().is_empty());
1795
1796        // FTS5 operator characters in the query must not error.
1797        for q in ["NOT AND OR", "\"unbalanced", "col:umn", "wild*", "(paren", "-", ""] {
1798            let _ = store.recall_search(q, 5).unwrap();
1799        }
1800    }
1801
1802    #[test]
1803    fn recall_deletion_and_clear_coherence() {
1804        let store = in_memory_store();
1805        let content = test_content("c");
1806        store
1807            .save_cache_entry_with_original("hash-x", &content, Some(b"kubectl rollout restarted deployment"))
1808            .unwrap();
1809        assert_eq!(store.recall_search("rollout", 5).unwrap().len(), 1);
1810
1811        store.delete_cache_entry("hash-x").unwrap();
1812        assert!(store.recall_search("rollout", 5).unwrap().is_empty());
1813
1814        // clear_cache drops output entries but keeps session summaries.
1815        store
1816            .save_cache_entry_with_original("hash-y", &content, Some(b"terraform plan shows three changes"))
1817            .unwrap();
1818        let session = make_session("sess-recall", "/tmp/p", "refactored the billing retry loop");
1819        store.save_session(&session).unwrap();
1820        store.clear_cache().unwrap();
1821        assert!(store.recall_search("terraform", 5).unwrap().is_empty());
1822        let session_hits = store.recall_search("billing retry", 5).unwrap();
1823        assert_eq!(session_hits.len(), 1);
1824        assert_eq!(session_hits[0].kind, "session");
1825        assert_eq!(session_hits[0].ref_hash, "sess-recall");
1826
1827        store.reset_all().unwrap();
1828        assert!(store.recall_search("billing", 5).unwrap().is_empty());
1829    }
1830
1831    #[test]
1832    fn recall_ranks_better_matches_first() {
1833        let store = in_memory_store();
1834        let content = test_content("c");
1835        store
1836            .save_cache_entry_with_original(
1837                "hash-dense",
1838                &content,
1839                Some(b"panic unwrap panic unwrap panic in worker thread"),
1840            )
1841            .unwrap();
1842        store
1843            .save_cache_entry_with_original(
1844                "hash-sparse",
1845                &content,
1846                Some(format!("{} panic once at the end", "filler line of ordinary words\n".repeat(50)).as_bytes()),
1847            )
1848            .unwrap();
1849        let hits = store.recall_search("panic", 5).unwrap();
1850        assert_eq!(hits.len(), 2);
1851        assert_eq!(hits[0].ref_hash, "hash-dense", "bm25 should rank the denser match first");
1852    }
1853
1854    #[test]
1855    fn formatter_gaps_ranks_low_reduction_high_volume() {
1856        let store = in_memory_store();
1857        // High volume, terrible reduction: a gap.
1858        for _ in 0..4 {
1859            store.log_compression(1_000, 950, &[], "xcodebuild").unwrap();
1860        }
1861        // High volume, great reduction: not a gap.
1862        for _ in 0..4 {
1863            store.log_compression(1_000, 100, &[], "cargo test").unwrap();
1864        }
1865        // Low volume, bad reduction: filtered by the 500-token floor.
1866        store.log_compression(100, 95, &[], "tiny-cmd").unwrap();
1867        // Dedup rows never count as gaps.
1868        store.log_compression(5_000, 13, &["dedup".to_string()], "dedup").unwrap();
1869
1870        let gaps = store.formatter_gaps(7, 10).unwrap();
1871        assert_eq!(gaps.len(), 1, "{gaps:?}");
1872        assert_eq!(gaps[0].command, "xcodebuild");
1873        assert_eq!(gaps[0].invocations, 4);
1874        assert!(gaps[0].reduction_pct() < 25.0);
1875    }
1876
1877    #[test]
1878    fn regret_log_roundtrip() {
1879        let store = in_memory_store();
1880        // Empty DB: zero everything.
1881        let empty = store.regret_stats().unwrap();
1882        assert_eq!(empty.reruns, 0);
1883        assert_eq!(empty.expands, 0);
1884        assert_eq!(empty.avg_rerun_gap_secs, 0.0);
1885
1886        store.log_regret("rerun", "git log", "aaaa", 10.0).unwrap();
1887        store.log_regret("rerun", "git log", "bbbb", 30.0).unwrap();
1888        store.log_regret("rerun", "cargo test", "cccc", 20.0).unwrap();
1889        store.log_regret("expand", "", "dddd", 0.0).unwrap();
1890
1891        let stats = store.regret_stats().unwrap();
1892        assert_eq!(stats.reruns, 3);
1893        assert_eq!(stats.expands, 1);
1894        assert!((stats.avg_rerun_gap_secs - 20.0).abs() < 1e-9);
1895
1896        let top = store.regret_by_command(10).unwrap();
1897        assert_eq!(top[0], ("git log".to_string(), 2));
1898        assert_eq!(top[1], ("cargo test".to_string(), 1));
1899        // Expand rows carry an empty cmd and must not appear.
1900        assert_eq!(top.len(), 2);
1901    }
1902
1903    #[test]
1904    fn test_save_and_load_session() {
1905        let store = in_memory_store();
1906        let session = make_session("sess-1", "/home/user/project", "REST API refactor");
1907
1908        let id = store.save_session(&session).unwrap();
1909        assert_eq!(id, "sess-1");
1910
1911        let loaded = store.load_session("sess-1".to_string()).unwrap();
1912        assert_eq!(loaded.id, session.id);
1913        assert_eq!(loaded.compressed_summary, session.compressed_summary);
1914        assert_eq!(loaded.project_dir, session.project_dir);
1915    }
1916
1917    #[test]
1918    fn test_save_session_upsert() {
1919        let store = in_memory_store();
1920        let mut session = make_session("sess-2", "/proj", "initial summary");
1921        store.save_session(&session).unwrap();
1922
1923        session.compressed_summary = "updated summary".to_string();
1924        store.save_session(&session).unwrap();
1925
1926        let loaded = store.load_session("sess-2".to_string()).unwrap();
1927        assert_eq!(loaded.compressed_summary, "updated summary");
1928    }
1929
1930    #[test]
1931    fn test_load_nonexistent_session_errors() {
1932        let store = in_memory_store();
1933        let result = store.load_session("does-not-exist".to_string());
1934        assert!(result.is_err());
1935    }
1936
1937    #[test]
1938    fn test_search_fts() {
1939        let store = in_memory_store();
1940        store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
1941        store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
1942
1943        let results = store.search("authentication").unwrap();
1944        assert_eq!(results.len(), 1);
1945        assert_eq!(results[0].id, "s1");
1946    }
1947
1948    #[test]
1949    fn test_search_by_date() {
1950        let store = in_memory_store();
1951        let now = Utc::now();
1952        let past = now - chrono::Duration::hours(2);
1953        let future = now + chrono::Duration::hours(2);
1954
1955        store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
1956
1957        let results = store.search_by_date(past, future).unwrap();
1958        assert!(!results.is_empty());
1959        assert!(results.iter().any(|r| r.id == "s1"));
1960    }
1961
1962    #[test]
1963    fn test_search_by_project() {
1964        let store = in_memory_store();
1965        store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
1966        store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
1967
1968        let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
1969        assert_eq!(results.len(), 1);
1970        assert_eq!(results[0].id, "s1");
1971    }
1972
1973    #[test]
1974    fn test_cache_entry_round_trip() {
1975        let store = in_memory_store();
1976        let entry = CompressedContent {
1977            data: "compressed data".to_string(),
1978            tokens_compressed: 10,
1979            tokens_original: 50,
1980            stages_applied: vec!["strip_nulls".to_string()],
1981            compression_ratio: 0.2,
1982            provenance: crate::types::Provenance::default(),
1983            verify: None,
1984        };
1985
1986        store.save_cache_entry("abc123", &entry).unwrap();
1987
1988        let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
1989        assert_eq!(loaded.data, entry.data);
1990        assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
1991        assert_eq!(loaded.tokens_original, entry.tokens_original);
1992    }
1993
1994    #[test]
1995    fn test_get_cache_entry_missing_returns_none() {
1996        let store = in_memory_store();
1997        let result = store.get_cache_entry("nonexistent").unwrap();
1998        assert!(result.is_none());
1999    }
2000
2001    #[test]
2002    fn test_open_or_create_corrupted_db() {
2003        let dir = tempfile::tempdir().unwrap();
2004        let path = dir.path().join("store.db");
2005
2006        // Write garbage bytes to simulate a corrupted database.
2007        std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
2008
2009        // Should succeed by creating a fresh database.
2010        let store = SessionStore::open_or_create(&path).unwrap();
2011        let session = make_session("s1", "/proj", "after corruption");
2012        store.save_session(&session).unwrap();
2013        let loaded = store.load_session("s1".to_string()).unwrap();
2014        assert_eq!(loaded.id, "s1");
2015    }
2016
2017    // ── Property-based tests ──────────────────────────────────────────────────
2018
2019    /// Build a `SessionState` with a specific `updated_at` timestamp.
2020    fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
2021        let now = Utc::now();
2022        SessionState {
2023            id: id.to_string(),
2024            project_dir: PathBuf::from("/proj"),
2025            conversation: vec![],
2026            corrections: CorrectionLog::default(),
2027            pins: vec![],
2028            learnings: vec![],
2029            compressed_summary: summary.to_string(),
2030            budget: BudgetState {
2031                window_size: 200_000,
2032                consumed: 0,
2033                pinned: 0,
2034                model_family: ModelFamily::AnthropicClaude,
2035            },
2036            tool_usage: vec![],
2037            created_at: now,
2038            updated_at,
2039        }
2040    }
2041
2042    // ── Property 26: Session store search correctness ─────────────────────────
2043    // **Validates: Requirements 20.2, 20.3, 20.4**
2044    //
2045    // For any set of sessions saved to the store, a keyword search SHALL return
2046    // all sessions whose compressed_summary contains the keyword, and no
2047    // sessions that don't contain it.
2048
2049    proptest! {
2050        /// **Validates: Requirements 20.2, 20.3, 20.4**
2051        ///
2052        /// For any set of sessions saved to the store, a keyword search SHALL
2053        /// return all sessions whose `compressed_summary` contains the keyword,
2054        /// and no sessions that don't contain it.
2055        #[test]
2056        fn prop_search_correctness(
2057            // A simple ASCII keyword: 5-8 lowercase letters, no common English
2058            // words that the porter stemmer might conflate with other terms.
2059            keyword in "[b-df-hj-np-tv-z]{5,8}",
2060            // 1-6 summaries that embed the keyword
2061            matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
2062            // 1-6 summaries that do NOT contain the keyword
2063            non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
2064        ) {
2065            // Ensure the keyword doesn't accidentally appear in non-matching summaries.
2066            for s in &non_matching {
2067                prop_assume!(!s.contains(keyword.as_str()));
2068            }
2069
2070            let store = in_memory_store();
2071
2072            // Save matching sessions (summary = "<suffix> <keyword> <suffix>")
2073            let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2074            for (i, suffix) in matching_suffixes.iter().enumerate() {
2075                let id = format!("match-{i}");
2076                let summary = format!("{} {} end", suffix, keyword);
2077                store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
2078                matching_ids.insert(id);
2079            }
2080
2081            // Save non-matching sessions
2082            let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2083            for (i, summary) in non_matching.iter().enumerate() {
2084                let id = format!("nomatch-{i}");
2085                store.save_session(&make_session(&id, "/proj", summary)).unwrap();
2086                non_matching_ids.insert(id);
2087            }
2088
2089            let results = store.search(&keyword).unwrap();
2090            let result_ids: std::collections::HashSet<String> =
2091                results.iter().map(|r| r.id.clone()).collect();
2092
2093            // Every matching session must appear in results.
2094            for id in &matching_ids {
2095                prop_assert!(
2096                    result_ids.contains(id),
2097                    "matching session '{}' not found in search results for keyword '{}'",
2098                    id, keyword
2099                );
2100            }
2101
2102            // No non-matching session may appear in results.
2103            for id in &non_matching_ids {
2104                prop_assert!(
2105                    !result_ids.contains(id),
2106                    "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
2107                    id, keyword
2108                );
2109            }
2110        }
2111    }
2112
2113    // ── Property: search_by_date correctness ─────────────────────────────────
2114    // **Validates: Requirements 20.4**
2115    //
2116    // For any set of sessions with different timestamps, searching by a date
2117    // range SHALL return exactly the sessions whose `updated_at` falls within
2118    // [from, to], and no sessions outside that range.
2119
2120    proptest! {
2121        /// **Validates: Requirements 20.4**
2122        ///
2123        /// For any set of sessions with distinct timestamps, `search_by_date`
2124        /// SHALL return exactly the sessions whose `updated_at` is within
2125        /// `[from, to]`, and no sessions outside that range.
2126        #[test]
2127        fn prop_search_by_date_correctness(
2128            // Generate 2-8 offsets in seconds from epoch (spread over a wide range)
2129            offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
2130            // The search window: start and end offsets (relative to the minimum offset)
2131            window_start_delta in 0i64..=3600i64,
2132            window_end_delta   in 3600i64..=7200i64,
2133        ) {
2134            use chrono::TimeZone;
2135
2136            // Deduplicate offsets so each session has a unique timestamp.
2137            let mut unique_offsets: Vec<i64> = offsets.clone();
2138            unique_offsets.sort_unstable();
2139            unique_offsets.dedup();
2140            prop_assume!(unique_offsets.len() >= 2);
2141
2142            let base_offset = unique_offsets[0];
2143            let from_offset = base_offset + window_start_delta;
2144            let to_offset   = base_offset + window_end_delta;
2145
2146            let from = Utc.timestamp_opt(from_offset, 0).unwrap();
2147            let to   = Utc.timestamp_opt(to_offset,   0).unwrap();
2148
2149            let store = in_memory_store();
2150
2151            let mut in_range_ids:  std::collections::HashSet<String> = std::collections::HashSet::new();
2152            let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2153
2154            for (i, &offset) in unique_offsets.iter().enumerate() {
2155                let ts = Utc.timestamp_opt(offset, 0).unwrap();
2156                let id = format!("sess-{i}");
2157                let session = make_session_at(&id, "some summary", ts);
2158                store.save_session(&session).unwrap();
2159
2160                if ts >= from && ts <= to {
2161                    in_range_ids.insert(id);
2162                } else {
2163                    out_range_ids.insert(id);
2164                }
2165            }
2166
2167            let results = store.search_by_date(from, to).unwrap();
2168            let result_ids: std::collections::HashSet<String> =
2169                results.iter().map(|r| r.id.clone()).collect();
2170
2171            // Every in-range session must appear.
2172            for id in &in_range_ids {
2173                prop_assert!(
2174                    result_ids.contains(id),
2175                    "in-range session '{}' missing from search_by_date results",
2176                    id
2177                );
2178            }
2179
2180            // No out-of-range session may appear.
2181            for id in &out_range_ids {
2182                prop_assert!(
2183                    !result_ids.contains(id),
2184                    "out-of-range session '{}' incorrectly appeared in search_by_date results",
2185                    id
2186                );
2187            }
2188        }
2189    }
2190}