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#[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
22pub struct SessionStore {
38 db: Connection,
39}
40
41const 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
151pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
154 conn.execute_batch(SCHEMA)?;
155 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 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 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
231impl SessionStore {
234 #[cfg(test)]
237 pub(crate) fn from_connection(conn: Connection) -> Self {
238 Self { db: conn }
239 }
240
241 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 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 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 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 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 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 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 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 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 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 pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
472 self.save_cache_entry_with_original(hash, compressed, None)
473 }
474
475 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 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 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 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 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 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 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 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 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 pub fn get_cache_entry_by_prefix(
684 &self,
685 prefix: &str,
686 ) -> Result<Option<(String, CompressedContent)>> {
687 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let _ = self.db.execute(
1323 "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1324 );
1325 Ok(count)
1326 }
1327
1328 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 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 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 let _ = self.db.execute("VACUUM", []);
1366 Ok(())
1367 }
1368
1369 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 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 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#[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#[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#[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#[derive(Debug, Clone)]
1523pub struct SliceCandidate {
1524 pub hash: String,
1525 pub original: Vec<u8>,
1526 pub data_json: String,
1527}
1528
1529#[derive(Debug, Clone)]
1531pub struct RecallHit {
1532 pub ref_hash: String,
1535 pub kind: String,
1536 pub snippet: String,
1538 pub created_at: String,
1539}
1540
1541fn 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#[derive(Debug, Clone, Default)]
1560pub struct RegretStats {
1561 pub reruns: u32,
1563 pub expands: u32,
1565 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#[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 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 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 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 for _ in 0..4 {
1737 store.log_compression(1_000, 950, &[], "xcodebuild").unwrap();
1738 }
1739 for _ in 0..4 {
1741 store.log_compression(1_000, 100, &[], "cargo test").unwrap();
1742 }
1743 store.log_compression(100, 95, &[], "tiny-cmd").unwrap();
1745 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 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 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 std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
1886
1887 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 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 proptest! {
1928 #[test]
1934 fn prop_search_correctness(
1935 keyword in "[b-df-hj-np-tv-z]{5,8}",
1938 matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
1940 non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
1942 ) {
1943 for s in &non_matching {
1945 prop_assume!(!s.contains(keyword.as_str()));
1946 }
1947
1948 let store = in_memory_store();
1949
1950 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 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 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 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 proptest! {
1999 #[test]
2005 fn prop_search_by_date_correctness(
2006 offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
2008 window_start_delta in 0i64..=3600i64,
2010 window_end_delta in 3600i64..=7200i64,
2011 ) {
2012 use chrono::TimeZone;
2013
2014 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 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 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}