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    /// First and last compression timestamps (RFC 3339), or `None` when
565    /// nothing has been logged yet.
566    pub fn compression_period(&self) -> Result<Option<(String, String)>> {
567        let row: Option<(Option<String>, Option<String>)> = self
568            .db
569            .query_row(
570                "SELECT MIN(created_at), MAX(created_at) FROM compression_log",
571                [],
572                |row| Ok((row.get(0)?, row.get(1)?)),
573            )
574            .ok();
575        Ok(match row {
576            Some((Some(a), Some(b))) => Some((a, b)),
577            _ => None,
578        })
579    }
580
581    /// Retrieve the stored original bytes for a cached hash, if the
582    /// caller populated them via `save_cache_entry_with_original`.
583    ///
584    /// Returns `Ok(None)` for missing entries AND for entries that were
585    /// saved by an older call site that did not pass `original`. The
586    /// caller should fall back to the compressed blob in the latter case
587    /// and surface a note to the user so they know this specific entry
588    /// wasn't round-trippable.
589    pub fn get_cache_entry_original(&self, hash: &str) -> Result<Option<Vec<u8>>> {
590        let result: rusqlite::Result<Option<Vec<u8>>> = self.db.query_row(
591            "SELECT original FROM cache_entries WHERE hash = ?1",
592            params![hash],
593            |row| row.get(0),
594        );
595        match result {
596            Ok(v) => Ok(v),
597            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
598            Err(e) => Err(SqzError::SessionStore(e)),
599        }
600    }
601
602    /// Most recently accessed entries that still hold their original bytes
603    /// and are at least `min_len` bytes long, newest first. Candidates for
604    /// slice matching: a ranged re-read can only be a slice of something
605    /// larger than itself.
606    pub fn recent_originals(&self, min_len: usize, limit: usize) -> Result<Vec<SliceCandidate>> {
607        let mut stmt = self.db.prepare(
608            "SELECT hash, original, data FROM cache_entries \
609             WHERE original IS NOT NULL AND length(original) > ?1 \
610             ORDER BY accessed_at DESC LIMIT ?2",
611        )?;
612        let rows = stmt.query_map(params![min_len as i64, limit as i64], |row| {
613            Ok(SliceCandidate {
614                hash: row.get(0)?,
615                original: row.get(1)?,
616                data_json: row.get(2)?,
617            })
618        })?;
619        let mut out = Vec::new();
620        for row in rows {
621            out.push(row?);
622        }
623        Ok(out)
624    }
625
626    /// Delete a cache entry by content hash.
627    pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
628        self.db.execute(
629            "DELETE FROM cache_entries WHERE hash = ?1",
630            params![hash],
631        )?;
632        let _ = self.db.execute(
633            "DELETE FROM recall_index WHERE ref_hash = ?1",
634            params![hash],
635        );
636        Ok(())
637    }
638
639    /// Return all cache entries ordered by `accessed_at` ASC (oldest first),
640    /// as `(hash, size_bytes)` pairs where `size_bytes` is the byte length of
641    /// the stored JSON data.
642    pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
643        let mut stmt = self.db.prepare(
644            "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
645        )?;
646        let rows = stmt.query_map([], |row| {
647            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
648        })?;
649        let mut entries = Vec::new();
650        for row in rows {
651            let (hash, size) = row?;
652            entries.push((hash, size as u64));
653        }
654        Ok(entries)
655    }
656
657    /// Retrieve a cache entry by content hash, updating `accessed_at`.
658    pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
659        let result: rusqlite::Result<String> = self.db.query_row(
660            "SELECT data FROM cache_entries WHERE hash = ?1",
661            params![hash],
662            |row| row.get(0),
663        );
664
665        match result {
666            Ok(data) => {
667                // Touch accessed_at for LRU tracking.
668                let now = Utc::now().to_rfc3339();
669                let _ = self.db.execute(
670                    "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
671                    params![now, hash],
672                );
673                let entry: CompressedContent = serde_json::from_str(&data)?;
674                Ok(Some(entry))
675            }
676            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
677            Err(e) => Err(SqzError::SessionStore(e)),
678        }
679    }
680
681    /// Look up a cache entry by a **prefix** of the content hash.
682    ///
683    /// The inline dedup refs we hand to the LLM carry only the first 16
684    /// hex chars of the SHA-256 (`§ref:<16-hex>§`), so when an agent runs
685    /// `sqz expand <prefix>` we need to resolve those 16 chars back to the
686    /// full 64-char key. Uses `LIKE 'prefix%'` with an index-friendly
687    /// anchored pattern so the query stays O(log n) on the primary key.
688    ///
689    /// Returns `Ok(Some((full_hash, entry)))` on unique match.
690    /// Returns `Ok(None)` if no entries match.
691    /// Returns `Err(_)` if the prefix is ambiguous (2+ matches) — the
692    /// caller should tell the user to use a longer prefix. 16-hex
693    /// collisions are astronomically unlikely (one in 2^64) but we
694    /// refuse to guess rather than quietly serve a surprise file.
695    ///
696    /// The prefix is validated as lowercase hex. Non-hex input returns
697    /// `None` without touching the database — this is how we handle the
698    /// common user-error case of pasting the ref with the `§` markers
699    /// still attached (they get rejected before we query SQLite).
700    pub fn get_cache_entry_by_prefix(
701        &self,
702        prefix: &str,
703    ) -> Result<Option<(String, CompressedContent)>> {
704        // Reject anything that isn't pure lowercase hex. The inline refs
705        // we emit are always lowercase so there's no reason to case-fold
706        // here; accidentally matching uppercase input would also match
707        // unrelated entries if someone hand-crafted a collision.
708        if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
709            return Ok(None);
710        }
711        let pattern = format!("{prefix}%");
712        let mut stmt = self
713            .db
714            .prepare("SELECT hash, data FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
715        let mut rows = stmt.query(params![pattern])?;
716
717        let first = match rows.next()? {
718            Some(r) => {
719                let hash: String = r.get(0)?;
720                let data: String = r.get(1)?;
721                (hash, data)
722            }
723            None => return Ok(None),
724        };
725
726        // Two or more hits — refuse. Prefix ambiguity is user-recoverable:
727        // rerun with a longer prefix. Matching one arbitrarily would be
728        // a silent data surprise.
729        if rows.next()?.is_some() {
730            return Err(SqzError::Other(format!(
731                "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
732            )));
733        }
734        drop(rows);
735        drop(stmt);
736
737        // Touch accessed_at for LRU tracking — symmetric with get_cache_entry.
738        let now = Utc::now().to_rfc3339();
739        let _ = self.db.execute(
740            "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
741            params![now, first.0],
742        );
743        let entry: CompressedContent = serde_json::from_str(&first.1)?;
744        Ok(Some((first.0, entry)))
745    }
746
747    /// Read the `accessed_at` timestamp for a cached hash without updating
748    /// it. Returns `None` if the hash is not cached.
749    ///
750    /// Used by the dedup freshness check: if `accessed_at` is recent, the
751    /// LLM likely still has the original content in its context window, so
752    /// returning a ref is safe. If it's old, re-send the full content.
753    pub fn get_cache_entry_accessed_at(&self, hash: &str) -> Result<Option<DateTime<Utc>>> {
754        let result: rusqlite::Result<String> = self.db.query_row(
755            "SELECT accessed_at FROM cache_entries WHERE hash = ?1",
756            params![hash],
757            |row| row.get(0),
758        );
759        match result {
760            Ok(s) => {
761                let ts = s
762                    .parse::<DateTime<Utc>>()
763                    .map_err(|e| SqzError::Other(format!("invalid accessed_at: {e}")))?;
764                Ok(Some(ts))
765            }
766            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
767            Err(e) => Err(SqzError::SessionStore(e)),
768        }
769    }
770
771    /// Check if a cache entry exists without updating `accessed_at`.
772    pub fn cache_entry_exists(&self, hash: &str) -> Result<bool> {
773        let result: rusqlite::Result<i64> = self.db.query_row(
774            "SELECT 1 FROM cache_entries WHERE hash = ?1",
775            params![hash],
776            |row| row.get(0),
777        );
778        match result {
779            Ok(_) => Ok(true),
780            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
781            Err(e) => Err(SqzError::SessionStore(e)),
782        }
783    }
784
785    /// Update `accessed_at` for a cached hash to the current time. Called by
786    /// the cache manager when a ref is served so the next staleness check
787    /// sees the recent send.
788    pub fn touch_cache_entry(&self, hash: &str) -> Result<()> {
789        let now = Utc::now().to_rfc3339();
790        self.db.execute(
791            "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
792            params![now, hash],
793        )?;
794        Ok(())
795    }
796
797    /// Resolve a hash prefix (or full hash) to the unique full hash of
798    /// the matching cache entry. Returns `Ok(None)` for non-existent
799    /// entries. Returns an error if the prefix is ambiguous (matches
800    /// more than one entry) so the caller can ask for a longer prefix.
801    /// Does not touch `accessed_at`.
802    pub fn resolve_cache_hash_by_prefix(&self, prefix: &str) -> Result<Option<String>> {
803        if prefix.is_empty()
804            || !prefix
805                .chars()
806                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
807        {
808            return Ok(None);
809        }
810        let pattern = format!("{prefix}%");
811        let mut stmt = self
812            .db
813            .prepare("SELECT hash FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
814        let mut rows = stmt.query(params![pattern])?;
815        let first: Option<String> = match rows.next()? {
816            Some(r) => Some(r.get(0)?),
817            None => None,
818        };
819        if rows.next()?.is_some() {
820            return Err(SqzError::Other(format!(
821                "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
822            )));
823        }
824        Ok(first)
825    }
826
827    /// Mark a cache entry as pinned so it bypasses the TTL freshness
828    /// check. Returns `Ok(true)` if a row was updated, `Ok(false)` if the
829    /// hash is not in the cache.
830    pub fn pin_cache_entry(&self, hash: &str) -> Result<bool> {
831        let n = self.db.execute(
832            "UPDATE cache_entries SET pinned = 1 WHERE hash = ?1",
833            params![hash],
834        )?;
835        Ok(n > 0)
836    }
837
838    /// Unpin a cache entry so it returns to normal TTL-based freshness.
839    /// Returns `Ok(true)` if a row was updated, `Ok(false)` otherwise.
840    pub fn unpin_cache_entry(&self, hash: &str) -> Result<bool> {
841        let n = self.db.execute(
842            "UPDATE cache_entries SET pinned = 0 WHERE hash = ?1",
843            params![hash],
844        )?;
845        Ok(n > 0)
846    }
847
848    /// Unpin every cache entry. Returns the count of rows that were
849    /// previously pinned.
850    pub fn unpin_all_cache_entries(&self) -> Result<usize> {
851        let n = self
852            .db
853            .execute("UPDATE cache_entries SET pinned = 0 WHERE pinned = 1", [])?;
854        Ok(n)
855    }
856
857    /// Whether the given hash is pinned. Returns `false` for unpinned
858    /// entries AND for hashes not in the cache.
859    pub fn is_cache_entry_pinned(&self, hash: &str) -> Result<bool> {
860        let result: rusqlite::Result<i64> = self.db.query_row(
861            "SELECT pinned FROM cache_entries WHERE hash = ?1",
862            params![hash],
863            |row| row.get(0),
864        );
865        match result {
866            Ok(v) => Ok(v != 0),
867            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
868            Err(e) => Err(SqzError::SessionStore(e)),
869        }
870    }
871
872    /// List every pinned cache entry. Returns `(hash, accessed_at, size_bytes)`
873    /// tuples so the CLI can render a useful table.
874    pub fn list_pinned_cache_entries(
875        &self,
876    ) -> Result<Vec<(String, DateTime<Utc>, u64)>> {
877        let mut stmt = self
878            .db
879            .prepare(
880                "SELECT hash, accessed_at, length(data) FROM cache_entries \
881                 WHERE pinned = 1 ORDER BY accessed_at DESC",
882            )
883            .map_err(SqzError::SessionStore)?;
884        let rows = stmt
885            .query_map([], |row| {
886                Ok((
887                    row.get::<_, String>(0)?,
888                    row.get::<_, String>(1)?,
889                    row.get::<_, i64>(2)? as u64,
890                ))
891            })
892            .map_err(SqzError::SessionStore)?;
893        let mut out = Vec::new();
894        for row in rows {
895            let (hash, accessed_at_str, size) = row?;
896            let accessed_at = accessed_at_str.parse::<DateTime<Utc>>().map_err(|e| {
897                SqzError::Other(format!("invalid accessed_at on pinned entry: {e}"))
898            })?;
899            out.push((hash, accessed_at, size));
900        }
901        Ok(out)
902    }
903
904    /// Set a metadata key/value. Persists across sqz process boundaries
905    /// (each shell-hook invocation is a short-lived process).
906    pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
907        self.db.execute(
908            "INSERT INTO metadata (key, value) VALUES (?1, ?2)
909             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
910            params![key, value],
911        )?;
912        Ok(())
913    }
914
915    /// Get a metadata value. Returns `None` if the key has never been set.
916    pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
917        let result: rusqlite::Result<String> = self.db.query_row(
918            "SELECT value FROM metadata WHERE key = ?1",
919            params![key],
920            |row| row.get(0),
921        );
922        match result {
923            Ok(v) => Ok(Some(v)),
924            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
925            Err(e) => Err(SqzError::SessionStore(e)),
926        }
927    }
928
929    /// Log a compression event for cumulative stats tracking.
930    pub fn log_compression(
931        &self,
932        tokens_original: u32,
933        tokens_compressed: u32,
934        stages: &[String],
935        mode: &str,
936    ) -> Result<()> {
937        self.log_compression_with_project(tokens_original, tokens_compressed, stages, mode, None)
938    }
939
940    /// Log a compression event tagged with a project directory.
941    pub fn log_compression_with_project(
942        &self,
943        tokens_original: u32,
944        tokens_compressed: u32,
945        stages: &[String],
946        mode: &str,
947        project_dir: Option<&str>,
948    ) -> Result<()> {
949        let now = Utc::now().to_rfc3339();
950        let stages_str = stages.join(",");
951        self.db.execute(
952            "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at, project_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
953            params![tokens_original, tokens_compressed, stages_str, mode, now, project_dir],
954        ).map_err(SqzError::SessionStore)?;
955        Ok(())
956    }
957
958    /// Log a compression-regret signal. `kind` is `"rerun"` (byte-identical
959    /// output re-produced within the regret window) or `"expand"` (a
960    /// `§ref:…§` token expanded back to original bytes). `gap_secs` is the
961    /// time since the content was last seen; 0.0 when unknown.
962    pub fn log_regret(&self, kind: &str, cmd: &str, hash: &str, gap_secs: f64) -> Result<()> {
963        let now = Utc::now().to_rfc3339();
964        self.db.execute(
965            "INSERT INTO regret_log (kind, cmd, hash, gap_secs, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
966            params![kind, cmd, hash, gap_secs, now],
967        ).map_err(SqzError::SessionStore)?;
968        Ok(())
969    }
970
971    /// Aggregate regret counts for `sqz stats`.
972    pub fn regret_stats(&self) -> Result<RegretStats> {
973        let mut stmt = self.db.prepare(
974            "SELECT \
975               COALESCE(SUM(CASE WHEN kind = 'rerun' THEN 1 ELSE 0 END), 0), \
976               COALESCE(SUM(CASE WHEN kind = 'expand' THEN 1 ELSE 0 END), 0), \
977               COALESCE(AVG(CASE WHEN kind = 'rerun' THEN gap_secs END), 0.0) \
978             FROM regret_log",
979        ).map_err(SqzError::SessionStore)?;
980        let stats = stmt.query_row([], |row| {
981            Ok(RegretStats {
982                reruns: row.get::<_, u32>(0)?,
983                expands: row.get::<_, u32>(1)?,
984                avg_rerun_gap_secs: row.get::<_, f64>(2)?,
985            })
986        }).map_err(SqzError::SessionStore)?;
987        Ok(stats)
988    }
989
990    /// Command families where compression is barely helping: meaningful
991    /// volume, under 25% reduction, ranked by tokens that still reached
992    /// the model. This is the formatter backlog, computed from real
993    /// history instead of guesses. Dedup rows are excluded (a ref is
994    /// already the best possible outcome).
995    pub fn formatter_gaps(&self, days: u32, limit: u32) -> Result<Vec<CommandStats>> {
996        let offset = format!("-{days} days");
997        let mut stmt = self.db.prepare(
998            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
999             FROM compression_log \
1000             WHERE created_at >= date('now', ?1) AND mode NOT IN ('dedup', 'slice') \
1001             GROUP BY mode \
1002             HAVING SUM(tokens_original) >= 500 \
1003                AND (SUM(tokens_original) - SUM(tokens_compressed)) * 100.0 \
1004                    / SUM(tokens_original) < 25.0 \
1005             ORDER BY SUM(tokens_compressed) DESC \
1006             LIMIT ?2",
1007        ).map_err(SqzError::SessionStore)?;
1008        let rows = stmt.query_map(params![offset, limit], |row| {
1009            let tokens_in: u64 = row.get(2)?;
1010            let tokens_out: u64 = row.get(3)?;
1011            Ok(CommandStats {
1012                command: row.get(0)?,
1013                invocations: row.get(1)?,
1014                tokens_in,
1015                tokens_out,
1016                tokens_saved: tokens_in.saturating_sub(tokens_out),
1017            })
1018        }).map_err(SqzError::SessionStore)?;
1019        let mut results = Vec::new();
1020        for row in rows {
1021            results.push(row.map_err(SqzError::SessionStore)?);
1022        }
1023        Ok(results)
1024    }
1025
1026    /// Rerun-regret counts grouped by command, most regretted first.
1027    pub fn regret_by_command(&self, limit: u32) -> Result<Vec<(String, u32)>> {
1028        let mut stmt = self.db.prepare(
1029            "SELECT cmd, COUNT(*) FROM regret_log \
1030             WHERE kind = 'rerun' AND cmd != '' \
1031             GROUP BY cmd ORDER BY COUNT(*) DESC LIMIT ?1",
1032        ).map_err(SqzError::SessionStore)?;
1033        let rows = stmt.query_map(params![limit], |row| {
1034            Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1035        }).map_err(SqzError::SessionStore)?;
1036        let mut results = Vec::new();
1037        for row in rows {
1038            results.push(row.map_err(SqzError::SessionStore)?);
1039        }
1040        Ok(results)
1041    }
1042
1043    /// Get cumulative compression stats from the log.
1044    pub fn compression_stats(&self) -> Result<CompressionStats> {
1045        let mut stmt = self.db.prepare(
1046            "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
1047        ).map_err(SqzError::SessionStore)?;
1048
1049        let stats = stmt.query_row([], |row| {
1050            Ok(CompressionStats {
1051                total_compressions: row.get::<_, u32>(0)?,
1052                total_tokens_in: row.get::<_, u64>(1)?,
1053                total_tokens_out: row.get::<_, u64>(2)?,
1054            })
1055        }).map_err(SqzError::SessionStore)?;
1056
1057        Ok(stats)
1058    }
1059
1060    /// Get daily compression gains for the last N days.
1061    pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
1062        let mut stmt = self.db.prepare(
1063            "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1064             FROM compression_log \
1065             WHERE created_at >= date('now', ?1) \
1066             GROUP BY d ORDER BY d",
1067        ).map_err(SqzError::SessionStore)?;
1068
1069        let offset = format!("-{days} days");
1070        let rows = stmt.query_map(params![offset], |row| {
1071            let tokens_in: u64 = row.get(2)?;
1072            let tokens_out: u64 = row.get(3)?;
1073            Ok(DailyGain {
1074                date: row.get(0)?,
1075                compressions: row.get(1)?,
1076                tokens_in,
1077                tokens_saved: tokens_in.saturating_sub(tokens_out),
1078            })
1079        }).map_err(SqzError::SessionStore)?;
1080
1081        let mut gains = Vec::new();
1082        for row in rows {
1083            gains.push(row.map_err(SqzError::SessionStore)?);
1084        }
1085        Ok(gains)
1086    }
1087
1088    /// Get cumulative compression stats for a specific project directory.
1089    pub fn compression_stats_for_project(&self, project_dir: &str) -> Result<CompressionStats> {
1090        let mut stmt = self.db.prepare(
1091            "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) \
1092             FROM compression_log WHERE project_dir = ?1",
1093        ).map_err(SqzError::SessionStore)?;
1094
1095        let stats = stmt.query_row(params![project_dir], |row| {
1096            Ok(CompressionStats {
1097                total_compressions: row.get::<_, u32>(0)?,
1098                total_tokens_in: row.get::<_, u64>(1)?,
1099                total_tokens_out: row.get::<_, u64>(2)?,
1100            })
1101        }).map_err(SqzError::SessionStore)?;
1102
1103        Ok(stats)
1104    }
1105
1106    /// Get daily compression gains for a specific project directory.
1107    pub fn daily_gains_for_project(&self, days: u32, project_dir: &str) -> Result<Vec<DailyGain>> {
1108        let mut stmt = self.db.prepare(
1109            "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1110             FROM compression_log \
1111             WHERE created_at >= date('now', ?1) AND project_dir = ?2 \
1112             GROUP BY d ORDER BY d",
1113        ).map_err(SqzError::SessionStore)?;
1114
1115        let offset = format!("-{days} days");
1116        let rows = stmt.query_map(params![offset, project_dir], |row| {
1117            let tokens_in: u64 = row.get(2)?;
1118            let tokens_out: u64 = row.get(3)?;
1119            Ok(DailyGain {
1120                date: row.get(0)?,
1121                compressions: row.get(1)?,
1122                tokens_in,
1123                tokens_saved: tokens_in.saturating_sub(tokens_out),
1124            })
1125        }).map_err(SqzError::SessionStore)?;
1126
1127        let mut gains = Vec::new();
1128        for row in rows {
1129            gains.push(row.map_err(SqzError::SessionStore)?);
1130        }
1131        Ok(gains)
1132    }
1133
1134    /// List distinct project directories that have compression data.
1135    pub fn list_projects(&self) -> Result<Vec<(String, u32, u64)>> {
1136        let mut stmt = self.db.prepare(
1137            "SELECT project_dir, COUNT(*), COALESCE(SUM(tokens_original) - SUM(tokens_compressed), 0) \
1138             FROM compression_log \
1139             WHERE project_dir IS NOT NULL \
1140             GROUP BY project_dir \
1141             ORDER BY COUNT(*) DESC",
1142        ).map_err(SqzError::SessionStore)?;
1143
1144        let rows = stmt.query_map([], |row| {
1145            Ok((
1146                row.get::<_, String>(0)?,
1147                row.get::<_, u32>(1)?,
1148                row.get::<_, u64>(2)?,
1149            ))
1150        }).map_err(SqzError::SessionStore)?;
1151
1152        let mut projects = Vec::new();
1153        for row in rows {
1154            projects.push(row.map_err(SqzError::SessionStore)?);
1155        }
1156        Ok(projects)
1157    }
1158
1159    /// Per-command breakdown of token usage. Returns top N commands by
1160    /// total tokens consumed, with compression stats for each.
1161    /// Used by `sqz stats --breakdown` to show where tokens are going.
1162    pub fn command_breakdown(&self, limit: u32) -> Result<Vec<CommandStats>> {
1163        let mut stmt = self.db.prepare(
1164            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1165             FROM compression_log \
1166             GROUP BY mode \
1167             ORDER BY SUM(tokens_original) DESC \
1168             LIMIT ?1",
1169        ).map_err(SqzError::SessionStore)?;
1170
1171        let rows = stmt.query_map(params![limit], |row| {
1172            let tokens_in: u64 = row.get(2)?;
1173            let tokens_out: u64 = row.get(3)?;
1174            Ok(CommandStats {
1175                command: row.get(0)?,
1176                invocations: row.get(1)?,
1177                tokens_in,
1178                tokens_out,
1179                tokens_saved: tokens_in.saturating_sub(tokens_out),
1180            })
1181        }).map_err(SqzError::SessionStore)?;
1182
1183        let mut results = Vec::new();
1184        for row in rows {
1185            results.push(row.map_err(SqzError::SessionStore)?);
1186        }
1187        Ok(results)
1188    }
1189
1190    /// Per-command breakdown filtered to a specific project.
1191    pub fn command_breakdown_for_project(&self, limit: u32, project_dir: &str) -> Result<Vec<CommandStats>> {
1192        let mut stmt = self.db.prepare(
1193            "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1194             FROM compression_log \
1195             WHERE project_dir = ?1 \
1196             GROUP BY mode \
1197             ORDER BY SUM(tokens_original) DESC \
1198             LIMIT ?2",
1199        ).map_err(SqzError::SessionStore)?;
1200
1201        let rows = stmt.query_map(params![project_dir, limit], |row| {
1202            let tokens_in: u64 = row.get(2)?;
1203            let tokens_out: u64 = row.get(3)?;
1204            Ok(CommandStats {
1205                command: row.get(0)?,
1206                invocations: row.get(1)?,
1207                tokens_in,
1208                tokens_out,
1209                tokens_saved: tokens_in.saturating_sub(tokens_out),
1210            })
1211        }).map_err(SqzError::SessionStore)?;
1212
1213        let mut results = Vec::new();
1214        for row in rows {
1215            results.push(row.map_err(SqzError::SessionStore)?);
1216        }
1217        Ok(results)
1218    }
1219
1220    /// Get the total tokens injected in the current session (since last
1221    /// compaction or start). Used by the adaptive pressure system to
1222    /// decide whether to escalate compression intensity.
1223    pub fn session_pressure(&self, since_minutes: u32) -> Result<u64> {
1224        let mut stmt = self.db.prepare(
1225            "SELECT COALESCE(SUM(tokens_compressed), 0) \
1226             FROM compression_log \
1227             WHERE created_at >= datetime('now', ?1)",
1228        ).map_err(SqzError::SessionStore)?;
1229
1230        let offset = format!("-{since_minutes} minutes");
1231        let total: u64 = stmt.query_row(params![offset], |row| {
1232            row.get(0)
1233        }).map_err(SqzError::SessionStore)?;
1234
1235        Ok(total)
1236    }
1237
1238    // ── Known files (persistent cross-command context tracking) ───────────
1239
1240    /// Record a file path as "known" (its content is in the dedup cache).
1241    /// Used by cross-command context refs to annotate error messages.
1242    pub fn add_known_file(&self, path: &str) -> Result<()> {
1243        let now = Utc::now().to_rfc3339();
1244        self.db.execute(
1245            "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
1246            params![path, now],
1247        ).map_err(SqzError::SessionStore)?;
1248        Ok(())
1249    }
1250
1251    /// Load all known file paths from the persistent store.
1252    pub fn known_files(&self) -> Result<Vec<String>> {
1253        let mut stmt = self.db.prepare(
1254            "SELECT path FROM known_files ORDER BY added_at DESC",
1255        ).map_err(SqzError::SessionStore)?;
1256
1257        let rows = stmt.query_map([], |row| {
1258            row.get::<_, String>(0)
1259        }).map_err(SqzError::SessionStore)?;
1260
1261        let mut files = Vec::new();
1262        for row in rows {
1263            files.push(row.map_err(SqzError::SessionStore)?);
1264        }
1265        Ok(files)
1266    }
1267    /// Clear all known files (e.g. on session reset).
1268    pub fn clear_known_files(&self) -> Result<()> {
1269        self.db
1270            .execute("DELETE FROM known_files", [])
1271            .map_err(SqzError::SessionStore)?;
1272        Ok(())
1273    }
1274
1275    /// Number of entries currently in the dedup cache.
1276    pub fn cache_entry_count(&self) -> Result<u64> {
1277        self.db.query_row(
1278            "SELECT COUNT(*) FROM cache_entries", [],
1279            |row| row.get(0),
1280        ).map_err(SqzError::SessionStore)
1281    }
1282
1283    /// Increment the cache-hit counter (persisted in metadata).
1284    pub fn record_cache_hit(&self) -> Result<()> {
1285        let count: u64 = self.db.query_row(
1286            "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1287            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1288        ).unwrap_or(0);
1289        let new = count + 1;
1290        self.db.execute(
1291            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_hits', ?1)",
1292            params![new.to_string()],
1293        )?;
1294        Ok(())
1295    }
1296
1297    /// Increment the cache-miss counter (persisted in metadata).
1298    pub fn record_cache_miss(&self) -> Result<()> {
1299        let count: u64 = self.db.query_row(
1300            "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1301            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1302        ).unwrap_or(0);
1303        let new = count + 1;
1304        self.db.execute(
1305            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_misses', ?1)",
1306            params![new.to_string()],
1307        )?;
1308        Ok(())
1309    }
1310
1311    /// Return (cache_hits, cache_misses) from metadata counters.
1312    pub fn cache_hit_miss_counts(&self) -> Result<(u64, u64)> {
1313        let hits: u64 = self.db.query_row(
1314            "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1315            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1316        ).unwrap_or(0);
1317        let misses: u64 = self.db.query_row(
1318            "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1319            |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1320        ).unwrap_or(0);
1321        Ok((hits, misses))
1322    }
1323
1324    /// Clear the dedup cache. After this, all future reads will be treated
1325    /// as cache misses and compressed fresh. Does NOT clear compression
1326    /// stats or session history.
1327    pub fn clear_cache(&self) -> Result<u64> {
1328        let count: u64 = self.db.query_row(
1329            "SELECT COUNT(*) FROM cache_entries", [],
1330            |row| row.get(0),
1331        ).unwrap_or(0);
1332        self.db.execute("DELETE FROM cache_entries", [])
1333            .map_err(SqzError::SessionStore)?;
1334        let _ = self.db.execute(
1335            "DELETE FROM recall_index WHERE kind = 'output'", [],
1336        );
1337        // Also clear the compaction marker so freshness checks don't
1338        // reference timestamps from the deleted entries.
1339        let _ = self.db.execute(
1340            "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1341        );
1342        Ok(count)
1343    }
1344
1345    /// Clear compression stats (the `compression_log` table). After this,
1346    /// `sqz stats` and `sqz gain` will show zero until new compressions
1347    /// happen.
1348    pub fn clear_stats(&self) -> Result<u64> {
1349        let count: u64 = self.db.query_row(
1350            "SELECT COUNT(*) FROM compression_log", [],
1351            |row| row.get(0),
1352        ).unwrap_or(0);
1353        self.db.execute("DELETE FROM compression_log", [])
1354            .map_err(SqzError::SessionStore)?;
1355        Ok(count)
1356    }
1357
1358    /// Clear compression stats for a specific project directory only.
1359    pub fn clear_stats_for_project(&self, project_dir: &str) -> Result<u64> {
1360        let count: u64 = self.db.query_row(
1361            "SELECT COUNT(*) FROM compression_log WHERE project_dir = ?1",
1362            rusqlite::params![project_dir],
1363            |row| row.get(0),
1364        ).unwrap_or(0);
1365        self.db.execute(
1366            "DELETE FROM compression_log WHERE project_dir = ?1",
1367            rusqlite::params![project_dir],
1368        ).map_err(SqzError::SessionStore)?;
1369        Ok(count)
1370    }
1371
1372    /// Full reset: clear cache, stats, sessions, known files, and metadata.
1373    /// Equivalent to deleting sessions.db and letting sqz recreate it.
1374    pub fn reset_all(&self) -> Result<()> {
1375        self.db.execute("DELETE FROM cache_entries", []).map_err(SqzError::SessionStore)?;
1376        self.db.execute("DELETE FROM compression_log", []).map_err(SqzError::SessionStore)?;
1377        self.db.execute("DELETE FROM sessions", []).map_err(SqzError::SessionStore)?;
1378        self.db.execute("DELETE FROM known_files", []).map_err(SqzError::SessionStore)?;
1379        self.db.execute("DELETE FROM metadata", []).map_err(SqzError::SessionStore)?;
1380        let _ = self.db.execute("DELETE FROM recall_index", []);
1381        // VACUUM to reclaim disk space.
1382        let _ = self.db.execute("VACUUM", []);
1383        Ok(())
1384    }
1385
1386    /// Return all sessions ordered by most recently updated, limited to `limit`.
1387    pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionSummary>> {
1388        let mut stmt = self
1389            .db
1390            .prepare(
1391                "SELECT id, project_dir, compressed_summary, created_at, updated_at \
1392             FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1393            )
1394            .map_err(SqzError::SessionStore)?;
1395
1396        let rows = stmt
1397            .query_map(params![limit], |row| {
1398                Ok((
1399                    row.get::<_, String>(0)?,
1400                    row.get::<_, String>(1)?,
1401                    row.get::<_, String>(2)?,
1402                    row.get::<_, String>(3)?,
1403                    row.get::<_, String>(4)?,
1404                ))
1405            })
1406            .map_err(SqzError::SessionStore)?;
1407
1408        let mut results = Vec::new();
1409        for row in rows {
1410            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
1411            results.push(row_to_summary(
1412                id,
1413                project_dir,
1414                compressed_summary,
1415                created_at,
1416                updated_at,
1417            )?);
1418        }
1419        Ok(results)
1420    }
1421
1422    /// Per-tool breakdown: group compression_log by stages_applied (comma-split).
1423    pub fn per_tool_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1424        let mut stmt = self
1425            .db
1426            .prepare(
1427                "SELECT stages_applied, SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1428             FROM compression_log GROUP BY stages_applied ORDER BY COUNT(*) DESC LIMIT 20",
1429            )
1430            .map_err(SqzError::SessionStore)?;
1431
1432        let rows = stmt
1433            .query_map([], |row| {
1434                Ok((
1435                    row.get::<_, String>(0)?,
1436                    row.get::<_, u64>(1)?,
1437                    row.get::<_, u64>(2)?,
1438                    row.get::<_, u32>(3)?,
1439                ))
1440            })
1441            .map_err(SqzError::SessionStore)?;
1442
1443        let mut tool_map: std::collections::HashMap<String, (u64, u64, u32)> =
1444            std::collections::HashMap::new();
1445        for row in rows {
1446            let (stages, tokens_in, tokens_out, count) = row?;
1447            for stage in stages.split(',') {
1448                let stage = stage.trim();
1449                if stage.is_empty() {
1450                    continue;
1451                }
1452                let entry = tool_map.entry(stage.to_string()).or_default();
1453                entry.0 += tokens_in;
1454                entry.1 += tokens_out;
1455                entry.2 += count;
1456            }
1457        }
1458
1459        let mut result: Vec<_> = tool_map.into_iter().collect();
1460        result.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
1461        Ok(result
1462            .into_iter()
1463            .map(|(name, (tin, tout, cnt))| (name, tin, tout, cnt))
1464            .collect())
1465    }
1466
1467    /// Per-command breakdown: group compression_log by mode.
1468    pub fn per_command_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1469        let mut stmt = self
1470            .db
1471            .prepare(
1472                "SELECT COALESCE(mode, 'auto'), SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1473             FROM compression_log GROUP BY mode ORDER BY COUNT(*) DESC LIMIT 20",
1474            )
1475            .map_err(SqzError::SessionStore)?;
1476
1477        let rows = stmt
1478            .query_map([], |row| {
1479                Ok((
1480                    row.get::<_, String>(0)?,
1481                    row.get::<_, u64>(1)?,
1482                    row.get::<_, u64>(2)?,
1483                    row.get::<_, u32>(3)?,
1484                ))
1485            })
1486            .map_err(SqzError::SessionStore)?;
1487
1488        let mut result = Vec::new();
1489        for row in rows {
1490            result.push(row?);
1491        }
1492        Ok(result)
1493    }
1494}
1495
1496/// Cumulative compression statistics.
1497#[derive(Debug, Clone, Default)]
1498pub struct CompressionStats {
1499    pub total_compressions: u32,
1500    pub total_tokens_in: u64,
1501    pub total_tokens_out: u64,
1502}
1503
1504impl CompressionStats {
1505    pub fn tokens_saved(&self) -> u64 {
1506        self.total_tokens_in.saturating_sub(self.total_tokens_out)
1507    }
1508
1509    pub fn reduction_pct(&self) -> f64 {
1510        if self.total_tokens_in == 0 {
1511            0.0
1512        } else {
1513            (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
1514        }
1515    }
1516}
1517
1518/// A single day's compression gain.
1519#[derive(Debug, Clone)]
1520pub struct DailyGain {
1521    pub date: String,
1522    pub compressions: u32,
1523    pub tokens_saved: u64,
1524    pub tokens_in: u64,
1525}
1526
1527/// Per-command token usage breakdown.
1528#[derive(Debug, Clone)]
1529pub struct CommandStats {
1530    pub command: String,
1531    pub invocations: u32,
1532    pub tokens_in: u64,
1533    pub tokens_out: u64,
1534    pub tokens_saved: u64,
1535}
1536
1537/// A cache entry with its original bytes and the served form (JSON
1538/// `CompressedContent`), as returned by [`SessionStore::recent_originals`].
1539#[derive(Debug, Clone)]
1540pub struct SliceCandidate {
1541    pub hash: String,
1542    pub original: Vec<u8>,
1543    pub data_json: String,
1544}
1545
1546/// One `sqz recall` search hit.
1547#[derive(Debug, Clone)]
1548pub struct RecallHit {
1549    /// Content hash (kind `output`, expandable via `sqz expand`) or
1550    /// session id (kind `session`).
1551    pub ref_hash: String,
1552    pub kind: String,
1553    /// Match context with `>>`/`<<` around matched terms.
1554    pub snippet: String,
1555    pub created_at: String,
1556}
1557
1558/// Build a safe FTS5 MATCH expression from free-form user input: each
1559/// whitespace-separated term becomes a quoted phrase (implicit AND), so
1560/// FTS5 operator characters in the input can't break the query syntax.
1561fn fts_query(raw: &str) -> String {
1562    raw.split_whitespace()
1563        .filter_map(|term| {
1564            let cleaned = term.replace('"', "");
1565            if cleaned.is_empty() {
1566                None
1567            } else {
1568                Some(format!("\"{cleaned}\""))
1569            }
1570        })
1571        .collect::<Vec<_>>()
1572        .join(" ")
1573}
1574
1575/// Aggregated compression-regret signals (see `regret_log`).
1576#[derive(Debug, Clone, Default)]
1577pub struct RegretStats {
1578    /// Byte-identical output re-produced within the regret window.
1579    pub reruns: u32,
1580    /// `§ref:…§` tokens expanded back to original bytes.
1581    pub expands: u32,
1582    /// Mean seconds between last sighting and the rerun.
1583    pub avg_rerun_gap_secs: f64,
1584}
1585
1586impl CommandStats {
1587    pub fn reduction_pct(&self) -> f64 {
1588        if self.tokens_in == 0 {
1589            0.0
1590        } else {
1591            (1.0 - self.tokens_out as f64 / self.tokens_in as f64) * 100.0
1592        }
1593    }
1594}
1595
1596// ── Tests ─────────────────────────────────────────────────────────────────────
1597
1598#[cfg(test)]
1599mod tests {
1600    use super::*;
1601    use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
1602    use chrono::Utc;
1603    use proptest::prelude::*;
1604    use std::path::PathBuf;
1605
1606    fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
1607        let now = Utc::now();
1608        SessionState {
1609            id: id.to_string(),
1610            project_dir: PathBuf::from(project_dir),
1611            conversation: vec![],
1612            corrections: CorrectionLog::default(),
1613            pins: vec![],
1614            learnings: vec![],
1615            compressed_summary: summary.to_string(),
1616            budget: BudgetState {
1617                window_size: 200_000,
1618                consumed: 0,
1619                pinned: 0,
1620                model_family: ModelFamily::AnthropicClaude,
1621            },
1622            tool_usage: vec![],
1623            created_at: now,
1624            updated_at: now,
1625        }
1626    }
1627
1628    fn in_memory_store() -> SessionStore {
1629        let conn = Connection::open_in_memory().unwrap();
1630        apply_schema(&conn).unwrap();
1631        SessionStore { db: conn }
1632    }
1633
1634    fn test_content(data: &str) -> CompressedContent {
1635        CompressedContent {
1636            data: data.to_string(),
1637            tokens_compressed: 1,
1638            tokens_original: 2,
1639            stages_applied: vec![],
1640            compression_ratio: 0.5,
1641            provenance: crate::types::Provenance::default(),
1642            verify: None,
1643        }
1644    }
1645
1646    #[test]
1647    fn recall_indexes_cache_originals_and_searches() {
1648        let store = in_memory_store();
1649        let content = test_content("compressed");
1650        store
1651            .save_cache_entry_with_original(
1652                "hash-auth",
1653                &content,
1654                Some(b"error: auth middleware rejected the session token"),
1655            )
1656            .unwrap();
1657        store
1658            .save_cache_entry_with_original(
1659                "hash-build",
1660                &content,
1661                Some(b"cargo build finished in 32s with zero warnings"),
1662            )
1663            .unwrap();
1664
1665        let hits = store.recall_search("auth middleware", 5).unwrap();
1666        assert_eq!(hits.len(), 1);
1667        assert_eq!(hits[0].ref_hash, "hash-auth");
1668        assert_eq!(hits[0].kind, "output");
1669        assert!(hits[0].snippet.contains(">>auth<<"), "snippet: {}", hits[0].snippet);
1670
1671        // Re-saving the same hash must not duplicate index rows.
1672        store
1673            .save_cache_entry_with_original(
1674                "hash-auth",
1675                &content,
1676                Some(b"error: auth middleware rejected the session token"),
1677            )
1678            .unwrap();
1679        assert_eq!(store.recall_search("middleware", 5).unwrap().len(), 1);
1680    }
1681
1682    #[test]
1683    fn recall_skips_binary_and_survives_odd_queries() {
1684        let store = in_memory_store();
1685        let content = test_content("c");
1686        store
1687            .save_cache_entry_with_original("hash-bin", &content, Some(b"binary\x00payload"))
1688            .unwrap();
1689        assert!(store.recall_search("binary", 5).unwrap().is_empty());
1690
1691        // FTS5 operator characters in the query must not error.
1692        for q in ["NOT AND OR", "\"unbalanced", "col:umn", "wild*", "(paren", "-", ""] {
1693            let _ = store.recall_search(q, 5).unwrap();
1694        }
1695    }
1696
1697    #[test]
1698    fn recall_deletion_and_clear_coherence() {
1699        let store = in_memory_store();
1700        let content = test_content("c");
1701        store
1702            .save_cache_entry_with_original("hash-x", &content, Some(b"kubectl rollout restarted deployment"))
1703            .unwrap();
1704        assert_eq!(store.recall_search("rollout", 5).unwrap().len(), 1);
1705
1706        store.delete_cache_entry("hash-x").unwrap();
1707        assert!(store.recall_search("rollout", 5).unwrap().is_empty());
1708
1709        // clear_cache drops output entries but keeps session summaries.
1710        store
1711            .save_cache_entry_with_original("hash-y", &content, Some(b"terraform plan shows three changes"))
1712            .unwrap();
1713        let session = make_session("sess-recall", "/tmp/p", "refactored the billing retry loop");
1714        store.save_session(&session).unwrap();
1715        store.clear_cache().unwrap();
1716        assert!(store.recall_search("terraform", 5).unwrap().is_empty());
1717        let session_hits = store.recall_search("billing retry", 5).unwrap();
1718        assert_eq!(session_hits.len(), 1);
1719        assert_eq!(session_hits[0].kind, "session");
1720        assert_eq!(session_hits[0].ref_hash, "sess-recall");
1721
1722        store.reset_all().unwrap();
1723        assert!(store.recall_search("billing", 5).unwrap().is_empty());
1724    }
1725
1726    #[test]
1727    fn recall_ranks_better_matches_first() {
1728        let store = in_memory_store();
1729        let content = test_content("c");
1730        store
1731            .save_cache_entry_with_original(
1732                "hash-dense",
1733                &content,
1734                Some(b"panic unwrap panic unwrap panic in worker thread"),
1735            )
1736            .unwrap();
1737        store
1738            .save_cache_entry_with_original(
1739                "hash-sparse",
1740                &content,
1741                Some(format!("{} panic once at the end", "filler line of ordinary words\n".repeat(50)).as_bytes()),
1742            )
1743            .unwrap();
1744        let hits = store.recall_search("panic", 5).unwrap();
1745        assert_eq!(hits.len(), 2);
1746        assert_eq!(hits[0].ref_hash, "hash-dense", "bm25 should rank the denser match first");
1747    }
1748
1749    #[test]
1750    fn formatter_gaps_ranks_low_reduction_high_volume() {
1751        let store = in_memory_store();
1752        // High volume, terrible reduction: a gap.
1753        for _ in 0..4 {
1754            store.log_compression(1_000, 950, &[], "xcodebuild").unwrap();
1755        }
1756        // High volume, great reduction: not a gap.
1757        for _ in 0..4 {
1758            store.log_compression(1_000, 100, &[], "cargo test").unwrap();
1759        }
1760        // Low volume, bad reduction: filtered by the 500-token floor.
1761        store.log_compression(100, 95, &[], "tiny-cmd").unwrap();
1762        // Dedup rows never count as gaps.
1763        store.log_compression(5_000, 13, &["dedup".to_string()], "dedup").unwrap();
1764
1765        let gaps = store.formatter_gaps(7, 10).unwrap();
1766        assert_eq!(gaps.len(), 1, "{gaps:?}");
1767        assert_eq!(gaps[0].command, "xcodebuild");
1768        assert_eq!(gaps[0].invocations, 4);
1769        assert!(gaps[0].reduction_pct() < 25.0);
1770    }
1771
1772    #[test]
1773    fn regret_log_roundtrip() {
1774        let store = in_memory_store();
1775        // Empty DB: zero everything.
1776        let empty = store.regret_stats().unwrap();
1777        assert_eq!(empty.reruns, 0);
1778        assert_eq!(empty.expands, 0);
1779        assert_eq!(empty.avg_rerun_gap_secs, 0.0);
1780
1781        store.log_regret("rerun", "git log", "aaaa", 10.0).unwrap();
1782        store.log_regret("rerun", "git log", "bbbb", 30.0).unwrap();
1783        store.log_regret("rerun", "cargo test", "cccc", 20.0).unwrap();
1784        store.log_regret("expand", "", "dddd", 0.0).unwrap();
1785
1786        let stats = store.regret_stats().unwrap();
1787        assert_eq!(stats.reruns, 3);
1788        assert_eq!(stats.expands, 1);
1789        assert!((stats.avg_rerun_gap_secs - 20.0).abs() < 1e-9);
1790
1791        let top = store.regret_by_command(10).unwrap();
1792        assert_eq!(top[0], ("git log".to_string(), 2));
1793        assert_eq!(top[1], ("cargo test".to_string(), 1));
1794        // Expand rows carry an empty cmd and must not appear.
1795        assert_eq!(top.len(), 2);
1796    }
1797
1798    #[test]
1799    fn test_save_and_load_session() {
1800        let store = in_memory_store();
1801        let session = make_session("sess-1", "/home/user/project", "REST API refactor");
1802
1803        let id = store.save_session(&session).unwrap();
1804        assert_eq!(id, "sess-1");
1805
1806        let loaded = store.load_session("sess-1".to_string()).unwrap();
1807        assert_eq!(loaded.id, session.id);
1808        assert_eq!(loaded.compressed_summary, session.compressed_summary);
1809        assert_eq!(loaded.project_dir, session.project_dir);
1810    }
1811
1812    #[test]
1813    fn test_save_session_upsert() {
1814        let store = in_memory_store();
1815        let mut session = make_session("sess-2", "/proj", "initial summary");
1816        store.save_session(&session).unwrap();
1817
1818        session.compressed_summary = "updated summary".to_string();
1819        store.save_session(&session).unwrap();
1820
1821        let loaded = store.load_session("sess-2".to_string()).unwrap();
1822        assert_eq!(loaded.compressed_summary, "updated summary");
1823    }
1824
1825    #[test]
1826    fn test_load_nonexistent_session_errors() {
1827        let store = in_memory_store();
1828        let result = store.load_session("does-not-exist".to_string());
1829        assert!(result.is_err());
1830    }
1831
1832    #[test]
1833    fn test_search_fts() {
1834        let store = in_memory_store();
1835        store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
1836        store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
1837
1838        let results = store.search("authentication").unwrap();
1839        assert_eq!(results.len(), 1);
1840        assert_eq!(results[0].id, "s1");
1841    }
1842
1843    #[test]
1844    fn test_search_by_date() {
1845        let store = in_memory_store();
1846        let now = Utc::now();
1847        let past = now - chrono::Duration::hours(2);
1848        let future = now + chrono::Duration::hours(2);
1849
1850        store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
1851
1852        let results = store.search_by_date(past, future).unwrap();
1853        assert!(!results.is_empty());
1854        assert!(results.iter().any(|r| r.id == "s1"));
1855    }
1856
1857    #[test]
1858    fn test_search_by_project() {
1859        let store = in_memory_store();
1860        store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
1861        store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
1862
1863        let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
1864        assert_eq!(results.len(), 1);
1865        assert_eq!(results[0].id, "s1");
1866    }
1867
1868    #[test]
1869    fn test_cache_entry_round_trip() {
1870        let store = in_memory_store();
1871        let entry = CompressedContent {
1872            data: "compressed data".to_string(),
1873            tokens_compressed: 10,
1874            tokens_original: 50,
1875            stages_applied: vec!["strip_nulls".to_string()],
1876            compression_ratio: 0.2,
1877            provenance: crate::types::Provenance::default(),
1878            verify: None,
1879        };
1880
1881        store.save_cache_entry("abc123", &entry).unwrap();
1882
1883        let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
1884        assert_eq!(loaded.data, entry.data);
1885        assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
1886        assert_eq!(loaded.tokens_original, entry.tokens_original);
1887    }
1888
1889    #[test]
1890    fn test_get_cache_entry_missing_returns_none() {
1891        let store = in_memory_store();
1892        let result = store.get_cache_entry("nonexistent").unwrap();
1893        assert!(result.is_none());
1894    }
1895
1896    #[test]
1897    fn test_open_or_create_corrupted_db() {
1898        let dir = tempfile::tempdir().unwrap();
1899        let path = dir.path().join("store.db");
1900
1901        // Write garbage bytes to simulate a corrupted database.
1902        std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
1903
1904        // Should succeed by creating a fresh database.
1905        let store = SessionStore::open_or_create(&path).unwrap();
1906        let session = make_session("s1", "/proj", "after corruption");
1907        store.save_session(&session).unwrap();
1908        let loaded = store.load_session("s1".to_string()).unwrap();
1909        assert_eq!(loaded.id, "s1");
1910    }
1911
1912    // ── Property-based tests ──────────────────────────────────────────────────
1913
1914    /// Build a `SessionState` with a specific `updated_at` timestamp.
1915    fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
1916        let now = Utc::now();
1917        SessionState {
1918            id: id.to_string(),
1919            project_dir: PathBuf::from("/proj"),
1920            conversation: vec![],
1921            corrections: CorrectionLog::default(),
1922            pins: vec![],
1923            learnings: vec![],
1924            compressed_summary: summary.to_string(),
1925            budget: BudgetState {
1926                window_size: 200_000,
1927                consumed: 0,
1928                pinned: 0,
1929                model_family: ModelFamily::AnthropicClaude,
1930            },
1931            tool_usage: vec![],
1932            created_at: now,
1933            updated_at,
1934        }
1935    }
1936
1937    // ── Property 26: Session store search correctness ─────────────────────────
1938    // **Validates: Requirements 20.2, 20.3, 20.4**
1939    //
1940    // For any set of sessions saved to the store, a keyword search SHALL return
1941    // all sessions whose compressed_summary contains the keyword, and no
1942    // sessions that don't contain it.
1943
1944    proptest! {
1945        /// **Validates: Requirements 20.2, 20.3, 20.4**
1946        ///
1947        /// For any set of sessions saved to the store, a keyword search SHALL
1948        /// return all sessions whose `compressed_summary` contains the keyword,
1949        /// and no sessions that don't contain it.
1950        #[test]
1951        fn prop_search_correctness(
1952            // A simple ASCII keyword: 5-8 lowercase letters, no common English
1953            // words that the porter stemmer might conflate with other terms.
1954            keyword in "[b-df-hj-np-tv-z]{5,8}",
1955            // 1-6 summaries that embed the keyword
1956            matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
1957            // 1-6 summaries that do NOT contain the keyword
1958            non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
1959        ) {
1960            // Ensure the keyword doesn't accidentally appear in non-matching summaries.
1961            for s in &non_matching {
1962                prop_assume!(!s.contains(keyword.as_str()));
1963            }
1964
1965            let store = in_memory_store();
1966
1967            // Save matching sessions (summary = "<suffix> <keyword> <suffix>")
1968            let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1969            for (i, suffix) in matching_suffixes.iter().enumerate() {
1970                let id = format!("match-{i}");
1971                let summary = format!("{} {} end", suffix, keyword);
1972                store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
1973                matching_ids.insert(id);
1974            }
1975
1976            // Save non-matching sessions
1977            let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1978            for (i, summary) in non_matching.iter().enumerate() {
1979                let id = format!("nomatch-{i}");
1980                store.save_session(&make_session(&id, "/proj", summary)).unwrap();
1981                non_matching_ids.insert(id);
1982            }
1983
1984            let results = store.search(&keyword).unwrap();
1985            let result_ids: std::collections::HashSet<String> =
1986                results.iter().map(|r| r.id.clone()).collect();
1987
1988            // Every matching session must appear in results.
1989            for id in &matching_ids {
1990                prop_assert!(
1991                    result_ids.contains(id),
1992                    "matching session '{}' not found in search results for keyword '{}'",
1993                    id, keyword
1994                );
1995            }
1996
1997            // No non-matching session may appear in results.
1998            for id in &non_matching_ids {
1999                prop_assert!(
2000                    !result_ids.contains(id),
2001                    "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
2002                    id, keyword
2003                );
2004            }
2005        }
2006    }
2007
2008    // ── Property: search_by_date correctness ─────────────────────────────────
2009    // **Validates: Requirements 20.4**
2010    //
2011    // For any set of sessions with different timestamps, searching by a date
2012    // range SHALL return exactly the sessions whose `updated_at` falls within
2013    // [from, to], and no sessions outside that range.
2014
2015    proptest! {
2016        /// **Validates: Requirements 20.4**
2017        ///
2018        /// For any set of sessions with distinct timestamps, `search_by_date`
2019        /// SHALL return exactly the sessions whose `updated_at` is within
2020        /// `[from, to]`, and no sessions outside that range.
2021        #[test]
2022        fn prop_search_by_date_correctness(
2023            // Generate 2-8 offsets in seconds from epoch (spread over a wide range)
2024            offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
2025            // The search window: start and end offsets (relative to the minimum offset)
2026            window_start_delta in 0i64..=3600i64,
2027            window_end_delta   in 3600i64..=7200i64,
2028        ) {
2029            use chrono::TimeZone;
2030
2031            // Deduplicate offsets so each session has a unique timestamp.
2032            let mut unique_offsets: Vec<i64> = offsets.clone();
2033            unique_offsets.sort_unstable();
2034            unique_offsets.dedup();
2035            prop_assume!(unique_offsets.len() >= 2);
2036
2037            let base_offset = unique_offsets[0];
2038            let from_offset = base_offset + window_start_delta;
2039            let to_offset   = base_offset + window_end_delta;
2040
2041            let from = Utc.timestamp_opt(from_offset, 0).unwrap();
2042            let to   = Utc.timestamp_opt(to_offset,   0).unwrap();
2043
2044            let store = in_memory_store();
2045
2046            let mut in_range_ids:  std::collections::HashSet<String> = std::collections::HashSet::new();
2047            let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2048
2049            for (i, &offset) in unique_offsets.iter().enumerate() {
2050                let ts = Utc.timestamp_opt(offset, 0).unwrap();
2051                let id = format!("sess-{i}");
2052                let session = make_session_at(&id, "some summary", ts);
2053                store.save_session(&session).unwrap();
2054
2055                if ts >= from && ts <= to {
2056                    in_range_ids.insert(id);
2057                } else {
2058                    out_range_ids.insert(id);
2059                }
2060            }
2061
2062            let results = store.search_by_date(from, to).unwrap();
2063            let result_ids: std::collections::HashSet<String> =
2064                results.iter().map(|r| r.id.clone()).collect();
2065
2066            // Every in-range session must appear.
2067            for id in &in_range_ids {
2068                prop_assert!(
2069                    result_ids.contains(id),
2070                    "in-range session '{}' missing from search_by_date results",
2071                    id
2072                );
2073            }
2074
2075            // No out-of-range session may appear.
2076            for id in &out_range_ids {
2077                prop_assert!(
2078                    !result_ids.contains(id),
2079                    "out-of-range session '{}' incorrectly appeared in search_by_date results",
2080                    id
2081                );
2082            }
2083        }
2084    }
2085}