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