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 INFLATED_ROW_FILTER: &str = "mode IN ('sqz_read_file', 'sqz_grep', 'sqz_list_dir') \
44 AND tokens_compressed > tokens_original";
45
46const SCHEMA: &str = r#"
49PRAGMA journal_mode = WAL;
50
51CREATE TABLE IF NOT EXISTS sessions (
52 id TEXT PRIMARY KEY,
53 project_dir TEXT NOT NULL,
54 compressed_summary TEXT NOT NULL,
55 created_at TEXT NOT NULL,
56 updated_at TEXT NOT NULL,
57 data BLOB NOT NULL
58);
59
60CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
61 id,
62 project_dir,
63 compressed_summary,
64 content='sessions',
65 content_rowid='rowid',
66 tokenize='porter ascii'
67);
68
69CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
70 INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
71 VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
72END;
73
74CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE 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);
77END;
78
79CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
80 INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
81 VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
82 INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
83 VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
84END;
85
86CREATE TABLE IF NOT EXISTS cache_entries (
87 hash TEXT PRIMARY KEY,
88 data TEXT NOT NULL,
89 accessed_at TEXT NOT NULL,
90 -- Raw pre-compression bytes so `sqz expand <prefix>` can serve
91 -- truly uncompressed content to agents that cannot parse
92 -- `§ref:…§` dedup tokens. Nullable because the column was added
93 -- in an additive migration; rows written before that migration
94 -- (or via callers that don't have the original bytes) have NULL.
95 original BLOB,
96 -- Pinned entries bypass the TTL freshness check. Used for stable
97 -- knowledge-base content (a wiki, project documentation, system
98 -- prompts) that's persistently re-injected into every agent and
99 -- therefore never gets compacted out of the LLM's context. See
100 -- `sqz pin` for the user-facing CLI.
101 pinned INTEGER NOT NULL DEFAULT 0
102);
103
104CREATE TABLE IF NOT EXISTS compression_log (
105 id INTEGER PRIMARY KEY AUTOINCREMENT,
106 tokens_original INTEGER NOT NULL,
107 tokens_compressed INTEGER NOT NULL,
108 stages_applied TEXT NOT NULL,
109 mode TEXT NOT NULL DEFAULT 'auto',
110 created_at TEXT NOT NULL
111);
112
113-- Compression-regret signals. A 'rerun' row means the agent re-produced
114-- byte-identical output within a short window of last seeing it (the
115-- repeat bought no new information); an 'expand' row means a §ref:…§
116-- token was expanded back to its original bytes (a recovery round trip).
117-- Both are proxies for "compression dropped something the agent needed",
118-- surfaced by `sqz stats` so aggressiveness can be judged per command.
119CREATE TABLE IF NOT EXISTS regret_log (
120 id INTEGER PRIMARY KEY AUTOINCREMENT,
121 kind TEXT NOT NULL,
122 cmd TEXT NOT NULL,
123 hash TEXT NOT NULL,
124 gap_secs REAL NOT NULL,
125 created_at TEXT NOT NULL
126);
127
128CREATE TABLE IF NOT EXISTS known_files (
129 path TEXT PRIMARY KEY,
130 added_at TEXT NOT NULL
131);
132
133-- Small key/value store for engine-wide state that needs to persist across
134-- short-lived sqz processes (each shell-hook invocation is a new process).
135-- Initially used only for the last_compaction_at marker: cache entries with
136-- `accessed_at < last_compaction_at` are treated as stale even if still
137-- within the normal TTL. See cache_manager.rs for the freshness model.
138CREATE TABLE IF NOT EXISTS metadata (
139 key TEXT PRIMARY KEY,
140 value TEXT NOT NULL
141);
142
143-- Full-text search over everything that flowed through sqz: cached
144-- command output (kind='output', ref_hash expandable via `sqz expand`)
145-- and session summaries (kind='session', ref_hash = session id). Lets
146-- agents re-find content they have already seen instead of re-running
147-- commands or re-reading files. BM25-ranked via FTS5.
148CREATE VIRTUAL TABLE IF NOT EXISTS recall_index USING fts5(
149 content,
150 ref_hash UNINDEXED,
151 kind UNINDEXED,
152 created_at UNINDEXED
153);
154"#;
155
156pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
159 conn.execute_batch(SCHEMA)?;
160 let has_original: bool = conn
172 .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'original'")?
173 .query_row([], |_| Ok(()))
174 .is_ok();
175 if !has_original {
176 conn.execute("ALTER TABLE cache_entries ADD COLUMN original BLOB", [])?;
177 }
178
179 let has_project_dir: bool = conn
184 .prepare("SELECT 1 FROM pragma_table_info('compression_log') WHERE name = 'project_dir'")?
185 .query_row([], |_| Ok(()))
186 .is_ok();
187 if !has_project_dir {
188 conn.execute("ALTER TABLE compression_log ADD COLUMN project_dir TEXT", [])?;
189 }
190
191 let has_pinned: bool = conn
195 .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'pinned'")?
196 .query_row([], |_| Ok(()))
197 .is_ok();
198 if !has_pinned {
199 conn.execute(
200 "ALTER TABLE cache_entries ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
201 [],
202 )?;
203 }
204
205 Ok(())
206}
207
208fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
209 let conn = Connection::open(path)?;
210 apply_schema(&conn)?;
211 Ok(conn)
212}
213
214fn row_to_summary(
215 id: String,
216 project_dir: String,
217 compressed_summary: String,
218 created_at: String,
219 updated_at: String,
220) -> Result<SessionSummary> {
221 let created_at = created_at
222 .parse::<DateTime<Utc>>()
223 .map_err(|e| SqzError::Other(format!("invalid created_at timestamp: {e}")))?;
224 let updated_at = updated_at
225 .parse::<DateTime<Utc>>()
226 .map_err(|e| SqzError::Other(format!("invalid updated_at timestamp: {e}")))?;
227 Ok(SessionSummary {
228 id,
229 project_dir: PathBuf::from(project_dir),
230 compressed_summary,
231 created_at,
232 updated_at,
233 })
234}
235
236impl SessionStore {
239 #[cfg(test)]
242 pub(crate) fn from_connection(conn: Connection) -> Self {
243 Self { db: conn }
244 }
245
246 pub fn open(path: &Path) -> Result<Self> {
249 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
250 apply_schema(&conn)?;
251 Ok(Self { db: conn })
252 }
253
254 pub fn open_or_create(path: &Path) -> Result<Self> {
258 match open_connection(path) {
259 Ok(conn) => Ok(Self { db: conn }),
260 Err(e) => {
261 eprintln!(
262 "sqz warning: session store at '{}' is corrupted or inaccessible ({e}). \
263 Creating a new database. Prior session data has been lost.",
264 path.display()
265 );
266 if path.exists() {
268 let _ = std::fs::remove_file(path);
269 }
270 let conn = open_connection(path)
271 .map_err(|e2| SqzError::Other(format!("failed to create new session store: {e2}")))?;
272 Ok(Self { db: conn })
273 }
274 }
275 }
276
277 pub fn save_session(&self, session: &SessionState) -> Result<SessionId> {
281 let data = serde_json::to_vec(session)?;
282 let project_dir = session.project_dir.to_string_lossy().to_string();
283 let created_at = session.created_at.to_rfc3339();
284 let updated_at = session.updated_at.to_rfc3339();
285
286 self.db.execute(
287 r#"INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data)
288 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
289 ON CONFLICT(id) DO UPDATE SET
290 project_dir = excluded.project_dir,
291 compressed_summary = excluded.compressed_summary,
292 created_at = excluded.created_at,
293 updated_at = excluded.updated_at,
294 data = excluded.data"#,
295 params![
296 session.id,
297 project_dir,
298 session.compressed_summary,
299 created_at,
300 updated_at,
301 data,
302 ],
303 )?;
304 if !session.compressed_summary.is_empty() {
305 self.recall_index_upsert(
306 &session.id,
307 "session",
308 session.compressed_summary.as_bytes(),
309 );
310 }
311
312 Ok(session.id.clone())
313 }
314
315 pub fn save_dashboard_session(&self, id: &str, summary: &str) -> Result<()> {
317 let now = Utc::now().to_rfc3339();
318 let project_dir = std::env::current_dir()
319 .unwrap_or_else(|_| std::path::PathBuf::from("."))
320 .to_string_lossy()
321 .to_string();
322 let empty_data = serde_json::to_vec(&serde_json::json!({"conversation":[]}))?;
323
324 self.db
325 .execute(
326 "INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data) \
327 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
328 ON CONFLICT(id) DO UPDATE SET \
329 compressed_summary = excluded.compressed_summary, \
330 updated_at = excluded.updated_at",
331 params![id, project_dir, summary, now, now, empty_data],
332 )
333 .map_err(SqzError::SessionStore)?;
334 Ok(())
335 }
336
337 pub fn load_session(&self, id: SessionId) -> Result<SessionState> {
339 let data: Vec<u8> = self.db.query_row(
340 "SELECT data FROM sessions WHERE id = ?1",
341 params![id],
342 |row| row.get(0),
343 )?;
344 let session: SessionState = serde_json::from_slice(&data)?;
345 Ok(session)
346 }
347
348 pub fn search(&self, query: &str) -> Result<Vec<SessionSummary>> {
352 let mut stmt = self.db.prepare(
353 r#"SELECT s.id, s.project_dir, s.compressed_summary, s.created_at, s.updated_at
354 FROM sessions s
355 JOIN sessions_fts f ON s.rowid = f.rowid
356 WHERE sessions_fts MATCH ?1
357 ORDER BY rank"#,
358 )?;
359
360 let rows = stmt.query_map(params![query], |row| {
361 Ok((
362 row.get::<_, String>(0)?,
363 row.get::<_, String>(1)?,
364 row.get::<_, String>(2)?,
365 row.get::<_, String>(3)?,
366 row.get::<_, String>(4)?,
367 ))
368 })?;
369
370 let mut results = Vec::new();
371 for row in rows {
372 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
373 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
374 }
375 Ok(results)
376 }
377
378 pub fn search_by_date(
380 &self,
381 from: DateTime<Utc>,
382 to: DateTime<Utc>,
383 ) -> Result<Vec<SessionSummary>> {
384 let mut stmt = self.db.prepare(
385 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
386 FROM sessions
387 WHERE updated_at >= ?1 AND updated_at <= ?2
388 ORDER BY updated_at DESC"#,
389 )?;
390
391 let rows = stmt.query_map(params![from.to_rfc3339(), to.to_rfc3339()], |row| {
392 Ok((
393 row.get::<_, String>(0)?,
394 row.get::<_, String>(1)?,
395 row.get::<_, String>(2)?,
396 row.get::<_, String>(3)?,
397 row.get::<_, String>(4)?,
398 ))
399 })?;
400
401 let mut results = Vec::new();
402 for row in rows {
403 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
404 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
405 }
406 Ok(results)
407 }
408
409 pub fn latest_session(&self) -> Result<Option<SessionSummary>> {
411 let mut stmt = self.db.prepare(
412 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
413 FROM sessions
414 ORDER BY updated_at DESC
415 LIMIT 1"#,
416 ).map_err(SqzError::SessionStore)?;
417
418 let rows = stmt.query_map([], |row| {
419 Ok((
420 row.get::<_, String>(0)?,
421 row.get::<_, String>(1)?,
422 row.get::<_, String>(2)?,
423 row.get::<_, String>(3)?,
424 row.get::<_, String>(4)?,
425 ))
426 }).map_err(SqzError::SessionStore)?;
427
428 for row in rows {
429 let (id, project_dir, compressed_summary, created_at, updated_at) =
430 row.map_err(SqzError::SessionStore)?;
431 return Ok(Some(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?));
432 }
433 Ok(None)
434 }
435
436 pub fn search_by_project(&self, dir: &Path) -> Result<Vec<SessionSummary>> {
438 let dir_str = dir.to_string_lossy().to_string();
439 let mut stmt = self.db.prepare(
440 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
441 FROM sessions
442 WHERE project_dir = ?1
443 ORDER BY updated_at DESC"#,
444 )?;
445
446 let rows = stmt.query_map(params![dir_str], |row| {
447 Ok((
448 row.get::<_, String>(0)?,
449 row.get::<_, String>(1)?,
450 row.get::<_, String>(2)?,
451 row.get::<_, String>(3)?,
452 row.get::<_, String>(4)?,
453 ))
454 })?;
455
456 let mut results = Vec::new();
457 for row in rows {
458 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
459 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
460 }
461 Ok(results)
462 }
463
464 pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
477 self.save_cache_entry_with_original(hash, compressed, None)
478 }
479
480 pub fn save_cache_entry_with_original(
487 &self,
488 hash: &str,
489 compressed: &CompressedContent,
490 original: Option<&[u8]>,
491 ) -> Result<()> {
492 let data = serde_json::to_string(compressed)?;
493 let now = Utc::now().to_rfc3339();
494 self.db.execute(
495 r#"INSERT INTO cache_entries (hash, data, accessed_at, original)
496 VALUES (?1, ?2, ?3, ?4)
497 ON CONFLICT(hash) DO UPDATE
498 SET data = excluded.data,
499 accessed_at = excluded.accessed_at,
500 -- Don't overwrite a previously-stored `original`
501 -- with NULL. Older callers (that go through
502 -- save_cache_entry rather than the _with_original
503 -- variant) shouldn't erase the expand-able bytes.
504 original = COALESCE(excluded.original, original)"#,
505 params![hash, data, now, original],
506 )?;
507 if let Some(bytes) = original {
508 self.recall_index_upsert(hash, "output", bytes);
509 }
510 Ok(())
511 }
512
513 fn recall_index_upsert(&self, ref_hash: &str, kind: &str, content: &[u8]) {
517 const MAX_INDEXED_BYTES: usize = 262_144;
518 if content.is_empty() || content.contains(&0) {
519 return;
520 }
521 let text = String::from_utf8_lossy(content);
522 let capped: &str = if text.len() > MAX_INDEXED_BYTES {
523 let mut end = MAX_INDEXED_BYTES;
524 while !text.is_char_boundary(end) {
525 end -= 1;
526 }
527 &text[..end]
528 } else {
529 &text
530 };
531 let now = Utc::now().to_rfc3339();
532 let _ = self.db.execute(
533 "DELETE FROM recall_index WHERE ref_hash = ?1",
534 params![ref_hash],
535 );
536 let _ = self.db.execute(
537 "INSERT INTO recall_index (content, ref_hash, kind, created_at) VALUES (?1, ?2, ?3, ?4)",
538 params![capped, ref_hash, kind, now],
539 );
540 }
541
542 pub fn recall_search(&self, query: &str, limit: u32) -> Result<Vec<RecallHit>> {
545 let match_expr = fts_query(query);
546 if match_expr.is_empty() {
547 return Ok(Vec::new());
548 }
549 let mut stmt = self.db.prepare(
550 "SELECT ref_hash, kind, snippet(recall_index, 0, '>>', '<<', ' … ', 24), created_at \
551 FROM recall_index WHERE recall_index MATCH ?1 \
552 ORDER BY bm25(recall_index) LIMIT ?2",
553 ).map_err(SqzError::SessionStore)?;
554 let rows = stmt.query_map(params![match_expr, limit], |row| {
555 Ok(RecallHit {
556 ref_hash: row.get(0)?,
557 kind: row.get(1)?,
558 snippet: row.get(2)?,
559 created_at: row.get(3)?,
560 })
561 }).map_err(SqzError::SessionStore)?;
562 let mut hits = Vec::new();
563 for row in rows {
564 hits.push(row.map_err(SqzError::SessionStore)?);
565 }
566 Ok(hits)
567 }
568
569 pub fn compression_period(&self) -> Result<Option<(String, String)>> {
572 let row: Option<(Option<String>, Option<String>)> = self
573 .db
574 .query_row(
575 "SELECT MIN(created_at), MAX(created_at) FROM compression_log",
576 [],
577 |row| Ok((row.get(0)?, row.get(1)?)),
578 )
579 .ok();
580 Ok(match row {
581 Some((Some(a), Some(b))) => Some((a, b)),
582 _ => None,
583 })
584 }
585
586 pub fn get_cache_entry_original(&self, hash: &str) -> Result<Option<Vec<u8>>> {
595 let result: rusqlite::Result<Option<Vec<u8>>> = self.db.query_row(
596 "SELECT original FROM cache_entries WHERE hash = ?1",
597 params![hash],
598 |row| row.get(0),
599 );
600 match result {
601 Ok(v) => Ok(v),
602 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
603 Err(e) => Err(SqzError::SessionStore(e)),
604 }
605 }
606
607 pub fn recent_originals(&self, min_len: usize, limit: usize) -> Result<Vec<SliceCandidate>> {
612 let mut stmt = self.db.prepare(
613 "SELECT hash, original, data FROM cache_entries \
614 WHERE original IS NOT NULL AND length(original) > ?1 \
615 ORDER BY accessed_at DESC LIMIT ?2",
616 )?;
617 let rows = stmt.query_map(params![min_len as i64, limit as i64], |row| {
618 Ok(SliceCandidate {
619 hash: row.get(0)?,
620 original: row.get(1)?,
621 data_json: row.get(2)?,
622 })
623 })?;
624 let mut out = Vec::new();
625 for row in rows {
626 out.push(row?);
627 }
628 Ok(out)
629 }
630
631 pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
633 self.db.execute(
634 "DELETE FROM cache_entries WHERE hash = ?1",
635 params![hash],
636 )?;
637 let _ = self.db.execute(
638 "DELETE FROM recall_index WHERE ref_hash = ?1",
639 params![hash],
640 );
641 Ok(())
642 }
643
644 pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
648 let mut stmt = self.db.prepare(
649 "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
650 )?;
651 let rows = stmt.query_map([], |row| {
652 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
653 })?;
654 let mut entries = Vec::new();
655 for row in rows {
656 let (hash, size) = row?;
657 entries.push((hash, size as u64));
658 }
659 Ok(entries)
660 }
661
662 pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
664 let result: rusqlite::Result<String> = self.db.query_row(
665 "SELECT data FROM cache_entries WHERE hash = ?1",
666 params![hash],
667 |row| row.get(0),
668 );
669
670 match result {
671 Ok(data) => {
672 let now = Utc::now().to_rfc3339();
674 let _ = self.db.execute(
675 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
676 params![now, hash],
677 );
678 let entry: CompressedContent = serde_json::from_str(&data)?;
679 Ok(Some(entry))
680 }
681 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
682 Err(e) => Err(SqzError::SessionStore(e)),
683 }
684 }
685
686 pub fn get_cache_entry_by_prefix(
706 &self,
707 prefix: &str,
708 ) -> Result<Option<(String, CompressedContent)>> {
709 if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
714 return Ok(None);
715 }
716 let pattern = format!("{prefix}%");
717 let mut stmt = self
718 .db
719 .prepare("SELECT hash, data FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
720 let mut rows = stmt.query(params![pattern])?;
721
722 let first = match rows.next()? {
723 Some(r) => {
724 let hash: String = r.get(0)?;
725 let data: String = r.get(1)?;
726 (hash, data)
727 }
728 None => return Ok(None),
729 };
730
731 if rows.next()?.is_some() {
735 return Err(SqzError::Other(format!(
736 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
737 )));
738 }
739 drop(rows);
740 drop(stmt);
741
742 let now = Utc::now().to_rfc3339();
744 let _ = self.db.execute(
745 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
746 params![now, first.0],
747 );
748 let entry: CompressedContent = serde_json::from_str(&first.1)?;
749 Ok(Some((first.0, entry)))
750 }
751
752 pub fn get_cache_entry_accessed_at(&self, hash: &str) -> Result<Option<DateTime<Utc>>> {
759 let result: rusqlite::Result<String> = self.db.query_row(
760 "SELECT accessed_at FROM cache_entries WHERE hash = ?1",
761 params![hash],
762 |row| row.get(0),
763 );
764 match result {
765 Ok(s) => {
766 let ts = s
767 .parse::<DateTime<Utc>>()
768 .map_err(|e| SqzError::Other(format!("invalid accessed_at: {e}")))?;
769 Ok(Some(ts))
770 }
771 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
772 Err(e) => Err(SqzError::SessionStore(e)),
773 }
774 }
775
776 pub fn cache_entry_exists(&self, hash: &str) -> Result<bool> {
778 let result: rusqlite::Result<i64> = self.db.query_row(
779 "SELECT 1 FROM cache_entries WHERE hash = ?1",
780 params![hash],
781 |row| row.get(0),
782 );
783 match result {
784 Ok(_) => Ok(true),
785 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
786 Err(e) => Err(SqzError::SessionStore(e)),
787 }
788 }
789
790 pub fn touch_cache_entry(&self, hash: &str) -> Result<()> {
794 let now = Utc::now().to_rfc3339();
795 self.db.execute(
796 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
797 params![now, hash],
798 )?;
799 Ok(())
800 }
801
802 pub fn resolve_cache_hash_by_prefix(&self, prefix: &str) -> Result<Option<String>> {
808 if prefix.is_empty()
809 || !prefix
810 .chars()
811 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
812 {
813 return Ok(None);
814 }
815 let pattern = format!("{prefix}%");
816 let mut stmt = self
817 .db
818 .prepare("SELECT hash FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
819 let mut rows = stmt.query(params![pattern])?;
820 let first: Option<String> = match rows.next()? {
821 Some(r) => Some(r.get(0)?),
822 None => None,
823 };
824 if rows.next()?.is_some() {
825 return Err(SqzError::Other(format!(
826 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
827 )));
828 }
829 Ok(first)
830 }
831
832 pub fn pin_cache_entry(&self, hash: &str) -> Result<bool> {
836 let n = self.db.execute(
837 "UPDATE cache_entries SET pinned = 1 WHERE hash = ?1",
838 params![hash],
839 )?;
840 Ok(n > 0)
841 }
842
843 pub fn unpin_cache_entry(&self, hash: &str) -> Result<bool> {
846 let n = self.db.execute(
847 "UPDATE cache_entries SET pinned = 0 WHERE hash = ?1",
848 params![hash],
849 )?;
850 Ok(n > 0)
851 }
852
853 pub fn unpin_all_cache_entries(&self) -> Result<usize> {
856 let n = self
857 .db
858 .execute("UPDATE cache_entries SET pinned = 0 WHERE pinned = 1", [])?;
859 Ok(n)
860 }
861
862 pub fn is_cache_entry_pinned(&self, hash: &str) -> Result<bool> {
865 let result: rusqlite::Result<i64> = self.db.query_row(
866 "SELECT pinned FROM cache_entries WHERE hash = ?1",
867 params![hash],
868 |row| row.get(0),
869 );
870 match result {
871 Ok(v) => Ok(v != 0),
872 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
873 Err(e) => Err(SqzError::SessionStore(e)),
874 }
875 }
876
877 pub fn list_pinned_cache_entries(
880 &self,
881 ) -> Result<Vec<(String, DateTime<Utc>, u64)>> {
882 let mut stmt = self
883 .db
884 .prepare(
885 "SELECT hash, accessed_at, length(data) FROM cache_entries \
886 WHERE pinned = 1 ORDER BY accessed_at DESC",
887 )
888 .map_err(SqzError::SessionStore)?;
889 let rows = stmt
890 .query_map([], |row| {
891 Ok((
892 row.get::<_, String>(0)?,
893 row.get::<_, String>(1)?,
894 row.get::<_, i64>(2)? as u64,
895 ))
896 })
897 .map_err(SqzError::SessionStore)?;
898 let mut out = Vec::new();
899 for row in rows {
900 let (hash, accessed_at_str, size) = row?;
901 let accessed_at = accessed_at_str.parse::<DateTime<Utc>>().map_err(|e| {
902 SqzError::Other(format!("invalid accessed_at on pinned entry: {e}"))
903 })?;
904 out.push((hash, accessed_at, size));
905 }
906 Ok(out)
907 }
908
909 pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
912 self.db.execute(
913 "INSERT INTO metadata (key, value) VALUES (?1, ?2)
914 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
915 params![key, value],
916 )?;
917 Ok(())
918 }
919
920 pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
922 let result: rusqlite::Result<String> = self.db.query_row(
923 "SELECT value FROM metadata WHERE key = ?1",
924 params![key],
925 |row| row.get(0),
926 );
927 match result {
928 Ok(v) => Ok(Some(v)),
929 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
930 Err(e) => Err(SqzError::SessionStore(e)),
931 }
932 }
933
934 pub fn log_compression(
936 &self,
937 tokens_original: u32,
938 tokens_compressed: u32,
939 stages: &[String],
940 mode: &str,
941 ) -> Result<()> {
942 self.log_compression_with_project(tokens_original, tokens_compressed, stages, mode, None)
943 }
944
945 pub fn log_compression_with_project(
947 &self,
948 tokens_original: u32,
949 tokens_compressed: u32,
950 stages: &[String],
951 mode: &str,
952 project_dir: Option<&str>,
953 ) -> Result<()> {
954 let now = Utc::now().to_rfc3339();
955 let stages_str = stages.join(",");
956 self.db.execute(
957 "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at, project_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
958 params![tokens_original, tokens_compressed, stages_str, mode, now, project_dir],
959 ).map_err(SqzError::SessionStore)?;
960 Ok(())
961 }
962
963 pub fn log_regret(&self, kind: &str, cmd: &str, hash: &str, gap_secs: f64) -> Result<()> {
968 let now = Utc::now().to_rfc3339();
969 self.db.execute(
970 "INSERT INTO regret_log (kind, cmd, hash, gap_secs, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
971 params![kind, cmd, hash, gap_secs, now],
972 ).map_err(SqzError::SessionStore)?;
973 Ok(())
974 }
975
976 pub fn regret_stats(&self) -> Result<RegretStats> {
978 let mut stmt = self.db.prepare(
979 "SELECT \
980 COALESCE(SUM(CASE WHEN kind = 'rerun' THEN 1 ELSE 0 END), 0), \
981 COALESCE(SUM(CASE WHEN kind = 'expand' THEN 1 ELSE 0 END), 0), \
982 COALESCE(AVG(CASE WHEN kind = 'rerun' THEN gap_secs END), 0.0) \
983 FROM regret_log",
984 ).map_err(SqzError::SessionStore)?;
985 let stats = stmt.query_row([], |row| {
986 Ok(RegretStats {
987 reruns: row.get::<_, u32>(0)?,
988 expands: row.get::<_, u32>(1)?,
989 avg_rerun_gap_secs: row.get::<_, f64>(2)?,
990 })
991 }).map_err(SqzError::SessionStore)?;
992 Ok(stats)
993 }
994
995 pub fn formatter_gaps(&self, days: u32, limit: u32) -> Result<Vec<CommandStats>> {
1001 let offset = format!("-{days} days");
1002 let mut stmt = self.db.prepare(
1003 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1004 FROM compression_log \
1005 WHERE created_at >= date('now', ?1) AND mode NOT IN ('dedup', 'slice') \
1006 GROUP BY mode \
1007 HAVING SUM(tokens_original) >= 500 \
1008 AND (SUM(tokens_original) - SUM(tokens_compressed)) * 100.0 \
1009 / SUM(tokens_original) < 25.0 \
1010 ORDER BY SUM(tokens_compressed) DESC \
1011 LIMIT ?2",
1012 ).map_err(SqzError::SessionStore)?;
1013 let rows = stmt.query_map(params![offset, limit], |row| {
1014 let tokens_in: u64 = row.get(2)?;
1015 let tokens_out: u64 = row.get(3)?;
1016 Ok(CommandStats {
1017 command: row.get(0)?,
1018 invocations: row.get(1)?,
1019 tokens_in,
1020 tokens_out,
1021 tokens_saved: tokens_in.saturating_sub(tokens_out),
1022 })
1023 }).map_err(SqzError::SessionStore)?;
1024 let mut results = Vec::new();
1025 for row in rows {
1026 results.push(row.map_err(SqzError::SessionStore)?);
1027 }
1028 Ok(results)
1029 }
1030
1031 pub fn regret_by_command(&self, limit: u32) -> Result<Vec<(String, u32)>> {
1033 let mut stmt = self.db.prepare(
1034 "SELECT cmd, COUNT(*) FROM regret_log \
1035 WHERE kind = 'rerun' AND cmd != '' \
1036 GROUP BY cmd ORDER BY COUNT(*) DESC LIMIT ?1",
1037 ).map_err(SqzError::SessionStore)?;
1038 let rows = stmt.query_map(params![limit], |row| {
1039 Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1040 }).map_err(SqzError::SessionStore)?;
1041 let mut results = Vec::new();
1042 for row in rows {
1043 results.push(row.map_err(SqzError::SessionStore)?);
1044 }
1045 Ok(results)
1046 }
1047
1048 pub fn compression_stats(&self) -> Result<CompressionStats> {
1050 let mut stmt = self.db.prepare(
1051 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
1052 ).map_err(SqzError::SessionStore)?;
1053
1054 let stats = stmt.query_row([], |row| {
1055 Ok(CompressionStats {
1056 total_compressions: row.get::<_, u32>(0)?,
1057 total_tokens_in: row.get::<_, u64>(1)?,
1058 total_tokens_out: row.get::<_, u64>(2)?,
1059 })
1060 }).map_err(SqzError::SessionStore)?;
1061
1062 Ok(stats)
1063 }
1064
1065 pub fn inflated_rows(&self) -> Result<(u32, u64)> {
1071 let sql = format!(
1072 "SELECT COUNT(*), COALESCE(SUM(tokens_compressed - tokens_original), 0) \
1073 FROM compression_log WHERE {INFLATED_ROW_FILTER}"
1074 );
1075 let (count, inflation): (u32, i64) = self
1076 .db
1077 .query_row(&sql, [], |row| Ok((row.get(0)?, row.get(1)?)))
1078 .map_err(SqzError::SessionStore)?;
1079 Ok((count, inflation.max(0) as u64))
1080 }
1081
1082 pub fn backup_to(&self, dest: &Path) -> Result<()> {
1086 self.db
1087 .execute("VACUUM INTO ?1", params![dest.to_string_lossy()])
1088 .map_err(SqzError::SessionStore)?;
1089 Ok(())
1090 }
1091
1092 pub fn clamp_inflated_rows(&self) -> Result<usize> {
1098 let sql = format!(
1099 "UPDATE compression_log SET tokens_original = tokens_compressed \
1100 WHERE {INFLATED_ROW_FILTER}"
1101 );
1102 self.db.execute(&sql, []).map_err(SqzError::SessionStore)
1103 }
1104
1105 pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
1107 let mut stmt = self.db.prepare(
1108 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1109 FROM compression_log \
1110 WHERE created_at >= date('now', ?1) \
1111 GROUP BY d ORDER BY d",
1112 ).map_err(SqzError::SessionStore)?;
1113
1114 let offset = format!("-{days} days");
1115 let rows = stmt.query_map(params![offset], |row| {
1116 let tokens_in: u64 = row.get(2)?;
1117 let tokens_out: u64 = row.get(3)?;
1118 Ok(DailyGain {
1119 date: row.get(0)?,
1120 compressions: row.get(1)?,
1121 tokens_in,
1122 tokens_saved: tokens_in.saturating_sub(tokens_out),
1123 })
1124 }).map_err(SqzError::SessionStore)?;
1125
1126 let mut gains = Vec::new();
1127 for row in rows {
1128 gains.push(row.map_err(SqzError::SessionStore)?);
1129 }
1130 Ok(gains)
1131 }
1132
1133 pub fn compression_stats_for_project(&self, project_dir: &str) -> Result<CompressionStats> {
1135 let mut stmt = self.db.prepare(
1136 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) \
1137 FROM compression_log WHERE project_dir = ?1",
1138 ).map_err(SqzError::SessionStore)?;
1139
1140 let stats = stmt.query_row(params![project_dir], |row| {
1141 Ok(CompressionStats {
1142 total_compressions: row.get::<_, u32>(0)?,
1143 total_tokens_in: row.get::<_, u64>(1)?,
1144 total_tokens_out: row.get::<_, u64>(2)?,
1145 })
1146 }).map_err(SqzError::SessionStore)?;
1147
1148 Ok(stats)
1149 }
1150
1151 pub fn daily_gains_for_project(&self, days: u32, project_dir: &str) -> Result<Vec<DailyGain>> {
1153 let mut stmt = self.db.prepare(
1154 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1155 FROM compression_log \
1156 WHERE created_at >= date('now', ?1) AND project_dir = ?2 \
1157 GROUP BY d ORDER BY d",
1158 ).map_err(SqzError::SessionStore)?;
1159
1160 let offset = format!("-{days} days");
1161 let rows = stmt.query_map(params![offset, project_dir], |row| {
1162 let tokens_in: u64 = row.get(2)?;
1163 let tokens_out: u64 = row.get(3)?;
1164 Ok(DailyGain {
1165 date: row.get(0)?,
1166 compressions: row.get(1)?,
1167 tokens_in,
1168 tokens_saved: tokens_in.saturating_sub(tokens_out),
1169 })
1170 }).map_err(SqzError::SessionStore)?;
1171
1172 let mut gains = Vec::new();
1173 for row in rows {
1174 gains.push(row.map_err(SqzError::SessionStore)?);
1175 }
1176 Ok(gains)
1177 }
1178
1179 pub fn list_projects(&self) -> Result<Vec<(String, u32, u64)>> {
1181 let mut stmt = self.db.prepare(
1182 "SELECT project_dir, COUNT(*), COALESCE(SUM(tokens_original) - SUM(tokens_compressed), 0) \
1183 FROM compression_log \
1184 WHERE project_dir IS NOT NULL \
1185 GROUP BY project_dir \
1186 ORDER BY COUNT(*) DESC",
1187 ).map_err(SqzError::SessionStore)?;
1188
1189 let rows = stmt.query_map([], |row| {
1190 Ok((
1191 row.get::<_, String>(0)?,
1192 row.get::<_, u32>(1)?,
1193 row.get::<_, u64>(2)?,
1194 ))
1195 }).map_err(SqzError::SessionStore)?;
1196
1197 let mut projects = Vec::new();
1198 for row in rows {
1199 projects.push(row.map_err(SqzError::SessionStore)?);
1200 }
1201 Ok(projects)
1202 }
1203
1204 pub fn command_breakdown(&self, limit: u32) -> Result<Vec<CommandStats>> {
1208 let mut stmt = self.db.prepare(
1209 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1210 FROM compression_log \
1211 GROUP BY mode \
1212 ORDER BY SUM(tokens_original) DESC \
1213 LIMIT ?1",
1214 ).map_err(SqzError::SessionStore)?;
1215
1216 let rows = stmt.query_map(params![limit], |row| {
1217 let tokens_in: u64 = row.get(2)?;
1218 let tokens_out: u64 = row.get(3)?;
1219 Ok(CommandStats {
1220 command: row.get(0)?,
1221 invocations: row.get(1)?,
1222 tokens_in,
1223 tokens_out,
1224 tokens_saved: tokens_in.saturating_sub(tokens_out),
1225 })
1226 }).map_err(SqzError::SessionStore)?;
1227
1228 let mut results = Vec::new();
1229 for row in rows {
1230 results.push(row.map_err(SqzError::SessionStore)?);
1231 }
1232 Ok(results)
1233 }
1234
1235 pub fn command_breakdown_for_project(&self, limit: u32, project_dir: &str) -> Result<Vec<CommandStats>> {
1237 let mut stmt = self.db.prepare(
1238 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1239 FROM compression_log \
1240 WHERE project_dir = ?1 \
1241 GROUP BY mode \
1242 ORDER BY SUM(tokens_original) DESC \
1243 LIMIT ?2",
1244 ).map_err(SqzError::SessionStore)?;
1245
1246 let rows = stmt.query_map(params![project_dir, limit], |row| {
1247 let tokens_in: u64 = row.get(2)?;
1248 let tokens_out: u64 = row.get(3)?;
1249 Ok(CommandStats {
1250 command: row.get(0)?,
1251 invocations: row.get(1)?,
1252 tokens_in,
1253 tokens_out,
1254 tokens_saved: tokens_in.saturating_sub(tokens_out),
1255 })
1256 }).map_err(SqzError::SessionStore)?;
1257
1258 let mut results = Vec::new();
1259 for row in rows {
1260 results.push(row.map_err(SqzError::SessionStore)?);
1261 }
1262 Ok(results)
1263 }
1264
1265 pub fn session_pressure(&self, since_minutes: u32) -> Result<u64> {
1269 let mut stmt = self.db.prepare(
1270 "SELECT COALESCE(SUM(tokens_compressed), 0) \
1271 FROM compression_log \
1272 WHERE created_at >= datetime('now', ?1)",
1273 ).map_err(SqzError::SessionStore)?;
1274
1275 let offset = format!("-{since_minutes} minutes");
1276 let total: u64 = stmt.query_row(params![offset], |row| {
1277 row.get(0)
1278 }).map_err(SqzError::SessionStore)?;
1279
1280 Ok(total)
1281 }
1282
1283 pub fn add_known_file(&self, path: &str) -> Result<()> {
1288 let now = Utc::now().to_rfc3339();
1289 self.db.execute(
1290 "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
1291 params![path, now],
1292 ).map_err(SqzError::SessionStore)?;
1293 Ok(())
1294 }
1295
1296 pub fn known_files(&self) -> Result<Vec<String>> {
1298 let mut stmt = self.db.prepare(
1299 "SELECT path FROM known_files ORDER BY added_at DESC",
1300 ).map_err(SqzError::SessionStore)?;
1301
1302 let rows = stmt.query_map([], |row| {
1303 row.get::<_, String>(0)
1304 }).map_err(SqzError::SessionStore)?;
1305
1306 let mut files = Vec::new();
1307 for row in rows {
1308 files.push(row.map_err(SqzError::SessionStore)?);
1309 }
1310 Ok(files)
1311 }
1312 pub fn clear_known_files(&self) -> Result<()> {
1314 self.db
1315 .execute("DELETE FROM known_files", [])
1316 .map_err(SqzError::SessionStore)?;
1317 Ok(())
1318 }
1319
1320 pub fn cache_entry_count(&self) -> Result<u64> {
1322 self.db.query_row(
1323 "SELECT COUNT(*) FROM cache_entries", [],
1324 |row| row.get(0),
1325 ).map_err(SqzError::SessionStore)
1326 }
1327
1328 pub fn record_cache_hit(&self) -> Result<()> {
1330 let count: u64 = self.db.query_row(
1331 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1332 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1333 ).unwrap_or(0);
1334 let new = count + 1;
1335 self.db.execute(
1336 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_hits', ?1)",
1337 params![new.to_string()],
1338 )?;
1339 Ok(())
1340 }
1341
1342 pub fn record_cache_miss(&self) -> Result<()> {
1344 let count: u64 = self.db.query_row(
1345 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1346 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1347 ).unwrap_or(0);
1348 let new = count + 1;
1349 self.db.execute(
1350 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_misses', ?1)",
1351 params![new.to_string()],
1352 )?;
1353 Ok(())
1354 }
1355
1356 pub fn cache_hit_miss_counts(&self) -> Result<(u64, u64)> {
1358 let hits: u64 = self.db.query_row(
1359 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1360 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1361 ).unwrap_or(0);
1362 let misses: u64 = self.db.query_row(
1363 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1364 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1365 ).unwrap_or(0);
1366 Ok((hits, misses))
1367 }
1368
1369 pub fn clear_cache(&self) -> Result<u64> {
1373 let count: u64 = self.db.query_row(
1374 "SELECT COUNT(*) FROM cache_entries", [],
1375 |row| row.get(0),
1376 ).unwrap_or(0);
1377 self.db.execute("DELETE FROM cache_entries", [])
1378 .map_err(SqzError::SessionStore)?;
1379 let _ = self.db.execute(
1380 "DELETE FROM recall_index WHERE kind = 'output'", [],
1381 );
1382 let _ = self.db.execute(
1385 "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1386 );
1387 Ok(count)
1388 }
1389
1390 pub fn clear_stats(&self) -> Result<u64> {
1394 let count: u64 = self.db.query_row(
1395 "SELECT COUNT(*) FROM compression_log", [],
1396 |row| row.get(0),
1397 ).unwrap_or(0);
1398 self.db.execute("DELETE FROM compression_log", [])
1399 .map_err(SqzError::SessionStore)?;
1400 Ok(count)
1401 }
1402
1403 pub fn clear_stats_for_project(&self, project_dir: &str) -> Result<u64> {
1405 let count: u64 = self.db.query_row(
1406 "SELECT COUNT(*) FROM compression_log WHERE project_dir = ?1",
1407 rusqlite::params![project_dir],
1408 |row| row.get(0),
1409 ).unwrap_or(0);
1410 self.db.execute(
1411 "DELETE FROM compression_log WHERE project_dir = ?1",
1412 rusqlite::params![project_dir],
1413 ).map_err(SqzError::SessionStore)?;
1414 Ok(count)
1415 }
1416
1417 pub fn reset_all(&self) -> Result<()> {
1420 self.db.execute("DELETE FROM cache_entries", []).map_err(SqzError::SessionStore)?;
1421 self.db.execute("DELETE FROM compression_log", []).map_err(SqzError::SessionStore)?;
1422 self.db.execute("DELETE FROM sessions", []).map_err(SqzError::SessionStore)?;
1423 self.db.execute("DELETE FROM known_files", []).map_err(SqzError::SessionStore)?;
1424 self.db.execute("DELETE FROM metadata", []).map_err(SqzError::SessionStore)?;
1425 let _ = self.db.execute("DELETE FROM recall_index", []);
1426 let _ = self.db.execute("VACUUM", []);
1428 Ok(())
1429 }
1430
1431 pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionSummary>> {
1433 let mut stmt = self
1434 .db
1435 .prepare(
1436 "SELECT id, project_dir, compressed_summary, created_at, updated_at \
1437 FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1438 )
1439 .map_err(SqzError::SessionStore)?;
1440
1441 let rows = stmt
1442 .query_map(params![limit], |row| {
1443 Ok((
1444 row.get::<_, String>(0)?,
1445 row.get::<_, String>(1)?,
1446 row.get::<_, String>(2)?,
1447 row.get::<_, String>(3)?,
1448 row.get::<_, String>(4)?,
1449 ))
1450 })
1451 .map_err(SqzError::SessionStore)?;
1452
1453 let mut results = Vec::new();
1454 for row in rows {
1455 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
1456 results.push(row_to_summary(
1457 id,
1458 project_dir,
1459 compressed_summary,
1460 created_at,
1461 updated_at,
1462 )?);
1463 }
1464 Ok(results)
1465 }
1466
1467 pub fn per_tool_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1469 let mut stmt = self
1470 .db
1471 .prepare(
1472 "SELECT stages_applied, SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1473 FROM compression_log GROUP BY stages_applied ORDER BY COUNT(*) DESC LIMIT 20",
1474 )
1475 .map_err(SqzError::SessionStore)?;
1476
1477 let rows = stmt
1478 .query_map([], |row| {
1479 Ok((
1480 row.get::<_, String>(0)?,
1481 row.get::<_, u64>(1)?,
1482 row.get::<_, u64>(2)?,
1483 row.get::<_, u32>(3)?,
1484 ))
1485 })
1486 .map_err(SqzError::SessionStore)?;
1487
1488 let mut tool_map: std::collections::HashMap<String, (u64, u64, u32)> =
1489 std::collections::HashMap::new();
1490 for row in rows {
1491 let (stages, tokens_in, tokens_out, count) = row?;
1492 for stage in stages.split(',') {
1493 let stage = stage.trim();
1494 if stage.is_empty() {
1495 continue;
1496 }
1497 let entry = tool_map.entry(stage.to_string()).or_default();
1498 entry.0 += tokens_in;
1499 entry.1 += tokens_out;
1500 entry.2 += count;
1501 }
1502 }
1503
1504 let mut result: Vec<_> = tool_map.into_iter().collect();
1505 result.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
1506 Ok(result
1507 .into_iter()
1508 .map(|(name, (tin, tout, cnt))| (name, tin, tout, cnt))
1509 .collect())
1510 }
1511
1512 pub fn per_command_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1514 let mut stmt = self
1515 .db
1516 .prepare(
1517 "SELECT COALESCE(mode, 'auto'), SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1518 FROM compression_log GROUP BY mode ORDER BY COUNT(*) DESC LIMIT 20",
1519 )
1520 .map_err(SqzError::SessionStore)?;
1521
1522 let rows = stmt
1523 .query_map([], |row| {
1524 Ok((
1525 row.get::<_, String>(0)?,
1526 row.get::<_, u64>(1)?,
1527 row.get::<_, u64>(2)?,
1528 row.get::<_, u32>(3)?,
1529 ))
1530 })
1531 .map_err(SqzError::SessionStore)?;
1532
1533 let mut result = Vec::new();
1534 for row in rows {
1535 result.push(row?);
1536 }
1537 Ok(result)
1538 }
1539}
1540
1541#[derive(Debug, Clone, Default)]
1543pub struct CompressionStats {
1544 pub total_compressions: u32,
1545 pub total_tokens_in: u64,
1546 pub total_tokens_out: u64,
1547}
1548
1549impl CompressionStats {
1550 pub fn tokens_saved(&self) -> u64 {
1551 self.total_tokens_in.saturating_sub(self.total_tokens_out)
1552 }
1553
1554 pub fn is_net_negative(&self) -> bool {
1558 self.total_tokens_out > self.total_tokens_in
1559 }
1560
1561 pub fn reduction_pct(&self) -> f64 {
1562 if self.total_tokens_in == 0 {
1563 0.0
1564 } else {
1565 (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
1566 }
1567 }
1568}
1569
1570#[derive(Debug, Clone)]
1572pub struct DailyGain {
1573 pub date: String,
1574 pub compressions: u32,
1575 pub tokens_saved: u64,
1576 pub tokens_in: u64,
1577}
1578
1579#[derive(Debug, Clone)]
1581pub struct CommandStats {
1582 pub command: String,
1583 pub invocations: u32,
1584 pub tokens_in: u64,
1585 pub tokens_out: u64,
1586 pub tokens_saved: u64,
1587}
1588
1589#[derive(Debug, Clone)]
1592pub struct SliceCandidate {
1593 pub hash: String,
1594 pub original: Vec<u8>,
1595 pub data_json: String,
1596}
1597
1598#[derive(Debug, Clone)]
1600pub struct RecallHit {
1601 pub ref_hash: String,
1604 pub kind: String,
1605 pub snippet: String,
1607 pub created_at: String,
1608}
1609
1610fn fts_query(raw: &str) -> String {
1614 raw.split_whitespace()
1615 .filter_map(|term| {
1616 let cleaned = term.replace('"', "");
1617 if cleaned.is_empty() {
1618 None
1619 } else {
1620 Some(format!("\"{cleaned}\""))
1621 }
1622 })
1623 .collect::<Vec<_>>()
1624 .join(" ")
1625}
1626
1627#[derive(Debug, Clone, Default)]
1629pub struct RegretStats {
1630 pub reruns: u32,
1632 pub expands: u32,
1634 pub avg_rerun_gap_secs: f64,
1636}
1637
1638impl CommandStats {
1639 pub fn reduction_pct(&self) -> f64 {
1640 if self.tokens_in == 0 {
1641 0.0
1642 } else {
1643 (1.0 - self.tokens_out as f64 / self.tokens_in as f64) * 100.0
1644 }
1645 }
1646}
1647
1648#[cfg(test)]
1651mod tests {
1652 use super::*;
1653 use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
1654 use chrono::Utc;
1655 use proptest::prelude::*;
1656 use std::path::PathBuf;
1657
1658 fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
1659 let now = Utc::now();
1660 SessionState {
1661 id: id.to_string(),
1662 project_dir: PathBuf::from(project_dir),
1663 conversation: vec![],
1664 corrections: CorrectionLog::default(),
1665 pins: vec![],
1666 learnings: vec![],
1667 compressed_summary: summary.to_string(),
1668 budget: BudgetState {
1669 window_size: 200_000,
1670 consumed: 0,
1671 pinned: 0,
1672 model_family: ModelFamily::AnthropicClaude,
1673 },
1674 tool_usage: vec![],
1675 created_at: now,
1676 updated_at: now,
1677 }
1678 }
1679
1680 fn in_memory_store() -> SessionStore {
1681 let conn = Connection::open_in_memory().unwrap();
1682 apply_schema(&conn).unwrap();
1683 SessionStore { db: conn }
1684 }
1685
1686 fn test_content(data: &str) -> CompressedContent {
1687 CompressedContent {
1688 data: data.to_string(),
1689 tokens_compressed: 1,
1690 tokens_original: 2,
1691 stages_applied: vec![],
1692 compression_ratio: 0.5,
1693 provenance: crate::types::Provenance::default(),
1694 verify: None,
1695 }
1696 }
1697
1698 #[test]
1699 fn backup_to_captures_wal_contents() {
1700 let dir = tempfile::tempdir().unwrap();
1701 let db = dir.path().join("live.db");
1702 let store = SessionStore::open_or_create(&db).unwrap();
1703 store.log_compression(900, 100, &[], "auto").unwrap();
1704 let backup = dir.path().join("live.db.bak");
1706 store.backup_to(&backup).unwrap();
1707 let restored = SessionStore::open_or_create(&backup).unwrap();
1708 assert_eq!(restored.compression_stats().unwrap().total_tokens_in, 900);
1709 assert!(store.backup_to(&backup).is_err(), "never overwrites an existing file");
1710 }
1711
1712 #[test]
1713 fn inflated_rows_are_clamped_not_deleted() {
1714 let store = in_memory_store();
1715 store.log_compression(1000, 400, &[], "auto").unwrap();
1717 store.log_compression(5, 13, &[], "dedup").unwrap();
1720 store.log_compression(300, 340, &[], "auto").unwrap();
1721 store.log_compression(200, 230, &[], "sqz_read_file").unwrap();
1723 store.log_compression(655_870, 1_091_993, &[], "sqz_grep").unwrap();
1724 store.log_compression(38_382, 60_919, &[], "sqz_grep").unwrap();
1725 store.log_compression(500, 500, &[], "sqz_list_dir").unwrap();
1727
1728 let (count, inflation) = store.inflated_rows().unwrap();
1729 assert_eq!(count, 3);
1730 assert_eq!(inflation, 30 + (1_091_993 - 655_870) + (60_919 - 38_382));
1731
1732 let stats = store.compression_stats().unwrap();
1733 assert!(stats.is_net_negative(), "garbage rows push the total negative");
1734 assert_eq!(stats.tokens_saved(), 0, "saturating_sub hides it as zero");
1735
1736 assert_eq!(store.clamp_inflated_rows().unwrap(), 3);
1737 assert_eq!(store.inflated_rows().unwrap(), (0, 0));
1738 assert_eq!(store.clamp_inflated_rows().unwrap(), 0, "idempotent");
1739
1740 let stats = store.compression_stats().unwrap();
1741 assert!(!stats.is_net_negative());
1742 assert_eq!(stats.total_compressions, 7, "no row is deleted");
1743 assert_eq!(
1745 stats.total_tokens_in,
1746 1000 + 5 + 300 + 230 + 1_091_993 + 60_919 + 500
1747 );
1748 assert_eq!(stats.tokens_saved(), (1000 - 400) + (300 + 5) - (340 + 13));
1749 }
1750
1751 #[test]
1752 fn recall_indexes_cache_originals_and_searches() {
1753 let store = in_memory_store();
1754 let content = test_content("compressed");
1755 store
1756 .save_cache_entry_with_original(
1757 "hash-auth",
1758 &content,
1759 Some(b"error: auth middleware rejected the session token"),
1760 )
1761 .unwrap();
1762 store
1763 .save_cache_entry_with_original(
1764 "hash-build",
1765 &content,
1766 Some(b"cargo build finished in 32s with zero warnings"),
1767 )
1768 .unwrap();
1769
1770 let hits = store.recall_search("auth middleware", 5).unwrap();
1771 assert_eq!(hits.len(), 1);
1772 assert_eq!(hits[0].ref_hash, "hash-auth");
1773 assert_eq!(hits[0].kind, "output");
1774 assert!(hits[0].snippet.contains(">>auth<<"), "snippet: {}", hits[0].snippet);
1775
1776 store
1778 .save_cache_entry_with_original(
1779 "hash-auth",
1780 &content,
1781 Some(b"error: auth middleware rejected the session token"),
1782 )
1783 .unwrap();
1784 assert_eq!(store.recall_search("middleware", 5).unwrap().len(), 1);
1785 }
1786
1787 #[test]
1788 fn recall_skips_binary_and_survives_odd_queries() {
1789 let store = in_memory_store();
1790 let content = test_content("c");
1791 store
1792 .save_cache_entry_with_original("hash-bin", &content, Some(b"binary\x00payload"))
1793 .unwrap();
1794 assert!(store.recall_search("binary", 5).unwrap().is_empty());
1795
1796 for q in ["NOT AND OR", "\"unbalanced", "col:umn", "wild*", "(paren", "-", ""] {
1798 let _ = store.recall_search(q, 5).unwrap();
1799 }
1800 }
1801
1802 #[test]
1803 fn recall_deletion_and_clear_coherence() {
1804 let store = in_memory_store();
1805 let content = test_content("c");
1806 store
1807 .save_cache_entry_with_original("hash-x", &content, Some(b"kubectl rollout restarted deployment"))
1808 .unwrap();
1809 assert_eq!(store.recall_search("rollout", 5).unwrap().len(), 1);
1810
1811 store.delete_cache_entry("hash-x").unwrap();
1812 assert!(store.recall_search("rollout", 5).unwrap().is_empty());
1813
1814 store
1816 .save_cache_entry_with_original("hash-y", &content, Some(b"terraform plan shows three changes"))
1817 .unwrap();
1818 let session = make_session("sess-recall", "/tmp/p", "refactored the billing retry loop");
1819 store.save_session(&session).unwrap();
1820 store.clear_cache().unwrap();
1821 assert!(store.recall_search("terraform", 5).unwrap().is_empty());
1822 let session_hits = store.recall_search("billing retry", 5).unwrap();
1823 assert_eq!(session_hits.len(), 1);
1824 assert_eq!(session_hits[0].kind, "session");
1825 assert_eq!(session_hits[0].ref_hash, "sess-recall");
1826
1827 store.reset_all().unwrap();
1828 assert!(store.recall_search("billing", 5).unwrap().is_empty());
1829 }
1830
1831 #[test]
1832 fn recall_ranks_better_matches_first() {
1833 let store = in_memory_store();
1834 let content = test_content("c");
1835 store
1836 .save_cache_entry_with_original(
1837 "hash-dense",
1838 &content,
1839 Some(b"panic unwrap panic unwrap panic in worker thread"),
1840 )
1841 .unwrap();
1842 store
1843 .save_cache_entry_with_original(
1844 "hash-sparse",
1845 &content,
1846 Some(format!("{} panic once at the end", "filler line of ordinary words\n".repeat(50)).as_bytes()),
1847 )
1848 .unwrap();
1849 let hits = store.recall_search("panic", 5).unwrap();
1850 assert_eq!(hits.len(), 2);
1851 assert_eq!(hits[0].ref_hash, "hash-dense", "bm25 should rank the denser match first");
1852 }
1853
1854 #[test]
1855 fn formatter_gaps_ranks_low_reduction_high_volume() {
1856 let store = in_memory_store();
1857 for _ in 0..4 {
1859 store.log_compression(1_000, 950, &[], "xcodebuild").unwrap();
1860 }
1861 for _ in 0..4 {
1863 store.log_compression(1_000, 100, &[], "cargo test").unwrap();
1864 }
1865 store.log_compression(100, 95, &[], "tiny-cmd").unwrap();
1867 store.log_compression(5_000, 13, &["dedup".to_string()], "dedup").unwrap();
1869
1870 let gaps = store.formatter_gaps(7, 10).unwrap();
1871 assert_eq!(gaps.len(), 1, "{gaps:?}");
1872 assert_eq!(gaps[0].command, "xcodebuild");
1873 assert_eq!(gaps[0].invocations, 4);
1874 assert!(gaps[0].reduction_pct() < 25.0);
1875 }
1876
1877 #[test]
1878 fn regret_log_roundtrip() {
1879 let store = in_memory_store();
1880 let empty = store.regret_stats().unwrap();
1882 assert_eq!(empty.reruns, 0);
1883 assert_eq!(empty.expands, 0);
1884 assert_eq!(empty.avg_rerun_gap_secs, 0.0);
1885
1886 store.log_regret("rerun", "git log", "aaaa", 10.0).unwrap();
1887 store.log_regret("rerun", "git log", "bbbb", 30.0).unwrap();
1888 store.log_regret("rerun", "cargo test", "cccc", 20.0).unwrap();
1889 store.log_regret("expand", "", "dddd", 0.0).unwrap();
1890
1891 let stats = store.regret_stats().unwrap();
1892 assert_eq!(stats.reruns, 3);
1893 assert_eq!(stats.expands, 1);
1894 assert!((stats.avg_rerun_gap_secs - 20.0).abs() < 1e-9);
1895
1896 let top = store.regret_by_command(10).unwrap();
1897 assert_eq!(top[0], ("git log".to_string(), 2));
1898 assert_eq!(top[1], ("cargo test".to_string(), 1));
1899 assert_eq!(top.len(), 2);
1901 }
1902
1903 #[test]
1904 fn test_save_and_load_session() {
1905 let store = in_memory_store();
1906 let session = make_session("sess-1", "/home/user/project", "REST API refactor");
1907
1908 let id = store.save_session(&session).unwrap();
1909 assert_eq!(id, "sess-1");
1910
1911 let loaded = store.load_session("sess-1".to_string()).unwrap();
1912 assert_eq!(loaded.id, session.id);
1913 assert_eq!(loaded.compressed_summary, session.compressed_summary);
1914 assert_eq!(loaded.project_dir, session.project_dir);
1915 }
1916
1917 #[test]
1918 fn test_save_session_upsert() {
1919 let store = in_memory_store();
1920 let mut session = make_session("sess-2", "/proj", "initial summary");
1921 store.save_session(&session).unwrap();
1922
1923 session.compressed_summary = "updated summary".to_string();
1924 store.save_session(&session).unwrap();
1925
1926 let loaded = store.load_session("sess-2".to_string()).unwrap();
1927 assert_eq!(loaded.compressed_summary, "updated summary");
1928 }
1929
1930 #[test]
1931 fn test_load_nonexistent_session_errors() {
1932 let store = in_memory_store();
1933 let result = store.load_session("does-not-exist".to_string());
1934 assert!(result.is_err());
1935 }
1936
1937 #[test]
1938 fn test_search_fts() {
1939 let store = in_memory_store();
1940 store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
1941 store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
1942
1943 let results = store.search("authentication").unwrap();
1944 assert_eq!(results.len(), 1);
1945 assert_eq!(results[0].id, "s1");
1946 }
1947
1948 #[test]
1949 fn test_search_by_date() {
1950 let store = in_memory_store();
1951 let now = Utc::now();
1952 let past = now - chrono::Duration::hours(2);
1953 let future = now + chrono::Duration::hours(2);
1954
1955 store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
1956
1957 let results = store.search_by_date(past, future).unwrap();
1958 assert!(!results.is_empty());
1959 assert!(results.iter().any(|r| r.id == "s1"));
1960 }
1961
1962 #[test]
1963 fn test_search_by_project() {
1964 let store = in_memory_store();
1965 store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
1966 store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
1967
1968 let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
1969 assert_eq!(results.len(), 1);
1970 assert_eq!(results[0].id, "s1");
1971 }
1972
1973 #[test]
1974 fn test_cache_entry_round_trip() {
1975 let store = in_memory_store();
1976 let entry = CompressedContent {
1977 data: "compressed data".to_string(),
1978 tokens_compressed: 10,
1979 tokens_original: 50,
1980 stages_applied: vec!["strip_nulls".to_string()],
1981 compression_ratio: 0.2,
1982 provenance: crate::types::Provenance::default(),
1983 verify: None,
1984 };
1985
1986 store.save_cache_entry("abc123", &entry).unwrap();
1987
1988 let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
1989 assert_eq!(loaded.data, entry.data);
1990 assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
1991 assert_eq!(loaded.tokens_original, entry.tokens_original);
1992 }
1993
1994 #[test]
1995 fn test_get_cache_entry_missing_returns_none() {
1996 let store = in_memory_store();
1997 let result = store.get_cache_entry("nonexistent").unwrap();
1998 assert!(result.is_none());
1999 }
2000
2001 #[test]
2002 fn test_open_or_create_corrupted_db() {
2003 let dir = tempfile::tempdir().unwrap();
2004 let path = dir.path().join("store.db");
2005
2006 std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
2008
2009 let store = SessionStore::open_or_create(&path).unwrap();
2011 let session = make_session("s1", "/proj", "after corruption");
2012 store.save_session(&session).unwrap();
2013 let loaded = store.load_session("s1".to_string()).unwrap();
2014 assert_eq!(loaded.id, "s1");
2015 }
2016
2017 fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
2021 let now = Utc::now();
2022 SessionState {
2023 id: id.to_string(),
2024 project_dir: PathBuf::from("/proj"),
2025 conversation: vec![],
2026 corrections: CorrectionLog::default(),
2027 pins: vec![],
2028 learnings: vec![],
2029 compressed_summary: summary.to_string(),
2030 budget: BudgetState {
2031 window_size: 200_000,
2032 consumed: 0,
2033 pinned: 0,
2034 model_family: ModelFamily::AnthropicClaude,
2035 },
2036 tool_usage: vec![],
2037 created_at: now,
2038 updated_at,
2039 }
2040 }
2041
2042 proptest! {
2050 #[test]
2056 fn prop_search_correctness(
2057 keyword in "[b-df-hj-np-tv-z]{5,8}",
2060 matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
2062 non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
2064 ) {
2065 for s in &non_matching {
2067 prop_assume!(!s.contains(keyword.as_str()));
2068 }
2069
2070 let store = in_memory_store();
2071
2072 let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2074 for (i, suffix) in matching_suffixes.iter().enumerate() {
2075 let id = format!("match-{i}");
2076 let summary = format!("{} {} end", suffix, keyword);
2077 store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
2078 matching_ids.insert(id);
2079 }
2080
2081 let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2083 for (i, summary) in non_matching.iter().enumerate() {
2084 let id = format!("nomatch-{i}");
2085 store.save_session(&make_session(&id, "/proj", summary)).unwrap();
2086 non_matching_ids.insert(id);
2087 }
2088
2089 let results = store.search(&keyword).unwrap();
2090 let result_ids: std::collections::HashSet<String> =
2091 results.iter().map(|r| r.id.clone()).collect();
2092
2093 for id in &matching_ids {
2095 prop_assert!(
2096 result_ids.contains(id),
2097 "matching session '{}' not found in search results for keyword '{}'",
2098 id, keyword
2099 );
2100 }
2101
2102 for id in &non_matching_ids {
2104 prop_assert!(
2105 !result_ids.contains(id),
2106 "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
2107 id, keyword
2108 );
2109 }
2110 }
2111 }
2112
2113 proptest! {
2121 #[test]
2127 fn prop_search_by_date_correctness(
2128 offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
2130 window_start_delta in 0i64..=3600i64,
2132 window_end_delta in 3600i64..=7200i64,
2133 ) {
2134 use chrono::TimeZone;
2135
2136 let mut unique_offsets: Vec<i64> = offsets.clone();
2138 unique_offsets.sort_unstable();
2139 unique_offsets.dedup();
2140 prop_assume!(unique_offsets.len() >= 2);
2141
2142 let base_offset = unique_offsets[0];
2143 let from_offset = base_offset + window_start_delta;
2144 let to_offset = base_offset + window_end_delta;
2145
2146 let from = Utc.timestamp_opt(from_offset, 0).unwrap();
2147 let to = Utc.timestamp_opt(to_offset, 0).unwrap();
2148
2149 let store = in_memory_store();
2150
2151 let mut in_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2152 let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2153
2154 for (i, &offset) in unique_offsets.iter().enumerate() {
2155 let ts = Utc.timestamp_opt(offset, 0).unwrap();
2156 let id = format!("sess-{i}");
2157 let session = make_session_at(&id, "some summary", ts);
2158 store.save_session(&session).unwrap();
2159
2160 if ts >= from && ts <= to {
2161 in_range_ids.insert(id);
2162 } else {
2163 out_range_ids.insert(id);
2164 }
2165 }
2166
2167 let results = store.search_by_date(from, to).unwrap();
2168 let result_ids: std::collections::HashSet<String> =
2169 results.iter().map(|r| r.id.clone()).collect();
2170
2171 for id in &in_range_ids {
2173 prop_assert!(
2174 result_ids.contains(id),
2175 "in-range session '{}' missing from search_by_date results",
2176 id
2177 );
2178 }
2179
2180 for id in &out_range_ids {
2182 prop_assert!(
2183 !result_ids.contains(id),
2184 "out-of-range session '{}' incorrectly appeared in search_by_date results",
2185 id
2186 );
2187 }
2188 }
2189 }
2190}