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 compression_period(&self) -> Result<Option<(String, String)>> {
567 let row: Option<(Option<String>, Option<String>)> = self
568 .db
569 .query_row(
570 "SELECT MIN(created_at), MAX(created_at) FROM compression_log",
571 [],
572 |row| Ok((row.get(0)?, row.get(1)?)),
573 )
574 .ok();
575 Ok(match row {
576 Some((Some(a), Some(b))) => Some((a, b)),
577 _ => None,
578 })
579 }
580
581 pub fn get_cache_entry_original(&self, hash: &str) -> Result<Option<Vec<u8>>> {
590 let result: rusqlite::Result<Option<Vec<u8>>> = self.db.query_row(
591 "SELECT original FROM cache_entries WHERE hash = ?1",
592 params![hash],
593 |row| row.get(0),
594 );
595 match result {
596 Ok(v) => Ok(v),
597 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
598 Err(e) => Err(SqzError::SessionStore(e)),
599 }
600 }
601
602 pub fn recent_originals(&self, min_len: usize, limit: usize) -> Result<Vec<SliceCandidate>> {
607 let mut stmt = self.db.prepare(
608 "SELECT hash, original, data FROM cache_entries \
609 WHERE original IS NOT NULL AND length(original) > ?1 \
610 ORDER BY accessed_at DESC LIMIT ?2",
611 )?;
612 let rows = stmt.query_map(params![min_len as i64, limit as i64], |row| {
613 Ok(SliceCandidate {
614 hash: row.get(0)?,
615 original: row.get(1)?,
616 data_json: row.get(2)?,
617 })
618 })?;
619 let mut out = Vec::new();
620 for row in rows {
621 out.push(row?);
622 }
623 Ok(out)
624 }
625
626 pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
628 self.db.execute(
629 "DELETE FROM cache_entries WHERE hash = ?1",
630 params![hash],
631 )?;
632 let _ = self.db.execute(
633 "DELETE FROM recall_index WHERE ref_hash = ?1",
634 params![hash],
635 );
636 Ok(())
637 }
638
639 pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
643 let mut stmt = self.db.prepare(
644 "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
645 )?;
646 let rows = stmt.query_map([], |row| {
647 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
648 })?;
649 let mut entries = Vec::new();
650 for row in rows {
651 let (hash, size) = row?;
652 entries.push((hash, size as u64));
653 }
654 Ok(entries)
655 }
656
657 pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
659 let result: rusqlite::Result<String> = self.db.query_row(
660 "SELECT data FROM cache_entries WHERE hash = ?1",
661 params![hash],
662 |row| row.get(0),
663 );
664
665 match result {
666 Ok(data) => {
667 let now = Utc::now().to_rfc3339();
669 let _ = self.db.execute(
670 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
671 params![now, hash],
672 );
673 let entry: CompressedContent = serde_json::from_str(&data)?;
674 Ok(Some(entry))
675 }
676 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
677 Err(e) => Err(SqzError::SessionStore(e)),
678 }
679 }
680
681 pub fn get_cache_entry_by_prefix(
701 &self,
702 prefix: &str,
703 ) -> Result<Option<(String, CompressedContent)>> {
704 if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
709 return Ok(None);
710 }
711 let pattern = format!("{prefix}%");
712 let mut stmt = self
713 .db
714 .prepare("SELECT hash, data FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
715 let mut rows = stmt.query(params![pattern])?;
716
717 let first = match rows.next()? {
718 Some(r) => {
719 let hash: String = r.get(0)?;
720 let data: String = r.get(1)?;
721 (hash, data)
722 }
723 None => return Ok(None),
724 };
725
726 if rows.next()?.is_some() {
730 return Err(SqzError::Other(format!(
731 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
732 )));
733 }
734 drop(rows);
735 drop(stmt);
736
737 let now = Utc::now().to_rfc3339();
739 let _ = self.db.execute(
740 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
741 params![now, first.0],
742 );
743 let entry: CompressedContent = serde_json::from_str(&first.1)?;
744 Ok(Some((first.0, entry)))
745 }
746
747 pub fn get_cache_entry_accessed_at(&self, hash: &str) -> Result<Option<DateTime<Utc>>> {
754 let result: rusqlite::Result<String> = self.db.query_row(
755 "SELECT accessed_at FROM cache_entries WHERE hash = ?1",
756 params![hash],
757 |row| row.get(0),
758 );
759 match result {
760 Ok(s) => {
761 let ts = s
762 .parse::<DateTime<Utc>>()
763 .map_err(|e| SqzError::Other(format!("invalid accessed_at: {e}")))?;
764 Ok(Some(ts))
765 }
766 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
767 Err(e) => Err(SqzError::SessionStore(e)),
768 }
769 }
770
771 pub fn cache_entry_exists(&self, hash: &str) -> Result<bool> {
773 let result: rusqlite::Result<i64> = self.db.query_row(
774 "SELECT 1 FROM cache_entries WHERE hash = ?1",
775 params![hash],
776 |row| row.get(0),
777 );
778 match result {
779 Ok(_) => Ok(true),
780 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
781 Err(e) => Err(SqzError::SessionStore(e)),
782 }
783 }
784
785 pub fn touch_cache_entry(&self, hash: &str) -> Result<()> {
789 let now = Utc::now().to_rfc3339();
790 self.db.execute(
791 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
792 params![now, hash],
793 )?;
794 Ok(())
795 }
796
797 pub fn resolve_cache_hash_by_prefix(&self, prefix: &str) -> Result<Option<String>> {
803 if prefix.is_empty()
804 || !prefix
805 .chars()
806 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
807 {
808 return Ok(None);
809 }
810 let pattern = format!("{prefix}%");
811 let mut stmt = self
812 .db
813 .prepare("SELECT hash FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
814 let mut rows = stmt.query(params![pattern])?;
815 let first: Option<String> = match rows.next()? {
816 Some(r) => Some(r.get(0)?),
817 None => None,
818 };
819 if rows.next()?.is_some() {
820 return Err(SqzError::Other(format!(
821 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
822 )));
823 }
824 Ok(first)
825 }
826
827 pub fn pin_cache_entry(&self, hash: &str) -> Result<bool> {
831 let n = self.db.execute(
832 "UPDATE cache_entries SET pinned = 1 WHERE hash = ?1",
833 params![hash],
834 )?;
835 Ok(n > 0)
836 }
837
838 pub fn unpin_cache_entry(&self, hash: &str) -> Result<bool> {
841 let n = self.db.execute(
842 "UPDATE cache_entries SET pinned = 0 WHERE hash = ?1",
843 params![hash],
844 )?;
845 Ok(n > 0)
846 }
847
848 pub fn unpin_all_cache_entries(&self) -> Result<usize> {
851 let n = self
852 .db
853 .execute("UPDATE cache_entries SET pinned = 0 WHERE pinned = 1", [])?;
854 Ok(n)
855 }
856
857 pub fn is_cache_entry_pinned(&self, hash: &str) -> Result<bool> {
860 let result: rusqlite::Result<i64> = self.db.query_row(
861 "SELECT pinned FROM cache_entries WHERE hash = ?1",
862 params![hash],
863 |row| row.get(0),
864 );
865 match result {
866 Ok(v) => Ok(v != 0),
867 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
868 Err(e) => Err(SqzError::SessionStore(e)),
869 }
870 }
871
872 pub fn list_pinned_cache_entries(
875 &self,
876 ) -> Result<Vec<(String, DateTime<Utc>, u64)>> {
877 let mut stmt = self
878 .db
879 .prepare(
880 "SELECT hash, accessed_at, length(data) FROM cache_entries \
881 WHERE pinned = 1 ORDER BY accessed_at DESC",
882 )
883 .map_err(SqzError::SessionStore)?;
884 let rows = stmt
885 .query_map([], |row| {
886 Ok((
887 row.get::<_, String>(0)?,
888 row.get::<_, String>(1)?,
889 row.get::<_, i64>(2)? as u64,
890 ))
891 })
892 .map_err(SqzError::SessionStore)?;
893 let mut out = Vec::new();
894 for row in rows {
895 let (hash, accessed_at_str, size) = row?;
896 let accessed_at = accessed_at_str.parse::<DateTime<Utc>>().map_err(|e| {
897 SqzError::Other(format!("invalid accessed_at on pinned entry: {e}"))
898 })?;
899 out.push((hash, accessed_at, size));
900 }
901 Ok(out)
902 }
903
904 pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
907 self.db.execute(
908 "INSERT INTO metadata (key, value) VALUES (?1, ?2)
909 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
910 params![key, value],
911 )?;
912 Ok(())
913 }
914
915 pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
917 let result: rusqlite::Result<String> = self.db.query_row(
918 "SELECT value FROM metadata WHERE key = ?1",
919 params![key],
920 |row| row.get(0),
921 );
922 match result {
923 Ok(v) => Ok(Some(v)),
924 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
925 Err(e) => Err(SqzError::SessionStore(e)),
926 }
927 }
928
929 pub fn log_compression(
931 &self,
932 tokens_original: u32,
933 tokens_compressed: u32,
934 stages: &[String],
935 mode: &str,
936 ) -> Result<()> {
937 self.log_compression_with_project(tokens_original, tokens_compressed, stages, mode, None)
938 }
939
940 pub fn log_compression_with_project(
942 &self,
943 tokens_original: u32,
944 tokens_compressed: u32,
945 stages: &[String],
946 mode: &str,
947 project_dir: Option<&str>,
948 ) -> Result<()> {
949 let now = Utc::now().to_rfc3339();
950 let stages_str = stages.join(",");
951 self.db.execute(
952 "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at, project_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
953 params![tokens_original, tokens_compressed, stages_str, mode, now, project_dir],
954 ).map_err(SqzError::SessionStore)?;
955 Ok(())
956 }
957
958 pub fn log_regret(&self, kind: &str, cmd: &str, hash: &str, gap_secs: f64) -> Result<()> {
963 let now = Utc::now().to_rfc3339();
964 self.db.execute(
965 "INSERT INTO regret_log (kind, cmd, hash, gap_secs, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
966 params![kind, cmd, hash, gap_secs, now],
967 ).map_err(SqzError::SessionStore)?;
968 Ok(())
969 }
970
971 pub fn regret_stats(&self) -> Result<RegretStats> {
973 let mut stmt = self.db.prepare(
974 "SELECT \
975 COALESCE(SUM(CASE WHEN kind = 'rerun' THEN 1 ELSE 0 END), 0), \
976 COALESCE(SUM(CASE WHEN kind = 'expand' THEN 1 ELSE 0 END), 0), \
977 COALESCE(AVG(CASE WHEN kind = 'rerun' THEN gap_secs END), 0.0) \
978 FROM regret_log",
979 ).map_err(SqzError::SessionStore)?;
980 let stats = stmt.query_row([], |row| {
981 Ok(RegretStats {
982 reruns: row.get::<_, u32>(0)?,
983 expands: row.get::<_, u32>(1)?,
984 avg_rerun_gap_secs: row.get::<_, f64>(2)?,
985 })
986 }).map_err(SqzError::SessionStore)?;
987 Ok(stats)
988 }
989
990 pub fn formatter_gaps(&self, days: u32, limit: u32) -> Result<Vec<CommandStats>> {
996 let offset = format!("-{days} days");
997 let mut stmt = self.db.prepare(
998 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
999 FROM compression_log \
1000 WHERE created_at >= date('now', ?1) AND mode NOT IN ('dedup', 'slice') \
1001 GROUP BY mode \
1002 HAVING SUM(tokens_original) >= 500 \
1003 AND (SUM(tokens_original) - SUM(tokens_compressed)) * 100.0 \
1004 / SUM(tokens_original) < 25.0 \
1005 ORDER BY SUM(tokens_compressed) DESC \
1006 LIMIT ?2",
1007 ).map_err(SqzError::SessionStore)?;
1008 let rows = stmt.query_map(params![offset, limit], |row| {
1009 let tokens_in: u64 = row.get(2)?;
1010 let tokens_out: u64 = row.get(3)?;
1011 Ok(CommandStats {
1012 command: row.get(0)?,
1013 invocations: row.get(1)?,
1014 tokens_in,
1015 tokens_out,
1016 tokens_saved: tokens_in.saturating_sub(tokens_out),
1017 })
1018 }).map_err(SqzError::SessionStore)?;
1019 let mut results = Vec::new();
1020 for row in rows {
1021 results.push(row.map_err(SqzError::SessionStore)?);
1022 }
1023 Ok(results)
1024 }
1025
1026 pub fn regret_by_command(&self, limit: u32) -> Result<Vec<(String, u32)>> {
1028 let mut stmt = self.db.prepare(
1029 "SELECT cmd, COUNT(*) FROM regret_log \
1030 WHERE kind = 'rerun' AND cmd != '' \
1031 GROUP BY cmd ORDER BY COUNT(*) DESC LIMIT ?1",
1032 ).map_err(SqzError::SessionStore)?;
1033 let rows = stmt.query_map(params![limit], |row| {
1034 Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1035 }).map_err(SqzError::SessionStore)?;
1036 let mut results = Vec::new();
1037 for row in rows {
1038 results.push(row.map_err(SqzError::SessionStore)?);
1039 }
1040 Ok(results)
1041 }
1042
1043 pub fn compression_stats(&self) -> Result<CompressionStats> {
1045 let mut stmt = self.db.prepare(
1046 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
1047 ).map_err(SqzError::SessionStore)?;
1048
1049 let stats = stmt.query_row([], |row| {
1050 Ok(CompressionStats {
1051 total_compressions: row.get::<_, u32>(0)?,
1052 total_tokens_in: row.get::<_, u64>(1)?,
1053 total_tokens_out: row.get::<_, u64>(2)?,
1054 })
1055 }).map_err(SqzError::SessionStore)?;
1056
1057 Ok(stats)
1058 }
1059
1060 pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
1062 let mut stmt = self.db.prepare(
1063 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1064 FROM compression_log \
1065 WHERE created_at >= date('now', ?1) \
1066 GROUP BY d ORDER BY d",
1067 ).map_err(SqzError::SessionStore)?;
1068
1069 let offset = format!("-{days} days");
1070 let rows = stmt.query_map(params![offset], |row| {
1071 let tokens_in: u64 = row.get(2)?;
1072 let tokens_out: u64 = row.get(3)?;
1073 Ok(DailyGain {
1074 date: row.get(0)?,
1075 compressions: row.get(1)?,
1076 tokens_in,
1077 tokens_saved: tokens_in.saturating_sub(tokens_out),
1078 })
1079 }).map_err(SqzError::SessionStore)?;
1080
1081 let mut gains = Vec::new();
1082 for row in rows {
1083 gains.push(row.map_err(SqzError::SessionStore)?);
1084 }
1085 Ok(gains)
1086 }
1087
1088 pub fn compression_stats_for_project(&self, project_dir: &str) -> Result<CompressionStats> {
1090 let mut stmt = self.db.prepare(
1091 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) \
1092 FROM compression_log WHERE project_dir = ?1",
1093 ).map_err(SqzError::SessionStore)?;
1094
1095 let stats = stmt.query_row(params![project_dir], |row| {
1096 Ok(CompressionStats {
1097 total_compressions: row.get::<_, u32>(0)?,
1098 total_tokens_in: row.get::<_, u64>(1)?,
1099 total_tokens_out: row.get::<_, u64>(2)?,
1100 })
1101 }).map_err(SqzError::SessionStore)?;
1102
1103 Ok(stats)
1104 }
1105
1106 pub fn daily_gains_for_project(&self, days: u32, project_dir: &str) -> Result<Vec<DailyGain>> {
1108 let mut stmt = self.db.prepare(
1109 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1110 FROM compression_log \
1111 WHERE created_at >= date('now', ?1) AND project_dir = ?2 \
1112 GROUP BY d ORDER BY d",
1113 ).map_err(SqzError::SessionStore)?;
1114
1115 let offset = format!("-{days} days");
1116 let rows = stmt.query_map(params![offset, project_dir], |row| {
1117 let tokens_in: u64 = row.get(2)?;
1118 let tokens_out: u64 = row.get(3)?;
1119 Ok(DailyGain {
1120 date: row.get(0)?,
1121 compressions: row.get(1)?,
1122 tokens_in,
1123 tokens_saved: tokens_in.saturating_sub(tokens_out),
1124 })
1125 }).map_err(SqzError::SessionStore)?;
1126
1127 let mut gains = Vec::new();
1128 for row in rows {
1129 gains.push(row.map_err(SqzError::SessionStore)?);
1130 }
1131 Ok(gains)
1132 }
1133
1134 pub fn list_projects(&self) -> Result<Vec<(String, u32, u64)>> {
1136 let mut stmt = self.db.prepare(
1137 "SELECT project_dir, COUNT(*), COALESCE(SUM(tokens_original) - SUM(tokens_compressed), 0) \
1138 FROM compression_log \
1139 WHERE project_dir IS NOT NULL \
1140 GROUP BY project_dir \
1141 ORDER BY COUNT(*) DESC",
1142 ).map_err(SqzError::SessionStore)?;
1143
1144 let rows = stmt.query_map([], |row| {
1145 Ok((
1146 row.get::<_, String>(0)?,
1147 row.get::<_, u32>(1)?,
1148 row.get::<_, u64>(2)?,
1149 ))
1150 }).map_err(SqzError::SessionStore)?;
1151
1152 let mut projects = Vec::new();
1153 for row in rows {
1154 projects.push(row.map_err(SqzError::SessionStore)?);
1155 }
1156 Ok(projects)
1157 }
1158
1159 pub fn command_breakdown(&self, limit: u32) -> Result<Vec<CommandStats>> {
1163 let mut stmt = self.db.prepare(
1164 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1165 FROM compression_log \
1166 GROUP BY mode \
1167 ORDER BY SUM(tokens_original) DESC \
1168 LIMIT ?1",
1169 ).map_err(SqzError::SessionStore)?;
1170
1171 let rows = stmt.query_map(params![limit], |row| {
1172 let tokens_in: u64 = row.get(2)?;
1173 let tokens_out: u64 = row.get(3)?;
1174 Ok(CommandStats {
1175 command: row.get(0)?,
1176 invocations: row.get(1)?,
1177 tokens_in,
1178 tokens_out,
1179 tokens_saved: tokens_in.saturating_sub(tokens_out),
1180 })
1181 }).map_err(SqzError::SessionStore)?;
1182
1183 let mut results = Vec::new();
1184 for row in rows {
1185 results.push(row.map_err(SqzError::SessionStore)?);
1186 }
1187 Ok(results)
1188 }
1189
1190 pub fn command_breakdown_for_project(&self, limit: u32, project_dir: &str) -> Result<Vec<CommandStats>> {
1192 let mut stmt = self.db.prepare(
1193 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1194 FROM compression_log \
1195 WHERE project_dir = ?1 \
1196 GROUP BY mode \
1197 ORDER BY SUM(tokens_original) DESC \
1198 LIMIT ?2",
1199 ).map_err(SqzError::SessionStore)?;
1200
1201 let rows = stmt.query_map(params![project_dir, limit], |row| {
1202 let tokens_in: u64 = row.get(2)?;
1203 let tokens_out: u64 = row.get(3)?;
1204 Ok(CommandStats {
1205 command: row.get(0)?,
1206 invocations: row.get(1)?,
1207 tokens_in,
1208 tokens_out,
1209 tokens_saved: tokens_in.saturating_sub(tokens_out),
1210 })
1211 }).map_err(SqzError::SessionStore)?;
1212
1213 let mut results = Vec::new();
1214 for row in rows {
1215 results.push(row.map_err(SqzError::SessionStore)?);
1216 }
1217 Ok(results)
1218 }
1219
1220 pub fn session_pressure(&self, since_minutes: u32) -> Result<u64> {
1224 let mut stmt = self.db.prepare(
1225 "SELECT COALESCE(SUM(tokens_compressed), 0) \
1226 FROM compression_log \
1227 WHERE created_at >= datetime('now', ?1)",
1228 ).map_err(SqzError::SessionStore)?;
1229
1230 let offset = format!("-{since_minutes} minutes");
1231 let total: u64 = stmt.query_row(params![offset], |row| {
1232 row.get(0)
1233 }).map_err(SqzError::SessionStore)?;
1234
1235 Ok(total)
1236 }
1237
1238 pub fn add_known_file(&self, path: &str) -> Result<()> {
1243 let now = Utc::now().to_rfc3339();
1244 self.db.execute(
1245 "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
1246 params![path, now],
1247 ).map_err(SqzError::SessionStore)?;
1248 Ok(())
1249 }
1250
1251 pub fn known_files(&self) -> Result<Vec<String>> {
1253 let mut stmt = self.db.prepare(
1254 "SELECT path FROM known_files ORDER BY added_at DESC",
1255 ).map_err(SqzError::SessionStore)?;
1256
1257 let rows = stmt.query_map([], |row| {
1258 row.get::<_, String>(0)
1259 }).map_err(SqzError::SessionStore)?;
1260
1261 let mut files = Vec::new();
1262 for row in rows {
1263 files.push(row.map_err(SqzError::SessionStore)?);
1264 }
1265 Ok(files)
1266 }
1267 pub fn clear_known_files(&self) -> Result<()> {
1269 self.db
1270 .execute("DELETE FROM known_files", [])
1271 .map_err(SqzError::SessionStore)?;
1272 Ok(())
1273 }
1274
1275 pub fn cache_entry_count(&self) -> Result<u64> {
1277 self.db.query_row(
1278 "SELECT COUNT(*) FROM cache_entries", [],
1279 |row| row.get(0),
1280 ).map_err(SqzError::SessionStore)
1281 }
1282
1283 pub fn record_cache_hit(&self) -> Result<()> {
1285 let count: u64 = self.db.query_row(
1286 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1287 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1288 ).unwrap_or(0);
1289 let new = count + 1;
1290 self.db.execute(
1291 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_hits', ?1)",
1292 params![new.to_string()],
1293 )?;
1294 Ok(())
1295 }
1296
1297 pub fn record_cache_miss(&self) -> Result<()> {
1299 let count: u64 = self.db.query_row(
1300 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1301 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1302 ).unwrap_or(0);
1303 let new = count + 1;
1304 self.db.execute(
1305 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_misses', ?1)",
1306 params![new.to_string()],
1307 )?;
1308 Ok(())
1309 }
1310
1311 pub fn cache_hit_miss_counts(&self) -> Result<(u64, u64)> {
1313 let hits: u64 = self.db.query_row(
1314 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1315 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1316 ).unwrap_or(0);
1317 let misses: u64 = self.db.query_row(
1318 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1319 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1320 ).unwrap_or(0);
1321 Ok((hits, misses))
1322 }
1323
1324 pub fn clear_cache(&self) -> Result<u64> {
1328 let count: u64 = self.db.query_row(
1329 "SELECT COUNT(*) FROM cache_entries", [],
1330 |row| row.get(0),
1331 ).unwrap_or(0);
1332 self.db.execute("DELETE FROM cache_entries", [])
1333 .map_err(SqzError::SessionStore)?;
1334 let _ = self.db.execute(
1335 "DELETE FROM recall_index WHERE kind = 'output'", [],
1336 );
1337 let _ = self.db.execute(
1340 "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1341 );
1342 Ok(count)
1343 }
1344
1345 pub fn clear_stats(&self) -> Result<u64> {
1349 let count: u64 = self.db.query_row(
1350 "SELECT COUNT(*) FROM compression_log", [],
1351 |row| row.get(0),
1352 ).unwrap_or(0);
1353 self.db.execute("DELETE FROM compression_log", [])
1354 .map_err(SqzError::SessionStore)?;
1355 Ok(count)
1356 }
1357
1358 pub fn clear_stats_for_project(&self, project_dir: &str) -> Result<u64> {
1360 let count: u64 = self.db.query_row(
1361 "SELECT COUNT(*) FROM compression_log WHERE project_dir = ?1",
1362 rusqlite::params![project_dir],
1363 |row| row.get(0),
1364 ).unwrap_or(0);
1365 self.db.execute(
1366 "DELETE FROM compression_log WHERE project_dir = ?1",
1367 rusqlite::params![project_dir],
1368 ).map_err(SqzError::SessionStore)?;
1369 Ok(count)
1370 }
1371
1372 pub fn reset_all(&self) -> Result<()> {
1375 self.db.execute("DELETE FROM cache_entries", []).map_err(SqzError::SessionStore)?;
1376 self.db.execute("DELETE FROM compression_log", []).map_err(SqzError::SessionStore)?;
1377 self.db.execute("DELETE FROM sessions", []).map_err(SqzError::SessionStore)?;
1378 self.db.execute("DELETE FROM known_files", []).map_err(SqzError::SessionStore)?;
1379 self.db.execute("DELETE FROM metadata", []).map_err(SqzError::SessionStore)?;
1380 let _ = self.db.execute("DELETE FROM recall_index", []);
1381 let _ = self.db.execute("VACUUM", []);
1383 Ok(())
1384 }
1385
1386 pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionSummary>> {
1388 let mut stmt = self
1389 .db
1390 .prepare(
1391 "SELECT id, project_dir, compressed_summary, created_at, updated_at \
1392 FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1393 )
1394 .map_err(SqzError::SessionStore)?;
1395
1396 let rows = stmt
1397 .query_map(params![limit], |row| {
1398 Ok((
1399 row.get::<_, String>(0)?,
1400 row.get::<_, String>(1)?,
1401 row.get::<_, String>(2)?,
1402 row.get::<_, String>(3)?,
1403 row.get::<_, String>(4)?,
1404 ))
1405 })
1406 .map_err(SqzError::SessionStore)?;
1407
1408 let mut results = Vec::new();
1409 for row in rows {
1410 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
1411 results.push(row_to_summary(
1412 id,
1413 project_dir,
1414 compressed_summary,
1415 created_at,
1416 updated_at,
1417 )?);
1418 }
1419 Ok(results)
1420 }
1421
1422 pub fn per_tool_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1424 let mut stmt = self
1425 .db
1426 .prepare(
1427 "SELECT stages_applied, SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1428 FROM compression_log GROUP BY stages_applied ORDER BY COUNT(*) DESC LIMIT 20",
1429 )
1430 .map_err(SqzError::SessionStore)?;
1431
1432 let rows = stmt
1433 .query_map([], |row| {
1434 Ok((
1435 row.get::<_, String>(0)?,
1436 row.get::<_, u64>(1)?,
1437 row.get::<_, u64>(2)?,
1438 row.get::<_, u32>(3)?,
1439 ))
1440 })
1441 .map_err(SqzError::SessionStore)?;
1442
1443 let mut tool_map: std::collections::HashMap<String, (u64, u64, u32)> =
1444 std::collections::HashMap::new();
1445 for row in rows {
1446 let (stages, tokens_in, tokens_out, count) = row?;
1447 for stage in stages.split(',') {
1448 let stage = stage.trim();
1449 if stage.is_empty() {
1450 continue;
1451 }
1452 let entry = tool_map.entry(stage.to_string()).or_default();
1453 entry.0 += tokens_in;
1454 entry.1 += tokens_out;
1455 entry.2 += count;
1456 }
1457 }
1458
1459 let mut result: Vec<_> = tool_map.into_iter().collect();
1460 result.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
1461 Ok(result
1462 .into_iter()
1463 .map(|(name, (tin, tout, cnt))| (name, tin, tout, cnt))
1464 .collect())
1465 }
1466
1467 pub fn per_command_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1469 let mut stmt = self
1470 .db
1471 .prepare(
1472 "SELECT COALESCE(mode, 'auto'), SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1473 FROM compression_log GROUP BY mode ORDER BY COUNT(*) DESC LIMIT 20",
1474 )
1475 .map_err(SqzError::SessionStore)?;
1476
1477 let rows = stmt
1478 .query_map([], |row| {
1479 Ok((
1480 row.get::<_, String>(0)?,
1481 row.get::<_, u64>(1)?,
1482 row.get::<_, u64>(2)?,
1483 row.get::<_, u32>(3)?,
1484 ))
1485 })
1486 .map_err(SqzError::SessionStore)?;
1487
1488 let mut result = Vec::new();
1489 for row in rows {
1490 result.push(row?);
1491 }
1492 Ok(result)
1493 }
1494}
1495
1496#[derive(Debug, Clone, Default)]
1498pub struct CompressionStats {
1499 pub total_compressions: u32,
1500 pub total_tokens_in: u64,
1501 pub total_tokens_out: u64,
1502}
1503
1504impl CompressionStats {
1505 pub fn tokens_saved(&self) -> u64 {
1506 self.total_tokens_in.saturating_sub(self.total_tokens_out)
1507 }
1508
1509 pub fn reduction_pct(&self) -> f64 {
1510 if self.total_tokens_in == 0 {
1511 0.0
1512 } else {
1513 (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
1514 }
1515 }
1516}
1517
1518#[derive(Debug, Clone)]
1520pub struct DailyGain {
1521 pub date: String,
1522 pub compressions: u32,
1523 pub tokens_saved: u64,
1524 pub tokens_in: u64,
1525}
1526
1527#[derive(Debug, Clone)]
1529pub struct CommandStats {
1530 pub command: String,
1531 pub invocations: u32,
1532 pub tokens_in: u64,
1533 pub tokens_out: u64,
1534 pub tokens_saved: u64,
1535}
1536
1537#[derive(Debug, Clone)]
1540pub struct SliceCandidate {
1541 pub hash: String,
1542 pub original: Vec<u8>,
1543 pub data_json: String,
1544}
1545
1546#[derive(Debug, Clone)]
1548pub struct RecallHit {
1549 pub ref_hash: String,
1552 pub kind: String,
1553 pub snippet: String,
1555 pub created_at: String,
1556}
1557
1558fn fts_query(raw: &str) -> String {
1562 raw.split_whitespace()
1563 .filter_map(|term| {
1564 let cleaned = term.replace('"', "");
1565 if cleaned.is_empty() {
1566 None
1567 } else {
1568 Some(format!("\"{cleaned}\""))
1569 }
1570 })
1571 .collect::<Vec<_>>()
1572 .join(" ")
1573}
1574
1575#[derive(Debug, Clone, Default)]
1577pub struct RegretStats {
1578 pub reruns: u32,
1580 pub expands: u32,
1582 pub avg_rerun_gap_secs: f64,
1584}
1585
1586impl CommandStats {
1587 pub fn reduction_pct(&self) -> f64 {
1588 if self.tokens_in == 0 {
1589 0.0
1590 } else {
1591 (1.0 - self.tokens_out as f64 / self.tokens_in as f64) * 100.0
1592 }
1593 }
1594}
1595
1596#[cfg(test)]
1599mod tests {
1600 use super::*;
1601 use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
1602 use chrono::Utc;
1603 use proptest::prelude::*;
1604 use std::path::PathBuf;
1605
1606 fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
1607 let now = Utc::now();
1608 SessionState {
1609 id: id.to_string(),
1610 project_dir: PathBuf::from(project_dir),
1611 conversation: vec![],
1612 corrections: CorrectionLog::default(),
1613 pins: vec![],
1614 learnings: vec![],
1615 compressed_summary: summary.to_string(),
1616 budget: BudgetState {
1617 window_size: 200_000,
1618 consumed: 0,
1619 pinned: 0,
1620 model_family: ModelFamily::AnthropicClaude,
1621 },
1622 tool_usage: vec![],
1623 created_at: now,
1624 updated_at: now,
1625 }
1626 }
1627
1628 fn in_memory_store() -> SessionStore {
1629 let conn = Connection::open_in_memory().unwrap();
1630 apply_schema(&conn).unwrap();
1631 SessionStore { db: conn }
1632 }
1633
1634 fn test_content(data: &str) -> CompressedContent {
1635 CompressedContent {
1636 data: data.to_string(),
1637 tokens_compressed: 1,
1638 tokens_original: 2,
1639 stages_applied: vec![],
1640 compression_ratio: 0.5,
1641 provenance: crate::types::Provenance::default(),
1642 verify: None,
1643 }
1644 }
1645
1646 #[test]
1647 fn recall_indexes_cache_originals_and_searches() {
1648 let store = in_memory_store();
1649 let content = test_content("compressed");
1650 store
1651 .save_cache_entry_with_original(
1652 "hash-auth",
1653 &content,
1654 Some(b"error: auth middleware rejected the session token"),
1655 )
1656 .unwrap();
1657 store
1658 .save_cache_entry_with_original(
1659 "hash-build",
1660 &content,
1661 Some(b"cargo build finished in 32s with zero warnings"),
1662 )
1663 .unwrap();
1664
1665 let hits = store.recall_search("auth middleware", 5).unwrap();
1666 assert_eq!(hits.len(), 1);
1667 assert_eq!(hits[0].ref_hash, "hash-auth");
1668 assert_eq!(hits[0].kind, "output");
1669 assert!(hits[0].snippet.contains(">>auth<<"), "snippet: {}", hits[0].snippet);
1670
1671 store
1673 .save_cache_entry_with_original(
1674 "hash-auth",
1675 &content,
1676 Some(b"error: auth middleware rejected the session token"),
1677 )
1678 .unwrap();
1679 assert_eq!(store.recall_search("middleware", 5).unwrap().len(), 1);
1680 }
1681
1682 #[test]
1683 fn recall_skips_binary_and_survives_odd_queries() {
1684 let store = in_memory_store();
1685 let content = test_content("c");
1686 store
1687 .save_cache_entry_with_original("hash-bin", &content, Some(b"binary\x00payload"))
1688 .unwrap();
1689 assert!(store.recall_search("binary", 5).unwrap().is_empty());
1690
1691 for q in ["NOT AND OR", "\"unbalanced", "col:umn", "wild*", "(paren", "-", ""] {
1693 let _ = store.recall_search(q, 5).unwrap();
1694 }
1695 }
1696
1697 #[test]
1698 fn recall_deletion_and_clear_coherence() {
1699 let store = in_memory_store();
1700 let content = test_content("c");
1701 store
1702 .save_cache_entry_with_original("hash-x", &content, Some(b"kubectl rollout restarted deployment"))
1703 .unwrap();
1704 assert_eq!(store.recall_search("rollout", 5).unwrap().len(), 1);
1705
1706 store.delete_cache_entry("hash-x").unwrap();
1707 assert!(store.recall_search("rollout", 5).unwrap().is_empty());
1708
1709 store
1711 .save_cache_entry_with_original("hash-y", &content, Some(b"terraform plan shows three changes"))
1712 .unwrap();
1713 let session = make_session("sess-recall", "/tmp/p", "refactored the billing retry loop");
1714 store.save_session(&session).unwrap();
1715 store.clear_cache().unwrap();
1716 assert!(store.recall_search("terraform", 5).unwrap().is_empty());
1717 let session_hits = store.recall_search("billing retry", 5).unwrap();
1718 assert_eq!(session_hits.len(), 1);
1719 assert_eq!(session_hits[0].kind, "session");
1720 assert_eq!(session_hits[0].ref_hash, "sess-recall");
1721
1722 store.reset_all().unwrap();
1723 assert!(store.recall_search("billing", 5).unwrap().is_empty());
1724 }
1725
1726 #[test]
1727 fn recall_ranks_better_matches_first() {
1728 let store = in_memory_store();
1729 let content = test_content("c");
1730 store
1731 .save_cache_entry_with_original(
1732 "hash-dense",
1733 &content,
1734 Some(b"panic unwrap panic unwrap panic in worker thread"),
1735 )
1736 .unwrap();
1737 store
1738 .save_cache_entry_with_original(
1739 "hash-sparse",
1740 &content,
1741 Some(format!("{} panic once at the end", "filler line of ordinary words\n".repeat(50)).as_bytes()),
1742 )
1743 .unwrap();
1744 let hits = store.recall_search("panic", 5).unwrap();
1745 assert_eq!(hits.len(), 2);
1746 assert_eq!(hits[0].ref_hash, "hash-dense", "bm25 should rank the denser match first");
1747 }
1748
1749 #[test]
1750 fn formatter_gaps_ranks_low_reduction_high_volume() {
1751 let store = in_memory_store();
1752 for _ in 0..4 {
1754 store.log_compression(1_000, 950, &[], "xcodebuild").unwrap();
1755 }
1756 for _ in 0..4 {
1758 store.log_compression(1_000, 100, &[], "cargo test").unwrap();
1759 }
1760 store.log_compression(100, 95, &[], "tiny-cmd").unwrap();
1762 store.log_compression(5_000, 13, &["dedup".to_string()], "dedup").unwrap();
1764
1765 let gaps = store.formatter_gaps(7, 10).unwrap();
1766 assert_eq!(gaps.len(), 1, "{gaps:?}");
1767 assert_eq!(gaps[0].command, "xcodebuild");
1768 assert_eq!(gaps[0].invocations, 4);
1769 assert!(gaps[0].reduction_pct() < 25.0);
1770 }
1771
1772 #[test]
1773 fn regret_log_roundtrip() {
1774 let store = in_memory_store();
1775 let empty = store.regret_stats().unwrap();
1777 assert_eq!(empty.reruns, 0);
1778 assert_eq!(empty.expands, 0);
1779 assert_eq!(empty.avg_rerun_gap_secs, 0.0);
1780
1781 store.log_regret("rerun", "git log", "aaaa", 10.0).unwrap();
1782 store.log_regret("rerun", "git log", "bbbb", 30.0).unwrap();
1783 store.log_regret("rerun", "cargo test", "cccc", 20.0).unwrap();
1784 store.log_regret("expand", "", "dddd", 0.0).unwrap();
1785
1786 let stats = store.regret_stats().unwrap();
1787 assert_eq!(stats.reruns, 3);
1788 assert_eq!(stats.expands, 1);
1789 assert!((stats.avg_rerun_gap_secs - 20.0).abs() < 1e-9);
1790
1791 let top = store.regret_by_command(10).unwrap();
1792 assert_eq!(top[0], ("git log".to_string(), 2));
1793 assert_eq!(top[1], ("cargo test".to_string(), 1));
1794 assert_eq!(top.len(), 2);
1796 }
1797
1798 #[test]
1799 fn test_save_and_load_session() {
1800 let store = in_memory_store();
1801 let session = make_session("sess-1", "/home/user/project", "REST API refactor");
1802
1803 let id = store.save_session(&session).unwrap();
1804 assert_eq!(id, "sess-1");
1805
1806 let loaded = store.load_session("sess-1".to_string()).unwrap();
1807 assert_eq!(loaded.id, session.id);
1808 assert_eq!(loaded.compressed_summary, session.compressed_summary);
1809 assert_eq!(loaded.project_dir, session.project_dir);
1810 }
1811
1812 #[test]
1813 fn test_save_session_upsert() {
1814 let store = in_memory_store();
1815 let mut session = make_session("sess-2", "/proj", "initial summary");
1816 store.save_session(&session).unwrap();
1817
1818 session.compressed_summary = "updated summary".to_string();
1819 store.save_session(&session).unwrap();
1820
1821 let loaded = store.load_session("sess-2".to_string()).unwrap();
1822 assert_eq!(loaded.compressed_summary, "updated summary");
1823 }
1824
1825 #[test]
1826 fn test_load_nonexistent_session_errors() {
1827 let store = in_memory_store();
1828 let result = store.load_session("does-not-exist".to_string());
1829 assert!(result.is_err());
1830 }
1831
1832 #[test]
1833 fn test_search_fts() {
1834 let store = in_memory_store();
1835 store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
1836 store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
1837
1838 let results = store.search("authentication").unwrap();
1839 assert_eq!(results.len(), 1);
1840 assert_eq!(results[0].id, "s1");
1841 }
1842
1843 #[test]
1844 fn test_search_by_date() {
1845 let store = in_memory_store();
1846 let now = Utc::now();
1847 let past = now - chrono::Duration::hours(2);
1848 let future = now + chrono::Duration::hours(2);
1849
1850 store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
1851
1852 let results = store.search_by_date(past, future).unwrap();
1853 assert!(!results.is_empty());
1854 assert!(results.iter().any(|r| r.id == "s1"));
1855 }
1856
1857 #[test]
1858 fn test_search_by_project() {
1859 let store = in_memory_store();
1860 store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
1861 store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
1862
1863 let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
1864 assert_eq!(results.len(), 1);
1865 assert_eq!(results[0].id, "s1");
1866 }
1867
1868 #[test]
1869 fn test_cache_entry_round_trip() {
1870 let store = in_memory_store();
1871 let entry = CompressedContent {
1872 data: "compressed data".to_string(),
1873 tokens_compressed: 10,
1874 tokens_original: 50,
1875 stages_applied: vec!["strip_nulls".to_string()],
1876 compression_ratio: 0.2,
1877 provenance: crate::types::Provenance::default(),
1878 verify: None,
1879 };
1880
1881 store.save_cache_entry("abc123", &entry).unwrap();
1882
1883 let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
1884 assert_eq!(loaded.data, entry.data);
1885 assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
1886 assert_eq!(loaded.tokens_original, entry.tokens_original);
1887 }
1888
1889 #[test]
1890 fn test_get_cache_entry_missing_returns_none() {
1891 let store = in_memory_store();
1892 let result = store.get_cache_entry("nonexistent").unwrap();
1893 assert!(result.is_none());
1894 }
1895
1896 #[test]
1897 fn test_open_or_create_corrupted_db() {
1898 let dir = tempfile::tempdir().unwrap();
1899 let path = dir.path().join("store.db");
1900
1901 std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
1903
1904 let store = SessionStore::open_or_create(&path).unwrap();
1906 let session = make_session("s1", "/proj", "after corruption");
1907 store.save_session(&session).unwrap();
1908 let loaded = store.load_session("s1".to_string()).unwrap();
1909 assert_eq!(loaded.id, "s1");
1910 }
1911
1912 fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
1916 let now = Utc::now();
1917 SessionState {
1918 id: id.to_string(),
1919 project_dir: PathBuf::from("/proj"),
1920 conversation: vec![],
1921 corrections: CorrectionLog::default(),
1922 pins: vec![],
1923 learnings: vec![],
1924 compressed_summary: summary.to_string(),
1925 budget: BudgetState {
1926 window_size: 200_000,
1927 consumed: 0,
1928 pinned: 0,
1929 model_family: ModelFamily::AnthropicClaude,
1930 },
1931 tool_usage: vec![],
1932 created_at: now,
1933 updated_at,
1934 }
1935 }
1936
1937 proptest! {
1945 #[test]
1951 fn prop_search_correctness(
1952 keyword in "[b-df-hj-np-tv-z]{5,8}",
1955 matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
1957 non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
1959 ) {
1960 for s in &non_matching {
1962 prop_assume!(!s.contains(keyword.as_str()));
1963 }
1964
1965 let store = in_memory_store();
1966
1967 let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1969 for (i, suffix) in matching_suffixes.iter().enumerate() {
1970 let id = format!("match-{i}");
1971 let summary = format!("{} {} end", suffix, keyword);
1972 store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
1973 matching_ids.insert(id);
1974 }
1975
1976 let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1978 for (i, summary) in non_matching.iter().enumerate() {
1979 let id = format!("nomatch-{i}");
1980 store.save_session(&make_session(&id, "/proj", summary)).unwrap();
1981 non_matching_ids.insert(id);
1982 }
1983
1984 let results = store.search(&keyword).unwrap();
1985 let result_ids: std::collections::HashSet<String> =
1986 results.iter().map(|r| r.id.clone()).collect();
1987
1988 for id in &matching_ids {
1990 prop_assert!(
1991 result_ids.contains(id),
1992 "matching session '{}' not found in search results for keyword '{}'",
1993 id, keyword
1994 );
1995 }
1996
1997 for id in &non_matching_ids {
1999 prop_assert!(
2000 !result_ids.contains(id),
2001 "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
2002 id, keyword
2003 );
2004 }
2005 }
2006 }
2007
2008 proptest! {
2016 #[test]
2022 fn prop_search_by_date_correctness(
2023 offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
2025 window_start_delta in 0i64..=3600i64,
2027 window_end_delta in 3600i64..=7200i64,
2028 ) {
2029 use chrono::TimeZone;
2030
2031 let mut unique_offsets: Vec<i64> = offsets.clone();
2033 unique_offsets.sort_unstable();
2034 unique_offsets.dedup();
2035 prop_assume!(unique_offsets.len() >= 2);
2036
2037 let base_offset = unique_offsets[0];
2038 let from_offset = base_offset + window_start_delta;
2039 let to_offset = base_offset + window_end_delta;
2040
2041 let from = Utc.timestamp_opt(from_offset, 0).unwrap();
2042 let to = Utc.timestamp_opt(to_offset, 0).unwrap();
2043
2044 let store = in_memory_store();
2045
2046 let mut in_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2047 let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2048
2049 for (i, &offset) in unique_offsets.iter().enumerate() {
2050 let ts = Utc.timestamp_opt(offset, 0).unwrap();
2051 let id = format!("sess-{i}");
2052 let session = make_session_at(&id, "some summary", ts);
2053 store.save_session(&session).unwrap();
2054
2055 if ts >= from && ts <= to {
2056 in_range_ids.insert(id);
2057 } else {
2058 out_range_ids.insert(id);
2059 }
2060 }
2061
2062 let results = store.search_by_date(from, to).unwrap();
2063 let result_ids: std::collections::HashSet<String> =
2064 results.iter().map(|r| r.id.clone()).collect();
2065
2066 for id in &in_range_ids {
2068 prop_assert!(
2069 result_ids.contains(id),
2070 "in-range session '{}' missing from search_by_date results",
2071 id
2072 );
2073 }
2074
2075 for id in &out_range_ids {
2077 prop_assert!(
2078 !result_ids.contains(id),
2079 "out-of-range session '{}' incorrectly appeared in search_by_date results",
2080 id
2081 );
2082 }
2083 }
2084 }
2085}