Skip to main content

sqz_engine/
session_store.rs

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