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
108CREATE TABLE IF NOT EXISTS known_files (
109    path        TEXT PRIMARY KEY,
110    added_at    TEXT NOT NULL
111);
112
113-- Small key/value store for engine-wide state that needs to persist across
114-- short-lived sqz processes (each shell-hook invocation is a new process).
115-- Initially used only for the last_compaction_at marker: cache entries with
116-- `accessed_at < last_compaction_at` are treated as stale even if still
117-- within the normal TTL. See cache_manager.rs for the freshness model.
118CREATE TABLE IF NOT EXISTS metadata (
119    key         TEXT PRIMARY KEY,
120    value       TEXT NOT NULL
121);
122"#;
123
124// ── Helpers ───────────────────────────────────────────────────────────────────
125
126pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
127    conn.execute_batch(SCHEMA)?;
128    // Additive migration: add `original` BLOB column to cache_entries if
129    // it does not yet exist. Stores the raw pre-compression bytes so
130    // `sqz expand <prefix>` can return truly uncompressed content when
131    // an agent cannot parse `§ref:…§` tokens (reported on-list by
132    // SquireNed for GLM 5.1). `NULL` for rows written by older sqz
133    // versions — `expand` treats those as "original unavailable, fall
134    // back to the compressed blob" so users don't get spurious errors
135    // on pre-migration data.
136    //
137    // Using pragma_table_info rather than a version table because the
138    // rest of sqz does the same — this is the first additive migration.
139    let has_original: bool = conn
140        .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'original'")?
141        .query_row([], |_| Ok(()))
142        .is_ok();
143    if !has_original {
144        conn.execute("ALTER TABLE cache_entries ADD COLUMN original BLOB", [])?;
145    }
146
147    // Additive migration: add `project_dir` TEXT column to compression_log.
148    // Enables per-project stats filtering (issue #13). NULL for rows
149    // written by older sqz versions — aggregate queries treat those as
150    // "unknown project".
151    let has_project_dir: bool = conn
152        .prepare("SELECT 1 FROM pragma_table_info('compression_log') WHERE name = 'project_dir'")?
153        .query_row([], |_| Ok(()))
154        .is_ok();
155    if !has_project_dir {
156        conn.execute("ALTER TABLE compression_log ADD COLUMN project_dir TEXT", [])?;
157    }
158
159    // Additive migration: add `pinned` flag to cache_entries. Pinned
160    // entries bypass the TTL freshness check so stable wiki / knowledge
161    // base content stays dedup-able forever.
162    let has_pinned: bool = conn
163        .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'pinned'")?
164        .query_row([], |_| Ok(()))
165        .is_ok();
166    if !has_pinned {
167        conn.execute(
168            "ALTER TABLE cache_entries ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
169            [],
170        )?;
171    }
172
173    Ok(())
174}
175
176fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
177    let conn = Connection::open(path)?;
178    apply_schema(&conn)?;
179    Ok(conn)
180}
181
182fn row_to_summary(
183    id: String,
184    project_dir: String,
185    compressed_summary: String,
186    created_at: String,
187    updated_at: String,
188) -> Result<SessionSummary> {
189    let created_at = created_at
190        .parse::<DateTime<Utc>>()
191        .map_err(|e| SqzError::Other(format!("invalid created_at timestamp: {e}")))?;
192    let updated_at = updated_at
193        .parse::<DateTime<Utc>>()
194        .map_err(|e| SqzError::Other(format!("invalid updated_at timestamp: {e}")))?;
195    Ok(SessionSummary {
196        id,
197        project_dir: PathBuf::from(project_dir),
198        compressed_summary,
199        created_at,
200        updated_at,
201    })
202}
203
204// ── SessionStore ──────────────────────────────────────────────────────────────
205
206impl SessionStore {
207    /// Construct a `SessionStore` from an already-open `Connection`.
208    /// Intended for testing (e.g., in-memory databases).
209    #[cfg(test)]
210    pub(crate) fn from_connection(conn: Connection) -> Self {
211        Self { db: conn }
212    }
213
214    /// Open an existing database at `path`. Returns an error if the file does
215    /// not exist or cannot be opened.
216    pub fn open(path: &Path) -> Result<Self> {
217        let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
218        apply_schema(&conn)?;
219        Ok(Self { db: conn })
220    }
221
222    /// Open the database at `path`, creating it if it does not exist.
223    /// If the database is corrupted, a fresh database is created at the same
224    /// path and a warning is logged to stderr.
225    pub fn open_or_create(path: &Path) -> Result<Self> {
226        match open_connection(path) {
227            Ok(conn) => Ok(Self { db: conn }),
228            Err(e) => {
229                eprintln!(
230                    "sqz warning: session store at '{}' is corrupted or inaccessible ({e}). \
231                     Creating a new database. Prior session data has been lost.",
232                    path.display()
233                );
234                // Remove the corrupted file so we can start fresh.
235                if path.exists() {
236                    let _ = std::fs::remove_file(path);
237                }
238                let conn = open_connection(path)
239                    .map_err(|e2| SqzError::Other(format!("failed to create new session store: {e2}")))?;
240                Ok(Self { db: conn })
241            }
242        }
243    }
244
245    // ── Session CRUD ──────────────────────────────────────────────────────────
246
247    /// Persist a session. Returns the session id.
248    pub fn save_session(&self, session: &SessionState) -> Result<SessionId> {
249        let data = serde_json::to_vec(session)?;
250        let project_dir = session.project_dir.to_string_lossy().to_string();
251        let created_at = session.created_at.to_rfc3339();
252        let updated_at = session.updated_at.to_rfc3339();
253
254        self.db.execute(
255            r#"INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data)
256               VALUES (?1, ?2, ?3, ?4, ?5, ?6)
257               ON CONFLICT(id) DO UPDATE SET
258                   project_dir        = excluded.project_dir,
259                   compressed_summary = excluded.compressed_summary,
260                   created_at         = excluded.created_at,
261                   updated_at         = excluded.updated_at,
262                   data               = excluded.data"#,
263            params![
264                session.id,
265                project_dir,
266                session.compressed_summary,
267                created_at,
268                updated_at,
269                data,
270            ],
271        )?;
272
273        Ok(session.id.clone())
274    }
275
276    /// Save a lightweight session from just an id and summary for dashboard use.
277    pub fn save_dashboard_session(&self, id: &str, summary: &str) -> Result<()> {
278        let now = Utc::now().to_rfc3339();
279        let project_dir = std::env::current_dir()
280            .unwrap_or_else(|_| std::path::PathBuf::from("."))
281            .to_string_lossy()
282            .to_string();
283        let empty_data = serde_json::to_vec(&serde_json::json!({"conversation":[]}))?;
284
285        self.db
286            .execute(
287                "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                 compressed_summary = excluded.compressed_summary, \
291                 updated_at = excluded.updated_at",
292                params![id, project_dir, summary, now, now, empty_data],
293            )
294            .map_err(SqzError::SessionStore)?;
295        Ok(())
296    }
297
298    /// Load a session by id.
299    pub fn load_session(&self, id: SessionId) -> Result<SessionState> {
300        let data: Vec<u8> = self.db.query_row(
301            "SELECT data FROM sessions WHERE id = ?1",
302            params![id],
303            |row| row.get(0),
304        )?;
305        let session: SessionState = serde_json::from_slice(&data)?;
306        Ok(session)
307    }
308
309    // ── Search ────────────────────────────────────────────────────────────────
310
311    /// Full-text search using FTS5 (porter stemmer, ASCII tokenizer).
312    pub fn search(&self, query: &str) -> Result<Vec<SessionSummary>> {
313        let mut stmt = self.db.prepare(
314            r#"SELECT s.id, s.project_dir, s.compressed_summary, s.created_at, s.updated_at
315               FROM sessions s
316               JOIN sessions_fts f ON s.rowid = f.rowid
317               WHERE sessions_fts MATCH ?1
318               ORDER BY rank"#,
319        )?;
320
321        let rows = stmt.query_map(params![query], |row| {
322            Ok((
323                row.get::<_, String>(0)?,
324                row.get::<_, String>(1)?,
325                row.get::<_, String>(2)?,
326                row.get::<_, String>(3)?,
327                row.get::<_, String>(4)?,
328            ))
329        })?;
330
331        let mut results = Vec::new();
332        for row in rows {
333            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
334            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
335        }
336        Ok(results)
337    }
338
339    /// Query sessions whose `updated_at` falls within `[from, to]`.
340    pub fn search_by_date(
341        &self,
342        from: DateTime<Utc>,
343        to: DateTime<Utc>,
344    ) -> Result<Vec<SessionSummary>> {
345        let mut stmt = self.db.prepare(
346            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
347               FROM sessions
348               WHERE updated_at >= ?1 AND updated_at <= ?2
349               ORDER BY updated_at DESC"#,
350        )?;
351
352        let rows = stmt.query_map(params![from.to_rfc3339(), to.to_rfc3339()], |row| {
353            Ok((
354                row.get::<_, String>(0)?,
355                row.get::<_, String>(1)?,
356                row.get::<_, String>(2)?,
357                row.get::<_, String>(3)?,
358                row.get::<_, String>(4)?,
359            ))
360        })?;
361
362        let mut results = Vec::new();
363        for row in rows {
364            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
365            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
366        }
367        Ok(results)
368    }
369
370    /// Return the most recently updated session, or `None` if no sessions exist.
371    pub fn latest_session(&self) -> Result<Option<SessionSummary>> {
372        let mut stmt = self.db.prepare(
373            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
374               FROM sessions
375               ORDER BY updated_at DESC
376               LIMIT 1"#,
377        ).map_err(SqzError::SessionStore)?;
378
379        let rows = stmt.query_map([], |row| {
380            Ok((
381                row.get::<_, String>(0)?,
382                row.get::<_, String>(1)?,
383                row.get::<_, String>(2)?,
384                row.get::<_, String>(3)?,
385                row.get::<_, String>(4)?,
386            ))
387        }).map_err(SqzError::SessionStore)?;
388
389        for row in rows {
390            let (id, project_dir, compressed_summary, created_at, updated_at) =
391                row.map_err(SqzError::SessionStore)?;
392            return Ok(Some(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?));
393        }
394        Ok(None)
395    }
396
397    /// Query sessions whose `project_dir` matches `dir` exactly.
398    pub fn search_by_project(&self, dir: &Path) -> Result<Vec<SessionSummary>> {
399        let dir_str = dir.to_string_lossy().to_string();
400        let mut stmt = self.db.prepare(
401            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
402               FROM sessions
403               WHERE project_dir = ?1
404               ORDER BY updated_at DESC"#,
405        )?;
406
407        let rows = stmt.query_map(params![dir_str], |row| {
408            Ok((
409                row.get::<_, String>(0)?,
410                row.get::<_, String>(1)?,
411                row.get::<_, String>(2)?,
412                row.get::<_, String>(3)?,
413                row.get::<_, String>(4)?,
414            ))
415        })?;
416
417        let mut results = Vec::new();
418        for row in rows {
419            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
420            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
421        }
422        Ok(results)
423    }
424
425    // ── Cache entries ─────────────────────────────────────────────────────────
426
427    /// Persist a cache entry keyed by content hash.
428    ///
429    /// Stores both the compressed JSON (`data`) used for dedup-hit
430    /// responses AND the raw uncompressed bytes (`original`) so that
431    /// `sqz expand <prefix>` can serve truly uncompressed content. See
432    /// [`save_cache_entry_with_original`] for the original-aware version;
433    /// this convenience wrapper exists for callers that do not (yet)
434    /// have the pre-compression bytes handy. Rows written through this
435    /// path leave `original` as `NULL`, and `expand` will degrade to
436    /// returning the compressed blob with a note.
437    pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
438        self.save_cache_entry_with_original(hash, compressed, None)
439    }
440
441    /// Persist a cache entry with both compressed and original content.
442    ///
443    /// `original` must be the exact bytes that produced `compressed`, so
444    /// that `expand` is a true inverse of dedup. We store the raw bytes
445    /// (not the UTF-8 string) because command output may include
446    /// non-UTF-8 sequences — storing the text would lose them.
447    pub fn save_cache_entry_with_original(
448        &self,
449        hash: &str,
450        compressed: &CompressedContent,
451        original: Option<&[u8]>,
452    ) -> Result<()> {
453        let data = serde_json::to_string(compressed)?;
454        let now = Utc::now().to_rfc3339();
455        self.db.execute(
456            r#"INSERT INTO cache_entries (hash, data, accessed_at, original)
457               VALUES (?1, ?2, ?3, ?4)
458               ON CONFLICT(hash) DO UPDATE
459                   SET data = excluded.data,
460                       accessed_at = excluded.accessed_at,
461                       -- Don't overwrite a previously-stored `original`
462                       -- with NULL. Older callers (that go through
463                       -- save_cache_entry rather than the _with_original
464                       -- variant) shouldn't erase the expand-able bytes.
465                       original = COALESCE(excluded.original, original)"#,
466            params![hash, data, now, original],
467        )?;
468        Ok(())
469    }
470
471    /// Retrieve the stored original bytes for a cached hash, if the
472    /// caller populated them via `save_cache_entry_with_original`.
473    ///
474    /// Returns `Ok(None)` for missing entries AND for entries that were
475    /// saved by an older call site that did not pass `original`. The
476    /// caller should fall back to the compressed blob in the latter case
477    /// and surface a note to the user so they know this specific entry
478    /// wasn't round-trippable.
479    pub fn get_cache_entry_original(&self, hash: &str) -> Result<Option<Vec<u8>>> {
480        let result: rusqlite::Result<Option<Vec<u8>>> = self.db.query_row(
481            "SELECT original FROM cache_entries WHERE hash = ?1",
482            params![hash],
483            |row| row.get(0),
484        );
485        match result {
486            Ok(v) => Ok(v),
487            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
488            Err(e) => Err(SqzError::SessionStore(e)),
489        }
490    }
491
492    /// Delete a cache entry by content hash.
493    pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
494        self.db.execute(
495            "DELETE FROM cache_entries WHERE hash = ?1",
496            params![hash],
497        )?;
498        Ok(())
499    }
500
501    /// Return all cache entries ordered by `accessed_at` ASC (oldest first),
502    /// as `(hash, size_bytes)` pairs where `size_bytes` is the byte length of
503    /// the stored JSON data.
504    pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
505        let mut stmt = self.db.prepare(
506            "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
507        )?;
508        let rows = stmt.query_map([], |row| {
509            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
510        })?;
511        let mut entries = Vec::new();
512        for row in rows {
513            let (hash, size) = row?;
514            entries.push((hash, size as u64));
515        }
516        Ok(entries)
517    }
518
519    /// Retrieve a cache entry by content hash, updating `accessed_at`.
520    pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
521        let result: rusqlite::Result<String> = self.db.query_row(
522            "SELECT data FROM cache_entries WHERE hash = ?1",
523            params![hash],
524            |row| row.get(0),
525        );
526
527        match result {
528            Ok(data) => {
529                // Touch accessed_at for LRU tracking.
530                let now = Utc::now().to_rfc3339();
531                let _ = self.db.execute(
532                    "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
533                    params![now, hash],
534                );
535                let entry: CompressedContent = serde_json::from_str(&data)?;
536                Ok(Some(entry))
537            }
538            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
539            Err(e) => Err(SqzError::SessionStore(e)),
540        }
541    }
542
543    /// Look up a cache entry by a **prefix** of the content hash.
544    ///
545    /// The inline dedup refs we hand to the LLM carry only the first 16
546    /// hex chars of the SHA-256 (`§ref:<16-hex>§`), so when an agent runs
547    /// `sqz expand <prefix>` we need to resolve those 16 chars back to the
548    /// full 64-char key. Uses `LIKE 'prefix%'` with an index-friendly
549    /// anchored pattern so the query stays O(log n) on the primary key.
550    ///
551    /// Returns `Ok(Some((full_hash, entry)))` on unique match.
552    /// Returns `Ok(None)` if no entries match.
553    /// Returns `Err(_)` if the prefix is ambiguous (2+ matches) — the
554    /// caller should tell the user to use a longer prefix. 16-hex
555    /// collisions are astronomically unlikely (one in 2^64) but we
556    /// refuse to guess rather than quietly serve a surprise file.
557    ///
558    /// The prefix is validated as lowercase hex. Non-hex input returns
559    /// `None` without touching the database — this is how we handle the
560    /// common user-error case of pasting the ref with the `§` markers
561    /// still attached (they get rejected before we query SQLite).
562    pub fn get_cache_entry_by_prefix(
563        &self,
564        prefix: &str,
565    ) -> Result<Option<(String, CompressedContent)>> {
566        // Reject anything that isn't pure lowercase hex. The inline refs
567        // we emit are always lowercase so there's no reason to case-fold
568        // here; accidentally matching uppercase input would also match
569        // unrelated entries if someone hand-crafted a collision.
570        if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
571            return Ok(None);
572        }
573        let pattern = format!("{prefix}%");
574        let mut stmt = self
575            .db
576            .prepare("SELECT hash, data FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
577        let mut rows = stmt.query(params![pattern])?;
578
579        let first = match rows.next()? {
580            Some(r) => {
581                let hash: String = r.get(0)?;
582                let data: String = r.get(1)?;
583                (hash, data)
584            }
585            None => return Ok(None),
586        };
587
588        // Two or more hits — refuse. Prefix ambiguity is user-recoverable:
589        // rerun with a longer prefix. Matching one arbitrarily would be
590        // a silent data surprise.
591        if rows.next()?.is_some() {
592            return Err(SqzError::Other(format!(
593                "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
594            )));
595        }
596        drop(rows);
597        drop(stmt);
598
599        // Touch accessed_at for LRU tracking — symmetric with get_cache_entry.
600        let now = Utc::now().to_rfc3339();
601        let _ = self.db.execute(
602            "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
603            params![now, first.0],
604        );
605        let entry: CompressedContent = serde_json::from_str(&first.1)?;
606        Ok(Some((first.0, entry)))
607    }
608
609    /// Read the `accessed_at` timestamp for a cached hash without updating
610    /// it. Returns `None` if the hash is not cached.
611    ///
612    /// Used by the dedup freshness check: if `accessed_at` is recent, the
613    /// LLM likely still has the original content in its context window, so
614    /// returning a ref is safe. If it's old, re-send the full content.
615    pub fn get_cache_entry_accessed_at(&self, hash: &str) -> Result<Option<DateTime<Utc>>> {
616        let result: rusqlite::Result<String> = self.db.query_row(
617            "SELECT accessed_at FROM cache_entries WHERE hash = ?1",
618            params![hash],
619            |row| row.get(0),
620        );
621        match result {
622            Ok(s) => {
623                let ts = s
624                    .parse::<DateTime<Utc>>()
625                    .map_err(|e| SqzError::Other(format!("invalid accessed_at: {e}")))?;
626                Ok(Some(ts))
627            }
628            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
629            Err(e) => Err(SqzError::SessionStore(e)),
630        }
631    }
632
633    /// Check if a cache entry exists without updating `accessed_at`.
634    pub fn cache_entry_exists(&self, hash: &str) -> Result<bool> {
635        let result: rusqlite::Result<i64> = self.db.query_row(
636            "SELECT 1 FROM cache_entries WHERE hash = ?1",
637            params![hash],
638            |row| row.get(0),
639        );
640        match result {
641            Ok(_) => Ok(true),
642            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
643            Err(e) => Err(SqzError::SessionStore(e)),
644        }
645    }
646
647    /// Update `accessed_at` for a cached hash to the current time. Called by
648    /// the cache manager when a ref is served so the next staleness check
649    /// sees the recent send.
650    pub fn touch_cache_entry(&self, hash: &str) -> Result<()> {
651        let now = Utc::now().to_rfc3339();
652        self.db.execute(
653            "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
654            params![now, hash],
655        )?;
656        Ok(())
657    }
658
659    /// Resolve a hash prefix (or full hash) to the unique full hash of
660    /// the matching cache entry. Returns `Ok(None)` for non-existent
661    /// entries. Returns an error if the prefix is ambiguous (matches
662    /// more than one entry) so the caller can ask for a longer prefix.
663    /// Does not touch `accessed_at`.
664    pub fn resolve_cache_hash_by_prefix(&self, prefix: &str) -> Result<Option<String>> {
665        if prefix.is_empty()
666            || !prefix
667                .chars()
668                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
669        {
670            return Ok(None);
671        }
672        let pattern = format!("{prefix}%");
673        let mut stmt = self
674            .db
675            .prepare("SELECT hash FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
676        let mut rows = stmt.query(params![pattern])?;
677        let first: Option<String> = match rows.next()? {
678            Some(r) => Some(r.get(0)?),
679            None => None,
680        };
681        if rows.next()?.is_some() {
682            return Err(SqzError::Other(format!(
683                "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
684            )));
685        }
686        Ok(first)
687    }
688
689    /// Mark a cache entry as pinned so it bypasses the TTL freshness
690    /// check. Returns `Ok(true)` if a row was updated, `Ok(false)` if the
691    /// hash is not in the cache.
692    pub fn pin_cache_entry(&self, hash: &str) -> Result<bool> {
693        let n = self.db.execute(
694            "UPDATE cache_entries SET pinned = 1 WHERE hash = ?1",
695            params![hash],
696        )?;
697        Ok(n > 0)
698    }
699
700    /// Unpin a cache entry so it returns to normal TTL-based freshness.
701    /// Returns `Ok(true)` if a row was updated, `Ok(false)` otherwise.
702    pub fn unpin_cache_entry(&self, hash: &str) -> Result<bool> {
703        let n = self.db.execute(
704            "UPDATE cache_entries SET pinned = 0 WHERE hash = ?1",
705            params![hash],
706        )?;
707        Ok(n > 0)
708    }
709
710    /// Unpin every cache entry. Returns the count of rows that were
711    /// previously pinned.
712    pub fn unpin_all_cache_entries(&self) -> Result<usize> {
713        let n = self
714            .db
715            .execute("UPDATE cache_entries SET pinned = 0 WHERE pinned = 1", [])?;
716        Ok(n)
717    }
718
719    /// Whether the given hash is pinned. Returns `false` for unpinned
720    /// entries AND for hashes not in the cache.
721    pub fn is_cache_entry_pinned(&self, hash: &str) -> Result<bool> {
722        let result: rusqlite::Result<i64> = self.db.query_row(
723            "SELECT pinned FROM cache_entries WHERE hash = ?1",
724            params![hash],
725            |row| row.get(0),
726        );
727        match result {
728            Ok(v) => Ok(v != 0),
729            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
730            Err(e) => Err(SqzError::SessionStore(e)),
731        }
732    }
733
734    /// List every pinned cache entry. Returns `(hash, accessed_at, size_bytes)`
735    /// tuples so the CLI can render a useful table.
736    pub fn list_pinned_cache_entries(
737        &self,
738    ) -> Result<Vec<(String, DateTime<Utc>, u64)>> {
739        let mut stmt = self
740            .db
741            .prepare(
742                "SELECT hash, accessed_at, length(data) FROM cache_entries \
743                 WHERE pinned = 1 ORDER BY accessed_at DESC",
744            )
745            .map_err(SqzError::SessionStore)?;
746        let rows = stmt
747            .query_map([], |row| {
748                Ok((
749                    row.get::<_, String>(0)?,
750                    row.get::<_, String>(1)?,
751                    row.get::<_, i64>(2)? as u64,
752                ))
753            })
754            .map_err(SqzError::SessionStore)?;
755        let mut out = Vec::new();
756        for row in rows {
757            let (hash, accessed_at_str, size) = row?;
758            let accessed_at = accessed_at_str.parse::<DateTime<Utc>>().map_err(|e| {
759                SqzError::Other(format!("invalid accessed_at on pinned entry: {e}"))
760            })?;
761            out.push((hash, accessed_at, size));
762        }
763        Ok(out)
764    }
765
766    /// Set a metadata key/value. Persists across sqz process boundaries
767    /// (each shell-hook invocation is a short-lived process).
768    pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
769        self.db.execute(
770            "INSERT INTO metadata (key, value) VALUES (?1, ?2)
771             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
772            params![key, value],
773        )?;
774        Ok(())
775    }
776
777    /// Get a metadata value. Returns `None` if the key has never been set.
778    pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
779        let result: rusqlite::Result<String> = self.db.query_row(
780            "SELECT value FROM metadata WHERE key = ?1",
781            params![key],
782            |row| row.get(0),
783        );
784        match result {
785            Ok(v) => Ok(Some(v)),
786            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
787            Err(e) => Err(SqzError::SessionStore(e)),
788        }
789    }
790
791    /// Log a compression event for cumulative stats tracking.
792    pub fn log_compression(
793        &self,
794        tokens_original: u32,
795        tokens_compressed: u32,
796        stages: &[String],
797        mode: &str,
798    ) -> Result<()> {
799        self.log_compression_with_project(tokens_original, tokens_compressed, stages, mode, None)
800    }
801
802    /// Log a compression event tagged with a project directory.
803    pub fn log_compression_with_project(
804        &self,
805        tokens_original: u32,
806        tokens_compressed: u32,
807        stages: &[String],
808        mode: &str,
809        project_dir: Option<&str>,
810    ) -> Result<()> {
811        let now = Utc::now().to_rfc3339();
812        let stages_str = stages.join(",");
813        self.db.execute(
814            "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at, project_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
815            params![tokens_original, tokens_compressed, stages_str, mode, now, project_dir],
816        ).map_err(SqzError::SessionStore)?;
817        Ok(())
818    }
819
820    /// Get cumulative compression stats from the log.
821    pub fn compression_stats(&self) -> Result<CompressionStats> {
822        let mut stmt = self.db.prepare(
823            "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
824        ).map_err(SqzError::SessionStore)?;
825
826        let stats = stmt.query_row([], |row| {
827            Ok(CompressionStats {
828                total_compressions: row.get::<_, u32>(0)?,
829                total_tokens_in: row.get::<_, u64>(1)?,
830                total_tokens_out: row.get::<_, u64>(2)?,
831            })
832        }).map_err(SqzError::SessionStore)?;
833
834        Ok(stats)
835    }
836
837    /// Get daily compression gains for the last N days.
838    pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
839        let mut stmt = self.db.prepare(
840            "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
841             FROM compression_log \
842             WHERE created_at >= date('now', ?1) \
843             GROUP BY d ORDER BY d",
844        ).map_err(SqzError::SessionStore)?;
845
846        let offset = format!("-{days} days");
847        let rows = stmt.query_map(params![offset], |row| {
848            let tokens_in: u64 = row.get(2)?;
849            let tokens_out: u64 = row.get(3)?;
850            Ok(DailyGain {
851                date: row.get(0)?,
852                compressions: row.get(1)?,
853                tokens_in,
854                tokens_saved: tokens_in.saturating_sub(tokens_out),
855            })
856        }).map_err(SqzError::SessionStore)?;
857
858        let mut gains = Vec::new();
859        for row in rows {
860            gains.push(row.map_err(SqzError::SessionStore)?);
861        }
862        Ok(gains)
863    }
864
865    /// Get cumulative compression stats for a specific project directory.
866    pub fn compression_stats_for_project(&self, project_dir: &str) -> Result<CompressionStats> {
867        let mut stmt = self.db.prepare(
868            "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) \
869             FROM compression_log WHERE project_dir = ?1",
870        ).map_err(SqzError::SessionStore)?;
871
872        let stats = stmt.query_row(params![project_dir], |row| {
873            Ok(CompressionStats {
874                total_compressions: row.get::<_, u32>(0)?,
875                total_tokens_in: row.get::<_, u64>(1)?,
876                total_tokens_out: row.get::<_, u64>(2)?,
877            })
878        }).map_err(SqzError::SessionStore)?;
879
880        Ok(stats)
881    }
882
883    /// Get daily compression gains for a specific project directory.
884    pub fn daily_gains_for_project(&self, days: u32, project_dir: &str) -> Result<Vec<DailyGain>> {
885        let mut stmt = self.db.prepare(
886            "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
887             FROM compression_log \
888             WHERE created_at >= date('now', ?1) AND project_dir = ?2 \
889             GROUP BY d ORDER BY d",
890        ).map_err(SqzError::SessionStore)?;
891
892        let offset = format!("-{days} days");
893        let rows = stmt.query_map(params![offset, project_dir], |row| {
894            let tokens_in: u64 = row.get(2)?;
895            let tokens_out: u64 = row.get(3)?;
896            Ok(DailyGain {
897                date: row.get(0)?,
898                compressions: row.get(1)?,
899                tokens_in,
900                tokens_saved: tokens_in.saturating_sub(tokens_out),
901            })
902        }).map_err(SqzError::SessionStore)?;
903
904        let mut gains = Vec::new();
905        for row in rows {
906            gains.push(row.map_err(SqzError::SessionStore)?);
907        }
908        Ok(gains)
909    }
910
911    /// List distinct project directories that have compression data.
912    pub fn list_projects(&self) -> Result<Vec<(String, u32, u64)>> {
913        let mut stmt = self.db.prepare(
914            "SELECT project_dir, COUNT(*), COALESCE(SUM(tokens_original) - SUM(tokens_compressed), 0) \
915             FROM compression_log \
916             WHERE project_dir IS NOT NULL \
917             GROUP BY project_dir \
918             ORDER BY COUNT(*) DESC",
919        ).map_err(SqzError::SessionStore)?;
920
921        let rows = stmt.query_map([], |row| {
922            Ok((
923                row.get::<_, String>(0)?,
924                row.get::<_, u32>(1)?,
925                row.get::<_, u64>(2)?,
926            ))
927        }).map_err(SqzError::SessionStore)?;
928
929        let mut projects = Vec::new();
930        for row in rows {
931            projects.push(row.map_err(SqzError::SessionStore)?);
932        }
933        Ok(projects)
934    }
935
936    /// Per-command breakdown of token usage. Returns top N commands by
937    /// total tokens consumed, with compression stats for each.
938    /// Used by `sqz stats --breakdown` to show where tokens are going.
939    pub fn command_breakdown(&self, limit: u32) -> Result<Vec<CommandStats>> {
940        let mut stmt = self.db.prepare(
941            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
942             FROM compression_log \
943             GROUP BY mode \
944             ORDER BY SUM(tokens_original) DESC \
945             LIMIT ?1",
946        ).map_err(SqzError::SessionStore)?;
947
948        let rows = stmt.query_map(params![limit], |row| {
949            let tokens_in: u64 = row.get(2)?;
950            let tokens_out: u64 = row.get(3)?;
951            Ok(CommandStats {
952                command: row.get(0)?,
953                invocations: row.get(1)?,
954                tokens_in,
955                tokens_out,
956                tokens_saved: tokens_in.saturating_sub(tokens_out),
957            })
958        }).map_err(SqzError::SessionStore)?;
959
960        let mut results = Vec::new();
961        for row in rows {
962            results.push(row.map_err(SqzError::SessionStore)?);
963        }
964        Ok(results)
965    }
966
967    /// Per-command breakdown filtered to a specific project.
968    pub fn command_breakdown_for_project(&self, limit: u32, project_dir: &str) -> Result<Vec<CommandStats>> {
969        let mut stmt = self.db.prepare(
970            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
971             FROM compression_log \
972             WHERE project_dir = ?1 \
973             GROUP BY mode \
974             ORDER BY SUM(tokens_original) DESC \
975             LIMIT ?2",
976        ).map_err(SqzError::SessionStore)?;
977
978        let rows = stmt.query_map(params![project_dir, limit], |row| {
979            let tokens_in: u64 = row.get(2)?;
980            let tokens_out: u64 = row.get(3)?;
981            Ok(CommandStats {
982                command: row.get(0)?,
983                invocations: row.get(1)?,
984                tokens_in,
985                tokens_out,
986                tokens_saved: tokens_in.saturating_sub(tokens_out),
987            })
988        }).map_err(SqzError::SessionStore)?;
989
990        let mut results = Vec::new();
991        for row in rows {
992            results.push(row.map_err(SqzError::SessionStore)?);
993        }
994        Ok(results)
995    }
996
997    /// Get the total tokens injected in the current session (since last
998    /// compaction or start). Used by the adaptive pressure system to
999    /// decide whether to escalate compression intensity.
1000    pub fn session_pressure(&self, since_minutes: u32) -> Result<u64> {
1001        let mut stmt = self.db.prepare(
1002            "SELECT COALESCE(SUM(tokens_compressed), 0) \
1003             FROM compression_log \
1004             WHERE created_at >= datetime('now', ?1)",
1005        ).map_err(SqzError::SessionStore)?;
1006
1007        let offset = format!("-{since_minutes} minutes");
1008        let total: u64 = stmt.query_row(params![offset], |row| {
1009            row.get(0)
1010        }).map_err(SqzError::SessionStore)?;
1011
1012        Ok(total)
1013    }
1014
1015    // ── Known files (persistent cross-command context tracking) ───────────
1016
1017    /// Record a file path as "known" (its content is in the dedup cache).
1018    /// Used by cross-command context refs to annotate error messages.
1019    pub fn add_known_file(&self, path: &str) -> Result<()> {
1020        let now = Utc::now().to_rfc3339();
1021        self.db.execute(
1022            "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
1023            params![path, now],
1024        ).map_err(SqzError::SessionStore)?;
1025        Ok(())
1026    }
1027
1028    /// Load all known file paths from the persistent store.
1029    pub fn known_files(&self) -> Result<Vec<String>> {
1030        let mut stmt = self.db.prepare(
1031            "SELECT path FROM known_files ORDER BY added_at DESC",
1032        ).map_err(SqzError::SessionStore)?;
1033
1034        let rows = stmt.query_map([], |row| {
1035            row.get::<_, String>(0)
1036        }).map_err(SqzError::SessionStore)?;
1037
1038        let mut files = Vec::new();
1039        for row in rows {
1040            files.push(row.map_err(SqzError::SessionStore)?);
1041        }
1042        Ok(files)
1043    }
1044    /// Clear all known files (e.g. on session reset).
1045    pub fn clear_known_files(&self) -> Result<()> {
1046        self.db
1047            .execute("DELETE FROM known_files", [])
1048            .map_err(SqzError::SessionStore)?;
1049        Ok(())
1050    }
1051
1052    /// Number of entries currently in the dedup cache.
1053    pub fn cache_entry_count(&self) -> Result<u64> {
1054        self.db.query_row(
1055            "SELECT COUNT(*) FROM cache_entries", [],
1056            |row| row.get(0),
1057        ).map_err(SqzError::SessionStore)
1058    }
1059
1060    /// Increment the cache-hit counter (persisted in metadata).
1061    pub fn record_cache_hit(&self) -> Result<()> {
1062        let count: u64 = self.db.query_row(
1063            "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1064            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1065        ).unwrap_or(0);
1066        let new = count + 1;
1067        self.db.execute(
1068            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_hits', ?1)",
1069            params![new.to_string()],
1070        )?;
1071        Ok(())
1072    }
1073
1074    /// Increment the cache-miss counter (persisted in metadata).
1075    pub fn record_cache_miss(&self) -> Result<()> {
1076        let count: u64 = self.db.query_row(
1077            "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1078            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1079        ).unwrap_or(0);
1080        let new = count + 1;
1081        self.db.execute(
1082            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_misses', ?1)",
1083            params![new.to_string()],
1084        )?;
1085        Ok(())
1086    }
1087
1088    /// Return (cache_hits, cache_misses) from metadata counters.
1089    pub fn cache_hit_miss_counts(&self) -> Result<(u64, u64)> {
1090        let hits: u64 = self.db.query_row(
1091            "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1092            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1093        ).unwrap_or(0);
1094        let misses: u64 = self.db.query_row(
1095            "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1096            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1097        ).unwrap_or(0);
1098        Ok((hits, misses))
1099    }
1100
1101    /// Clear the dedup cache. After this, all future reads will be treated
1102    /// as cache misses and compressed fresh. Does NOT clear compression
1103    /// stats or session history.
1104    pub fn clear_cache(&self) -> Result<u64> {
1105        let count: u64 = self.db.query_row(
1106            "SELECT COUNT(*) FROM cache_entries", [],
1107            |row| row.get(0),
1108        ).unwrap_or(0);
1109        self.db.execute("DELETE FROM cache_entries", [])
1110            .map_err(SqzError::SessionStore)?;
1111        // Also clear the compaction marker so freshness checks don't
1112        // reference timestamps from the deleted entries.
1113        let _ = self.db.execute(
1114            "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1115        );
1116        Ok(count)
1117    }
1118
1119    /// Clear compression stats (the `compression_log` table). After this,
1120    /// `sqz stats` and `sqz gain` will show zero until new compressions
1121    /// happen.
1122    pub fn clear_stats(&self) -> Result<u64> {
1123        let count: u64 = self.db.query_row(
1124            "SELECT COUNT(*) FROM compression_log", [],
1125            |row| row.get(0),
1126        ).unwrap_or(0);
1127        self.db.execute("DELETE FROM compression_log", [])
1128            .map_err(SqzError::SessionStore)?;
1129        Ok(count)
1130    }
1131
1132    /// Clear compression stats for a specific project directory only.
1133    pub fn clear_stats_for_project(&self, project_dir: &str) -> Result<u64> {
1134        let count: u64 = self.db.query_row(
1135            "SELECT COUNT(*) FROM compression_log WHERE project_dir = ?1",
1136            rusqlite::params![project_dir],
1137            |row| row.get(0),
1138        ).unwrap_or(0);
1139        self.db.execute(
1140            "DELETE FROM compression_log WHERE project_dir = ?1",
1141            rusqlite::params![project_dir],
1142        ).map_err(SqzError::SessionStore)?;
1143        Ok(count)
1144    }
1145
1146    /// Full reset: clear cache, stats, sessions, known files, and metadata.
1147    /// Equivalent to deleting sessions.db and letting sqz recreate it.
1148    pub fn reset_all(&self) -> Result<()> {
1149        self.db.execute("DELETE FROM cache_entries", []).map_err(SqzError::SessionStore)?;
1150        self.db.execute("DELETE FROM compression_log", []).map_err(SqzError::SessionStore)?;
1151        self.db.execute("DELETE FROM sessions", []).map_err(SqzError::SessionStore)?;
1152        self.db.execute("DELETE FROM known_files", []).map_err(SqzError::SessionStore)?;
1153        self.db.execute("DELETE FROM metadata", []).map_err(SqzError::SessionStore)?;
1154        // VACUUM to reclaim disk space.
1155        let _ = self.db.execute("VACUUM", []);
1156        Ok(())
1157    }
1158
1159    /// Return all sessions ordered by most recently updated, limited to `limit`.
1160    pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionSummary>> {
1161        let mut stmt = self
1162            .db
1163            .prepare(
1164                "SELECT id, project_dir, compressed_summary, created_at, updated_at \
1165             FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1166            )
1167            .map_err(SqzError::SessionStore)?;
1168
1169        let rows = stmt
1170            .query_map(params![limit], |row| {
1171                Ok((
1172                    row.get::<_, String>(0)?,
1173                    row.get::<_, String>(1)?,
1174                    row.get::<_, String>(2)?,
1175                    row.get::<_, String>(3)?,
1176                    row.get::<_, String>(4)?,
1177                ))
1178            })
1179            .map_err(SqzError::SessionStore)?;
1180
1181        let mut results = Vec::new();
1182        for row in rows {
1183            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
1184            results.push(row_to_summary(
1185                id,
1186                project_dir,
1187                compressed_summary,
1188                created_at,
1189                updated_at,
1190            )?);
1191        }
1192        Ok(results)
1193    }
1194
1195    /// Per-tool breakdown: group compression_log by stages_applied (comma-split).
1196    pub fn per_tool_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1197        let mut stmt = self
1198            .db
1199            .prepare(
1200                "SELECT stages_applied, SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1201             FROM compression_log GROUP BY stages_applied ORDER BY COUNT(*) DESC LIMIT 20",
1202            )
1203            .map_err(SqzError::SessionStore)?;
1204
1205        let rows = stmt
1206            .query_map([], |row| {
1207                Ok((
1208                    row.get::<_, String>(0)?,
1209                    row.get::<_, u64>(1)?,
1210                    row.get::<_, u64>(2)?,
1211                    row.get::<_, u32>(3)?,
1212                ))
1213            })
1214            .map_err(SqzError::SessionStore)?;
1215
1216        let mut tool_map: std::collections::HashMap<String, (u64, u64, u32)> =
1217            std::collections::HashMap::new();
1218        for row in rows {
1219            let (stages, tokens_in, tokens_out, count) = row?;
1220            for stage in stages.split(',') {
1221                let stage = stage.trim();
1222                if stage.is_empty() {
1223                    continue;
1224                }
1225                let entry = tool_map.entry(stage.to_string()).or_default();
1226                entry.0 += tokens_in;
1227                entry.1 += tokens_out;
1228                entry.2 += count;
1229            }
1230        }
1231
1232        let mut result: Vec<_> = tool_map.into_iter().collect();
1233        result.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
1234        Ok(result
1235            .into_iter()
1236            .map(|(name, (tin, tout, cnt))| (name, tin, tout, cnt))
1237            .collect())
1238    }
1239
1240    /// Per-command breakdown: group compression_log by mode.
1241    pub fn per_command_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1242        let mut stmt = self
1243            .db
1244            .prepare(
1245                "SELECT COALESCE(mode, 'auto'), SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1246             FROM compression_log GROUP BY mode ORDER BY COUNT(*) DESC LIMIT 20",
1247            )
1248            .map_err(SqzError::SessionStore)?;
1249
1250        let rows = stmt
1251            .query_map([], |row| {
1252                Ok((
1253                    row.get::<_, String>(0)?,
1254                    row.get::<_, u64>(1)?,
1255                    row.get::<_, u64>(2)?,
1256                    row.get::<_, u32>(3)?,
1257                ))
1258            })
1259            .map_err(SqzError::SessionStore)?;
1260
1261        let mut result = Vec::new();
1262        for row in rows {
1263            result.push(row?);
1264        }
1265        Ok(result)
1266    }
1267}
1268
1269/// Cumulative compression statistics.
1270#[derive(Debug, Clone, Default)]
1271pub struct CompressionStats {
1272    pub total_compressions: u32,
1273    pub total_tokens_in: u64,
1274    pub total_tokens_out: u64,
1275}
1276
1277impl CompressionStats {
1278    pub fn tokens_saved(&self) -> u64 {
1279        self.total_tokens_in.saturating_sub(self.total_tokens_out)
1280    }
1281
1282    pub fn reduction_pct(&self) -> f64 {
1283        if self.total_tokens_in == 0 {
1284            0.0
1285        } else {
1286            (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
1287        }
1288    }
1289}
1290
1291/// A single day's compression gain.
1292#[derive(Debug, Clone)]
1293pub struct DailyGain {
1294    pub date: String,
1295    pub compressions: u32,
1296    pub tokens_saved: u64,
1297    pub tokens_in: u64,
1298}
1299
1300/// Per-command token usage breakdown.
1301#[derive(Debug, Clone)]
1302pub struct CommandStats {
1303    pub command: String,
1304    pub invocations: u32,
1305    pub tokens_in: u64,
1306    pub tokens_out: u64,
1307    pub tokens_saved: u64,
1308}
1309
1310impl CommandStats {
1311    pub fn reduction_pct(&self) -> f64 {
1312        if self.tokens_in == 0 {
1313            0.0
1314        } else {
1315            (1.0 - self.tokens_out as f64 / self.tokens_in as f64) * 100.0
1316        }
1317    }
1318}
1319
1320// ── Tests ─────────────────────────────────────────────────────────────────────
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325    use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
1326    use chrono::Utc;
1327    use proptest::prelude::*;
1328    use std::path::PathBuf;
1329
1330    fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
1331        let now = Utc::now();
1332        SessionState {
1333            id: id.to_string(),
1334            project_dir: PathBuf::from(project_dir),
1335            conversation: vec![],
1336            corrections: CorrectionLog::default(),
1337            pins: vec![],
1338            learnings: vec![],
1339            compressed_summary: summary.to_string(),
1340            budget: BudgetState {
1341                window_size: 200_000,
1342                consumed: 0,
1343                pinned: 0,
1344                model_family: ModelFamily::AnthropicClaude,
1345            },
1346            tool_usage: vec![],
1347            created_at: now,
1348            updated_at: now,
1349        }
1350    }
1351
1352    fn in_memory_store() -> SessionStore {
1353        let conn = Connection::open_in_memory().unwrap();
1354        apply_schema(&conn).unwrap();
1355        SessionStore { db: conn }
1356    }
1357
1358    #[test]
1359    fn test_save_and_load_session() {
1360        let store = in_memory_store();
1361        let session = make_session("sess-1", "/home/user/project", "REST API refactor");
1362
1363        let id = store.save_session(&session).unwrap();
1364        assert_eq!(id, "sess-1");
1365
1366        let loaded = store.load_session("sess-1".to_string()).unwrap();
1367        assert_eq!(loaded.id, session.id);
1368        assert_eq!(loaded.compressed_summary, session.compressed_summary);
1369        assert_eq!(loaded.project_dir, session.project_dir);
1370    }
1371
1372    #[test]
1373    fn test_save_session_upsert() {
1374        let store = in_memory_store();
1375        let mut session = make_session("sess-2", "/proj", "initial summary");
1376        store.save_session(&session).unwrap();
1377
1378        session.compressed_summary = "updated summary".to_string();
1379        store.save_session(&session).unwrap();
1380
1381        let loaded = store.load_session("sess-2".to_string()).unwrap();
1382        assert_eq!(loaded.compressed_summary, "updated summary");
1383    }
1384
1385    #[test]
1386    fn test_load_nonexistent_session_errors() {
1387        let store = in_memory_store();
1388        let result = store.load_session("does-not-exist".to_string());
1389        assert!(result.is_err());
1390    }
1391
1392    #[test]
1393    fn test_search_fts() {
1394        let store = in_memory_store();
1395        store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
1396        store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
1397
1398        let results = store.search("authentication").unwrap();
1399        assert_eq!(results.len(), 1);
1400        assert_eq!(results[0].id, "s1");
1401    }
1402
1403    #[test]
1404    fn test_search_by_date() {
1405        let store = in_memory_store();
1406        let now = Utc::now();
1407        let past = now - chrono::Duration::hours(2);
1408        let future = now + chrono::Duration::hours(2);
1409
1410        store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
1411
1412        let results = store.search_by_date(past, future).unwrap();
1413        assert!(!results.is_empty());
1414        assert!(results.iter().any(|r| r.id == "s1"));
1415    }
1416
1417    #[test]
1418    fn test_search_by_project() {
1419        let store = in_memory_store();
1420        store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
1421        store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
1422
1423        let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
1424        assert_eq!(results.len(), 1);
1425        assert_eq!(results[0].id, "s1");
1426    }
1427
1428    #[test]
1429    fn test_cache_entry_round_trip() {
1430        let store = in_memory_store();
1431        let entry = CompressedContent {
1432            data: "compressed data".to_string(),
1433            tokens_compressed: 10,
1434            tokens_original: 50,
1435            stages_applied: vec!["strip_nulls".to_string()],
1436            compression_ratio: 0.2,
1437            provenance: crate::types::Provenance::default(),
1438            verify: None,
1439        };
1440
1441        store.save_cache_entry("abc123", &entry).unwrap();
1442
1443        let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
1444        assert_eq!(loaded.data, entry.data);
1445        assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
1446        assert_eq!(loaded.tokens_original, entry.tokens_original);
1447    }
1448
1449    #[test]
1450    fn test_get_cache_entry_missing_returns_none() {
1451        let store = in_memory_store();
1452        let result = store.get_cache_entry("nonexistent").unwrap();
1453        assert!(result.is_none());
1454    }
1455
1456    #[test]
1457    fn test_open_or_create_corrupted_db() {
1458        let dir = tempfile::tempdir().unwrap();
1459        let path = dir.path().join("store.db");
1460
1461        // Write garbage bytes to simulate a corrupted database.
1462        std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
1463
1464        // Should succeed by creating a fresh database.
1465        let store = SessionStore::open_or_create(&path).unwrap();
1466        let session = make_session("s1", "/proj", "after corruption");
1467        store.save_session(&session).unwrap();
1468        let loaded = store.load_session("s1".to_string()).unwrap();
1469        assert_eq!(loaded.id, "s1");
1470    }
1471
1472    // ── Property-based tests ──────────────────────────────────────────────────
1473
1474    /// Build a `SessionState` with a specific `updated_at` timestamp.
1475    fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
1476        let now = Utc::now();
1477        SessionState {
1478            id: id.to_string(),
1479            project_dir: PathBuf::from("/proj"),
1480            conversation: vec![],
1481            corrections: CorrectionLog::default(),
1482            pins: vec![],
1483            learnings: vec![],
1484            compressed_summary: summary.to_string(),
1485            budget: BudgetState {
1486                window_size: 200_000,
1487                consumed: 0,
1488                pinned: 0,
1489                model_family: ModelFamily::AnthropicClaude,
1490            },
1491            tool_usage: vec![],
1492            created_at: now,
1493            updated_at,
1494        }
1495    }
1496
1497    // ── Property 26: Session store search correctness ─────────────────────────
1498    // **Validates: Requirements 20.2, 20.3, 20.4**
1499    //
1500    // For any set of sessions saved to the store, a keyword search SHALL return
1501    // all sessions whose compressed_summary contains the keyword, and no
1502    // sessions that don't contain it.
1503
1504    proptest! {
1505        /// **Validates: Requirements 20.2, 20.3, 20.4**
1506        ///
1507        /// For any set of sessions saved to the store, a keyword search SHALL
1508        /// return all sessions whose `compressed_summary` contains the keyword,
1509        /// and no sessions that don't contain it.
1510        #[test]
1511        fn prop_search_correctness(
1512            // A simple ASCII keyword: 5-8 lowercase letters, no common English
1513            // words that the porter stemmer might conflate with other terms.
1514            keyword in "[b-df-hj-np-tv-z]{5,8}",
1515            // 1-6 summaries that embed the keyword
1516            matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
1517            // 1-6 summaries that do NOT contain the keyword
1518            non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
1519        ) {
1520            // Ensure the keyword doesn't accidentally appear in non-matching summaries.
1521            for s in &non_matching {
1522                prop_assume!(!s.contains(keyword.as_str()));
1523            }
1524
1525            let store = in_memory_store();
1526
1527            // Save matching sessions (summary = "<suffix> <keyword> <suffix>")
1528            let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1529            for (i, suffix) in matching_suffixes.iter().enumerate() {
1530                let id = format!("match-{i}");
1531                let summary = format!("{} {} end", suffix, keyword);
1532                store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
1533                matching_ids.insert(id);
1534            }
1535
1536            // Save non-matching sessions
1537            let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1538            for (i, summary) in non_matching.iter().enumerate() {
1539                let id = format!("nomatch-{i}");
1540                store.save_session(&make_session(&id, "/proj", summary)).unwrap();
1541                non_matching_ids.insert(id);
1542            }
1543
1544            let results = store.search(&keyword).unwrap();
1545            let result_ids: std::collections::HashSet<String> =
1546                results.iter().map(|r| r.id.clone()).collect();
1547
1548            // Every matching session must appear in results.
1549            for id in &matching_ids {
1550                prop_assert!(
1551                    result_ids.contains(id),
1552                    "matching session '{}' not found in search results for keyword '{}'",
1553                    id, keyword
1554                );
1555            }
1556
1557            // No non-matching session may appear in results.
1558            for id in &non_matching_ids {
1559                prop_assert!(
1560                    !result_ids.contains(id),
1561                    "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
1562                    id, keyword
1563                );
1564            }
1565        }
1566    }
1567
1568    // ── Property: search_by_date correctness ─────────────────────────────────
1569    // **Validates: Requirements 20.4**
1570    //
1571    // For any set of sessions with different timestamps, searching by a date
1572    // range SHALL return exactly the sessions whose `updated_at` falls within
1573    // [from, to], and no sessions outside that range.
1574
1575    proptest! {
1576        /// **Validates: Requirements 20.4**
1577        ///
1578        /// For any set of sessions with distinct timestamps, `search_by_date`
1579        /// SHALL return exactly the sessions whose `updated_at` is within
1580        /// `[from, to]`, and no sessions outside that range.
1581        #[test]
1582        fn prop_search_by_date_correctness(
1583            // Generate 2-8 offsets in seconds from epoch (spread over a wide range)
1584            offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
1585            // The search window: start and end offsets (relative to the minimum offset)
1586            window_start_delta in 0i64..=3600i64,
1587            window_end_delta   in 3600i64..=7200i64,
1588        ) {
1589            use chrono::TimeZone;
1590
1591            // Deduplicate offsets so each session has a unique timestamp.
1592            let mut unique_offsets: Vec<i64> = offsets.clone();
1593            unique_offsets.sort_unstable();
1594            unique_offsets.dedup();
1595            prop_assume!(unique_offsets.len() >= 2);
1596
1597            let base_offset = unique_offsets[0];
1598            let from_offset = base_offset + window_start_delta;
1599            let to_offset   = base_offset + window_end_delta;
1600
1601            let from = Utc.timestamp_opt(from_offset, 0).unwrap();
1602            let to   = Utc.timestamp_opt(to_offset,   0).unwrap();
1603
1604            let store = in_memory_store();
1605
1606            let mut in_range_ids:  std::collections::HashSet<String> = std::collections::HashSet::new();
1607            let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1608
1609            for (i, &offset) in unique_offsets.iter().enumerate() {
1610                let ts = Utc.timestamp_opt(offset, 0).unwrap();
1611                let id = format!("sess-{i}");
1612                let session = make_session_at(&id, "some summary", ts);
1613                store.save_session(&session).unwrap();
1614
1615                if ts >= from && ts <= to {
1616                    in_range_ids.insert(id);
1617                } else {
1618                    out_range_ids.insert(id);
1619                }
1620            }
1621
1622            let results = store.search_by_date(from, to).unwrap();
1623            let result_ids: std::collections::HashSet<String> =
1624                results.iter().map(|r| r.id.clone()).collect();
1625
1626            // Every in-range session must appear.
1627            for id in &in_range_ids {
1628                prop_assert!(
1629                    result_ids.contains(id),
1630                    "in-range session '{}' missing from search_by_date results",
1631                    id
1632                );
1633            }
1634
1635            // No out-of-range session may appear.
1636            for id in &out_range_ids {
1637                prop_assert!(
1638                    !result_ids.contains(id),
1639                    "out-of-range session '{}' incorrectly appeared in search_by_date results",
1640                    id
1641                );
1642            }
1643        }
1644    }
1645}