1use std::path::Path;
2use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use rusqlite::{params, Connection, OpenFlags};
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Result, SqzError};
9use crate::types::{CompressedContent, SessionId, SessionState};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionSummary {
15 pub id: SessionId,
16 pub project_dir: PathBuf,
17 pub compressed_summary: String,
18 pub created_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20}
21
22pub struct SessionStore {
38 db: Connection,
39}
40
41const SCHEMA: &str = r#"
44PRAGMA journal_mode = WAL;
45
46CREATE TABLE IF NOT EXISTS sessions (
47 id TEXT PRIMARY KEY,
48 project_dir TEXT NOT NULL,
49 compressed_summary TEXT NOT NULL,
50 created_at TEXT NOT NULL,
51 updated_at TEXT NOT NULL,
52 data BLOB NOT NULL
53);
54
55CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
56 id,
57 project_dir,
58 compressed_summary,
59 content='sessions',
60 content_rowid='rowid',
61 tokenize='porter ascii'
62);
63
64CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
65 INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
66 VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
67END;
68
69CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
70 INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
71 VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
72END;
73
74CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
75 INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
76 VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
77 INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
78 VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
79END;
80
81CREATE TABLE IF NOT EXISTS cache_entries (
82 hash TEXT PRIMARY KEY,
83 data TEXT NOT NULL,
84 accessed_at TEXT NOT NULL,
85 -- Raw pre-compression bytes so `sqz expand <prefix>` can serve
86 -- truly uncompressed content to agents that cannot parse
87 -- `§ref:…§` dedup tokens. Nullable because the column was added
88 -- in an additive migration; rows written before that migration
89 -- (or via callers that don't have the original bytes) have NULL.
90 original BLOB,
91 -- Pinned entries bypass the TTL freshness check. Used for stable
92 -- knowledge-base content (a wiki, project documentation, system
93 -- prompts) that's persistently re-injected into every agent and
94 -- therefore never gets compacted out of the LLM's context. See
95 -- `sqz pin` for the user-facing CLI.
96 pinned INTEGER NOT NULL DEFAULT 0
97);
98
99CREATE TABLE IF NOT EXISTS compression_log (
100 id INTEGER PRIMARY KEY AUTOINCREMENT,
101 tokens_original INTEGER NOT NULL,
102 tokens_compressed INTEGER NOT NULL,
103 stages_applied TEXT NOT NULL,
104 mode TEXT NOT NULL DEFAULT 'auto',
105 created_at TEXT NOT NULL
106);
107
108-- Compression-regret signals. A 'rerun' row means the agent re-produced
109-- byte-identical output within a short window of last seeing it (the
110-- repeat bought no new information); an 'expand' row means a §ref:…§
111-- token was expanded back to its original bytes (a recovery round trip).
112-- Both are proxies for "compression dropped something the agent needed",
113-- surfaced by `sqz stats` so aggressiveness can be judged per command.
114CREATE TABLE IF NOT EXISTS regret_log (
115 id INTEGER PRIMARY KEY AUTOINCREMENT,
116 kind TEXT NOT NULL,
117 cmd TEXT NOT NULL,
118 hash TEXT NOT NULL,
119 gap_secs REAL NOT NULL,
120 created_at TEXT NOT NULL
121);
122
123CREATE TABLE IF NOT EXISTS known_files (
124 path TEXT PRIMARY KEY,
125 added_at TEXT NOT NULL
126);
127
128-- Small key/value store for engine-wide state that needs to persist across
129-- short-lived sqz processes (each shell-hook invocation is a new process).
130-- Initially used only for the last_compaction_at marker: cache entries with
131-- `accessed_at < last_compaction_at` are treated as stale even if still
132-- within the normal TTL. See cache_manager.rs for the freshness model.
133CREATE TABLE IF NOT EXISTS metadata (
134 key TEXT PRIMARY KEY,
135 value TEXT NOT NULL
136);
137
138-- Full-text search over everything that flowed through sqz: cached
139-- command output (kind='output', ref_hash expandable via `sqz expand`)
140-- and session summaries (kind='session', ref_hash = session id). Lets
141-- agents re-find content they have already seen instead of re-running
142-- commands or re-reading files. BM25-ranked via FTS5.
143CREATE VIRTUAL TABLE IF NOT EXISTS recall_index USING fts5(
144 content,
145 ref_hash UNINDEXED,
146 kind UNINDEXED,
147 created_at UNINDEXED
148);
149"#;
150
151pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
154 conn.execute_batch(SCHEMA)?;
155 let has_original: bool = conn
167 .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'original'")?
168 .query_row([], |_| Ok(()))
169 .is_ok();
170 if !has_original {
171 conn.execute("ALTER TABLE cache_entries ADD COLUMN original BLOB", [])?;
172 }
173
174 let has_project_dir: bool = conn
179 .prepare("SELECT 1 FROM pragma_table_info('compression_log') WHERE name = 'project_dir'")?
180 .query_row([], |_| Ok(()))
181 .is_ok();
182 if !has_project_dir {
183 conn.execute("ALTER TABLE compression_log ADD COLUMN project_dir TEXT", [])?;
184 }
185
186 let has_pinned: bool = conn
190 .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'pinned'")?
191 .query_row([], |_| Ok(()))
192 .is_ok();
193 if !has_pinned {
194 conn.execute(
195 "ALTER TABLE cache_entries ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
196 [],
197 )?;
198 }
199
200 Ok(())
201}
202
203fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
204 let conn = Connection::open(path)?;
205 apply_schema(&conn)?;
206 Ok(conn)
207}
208
209fn row_to_summary(
210 id: String,
211 project_dir: String,
212 compressed_summary: String,
213 created_at: String,
214 updated_at: String,
215) -> Result<SessionSummary> {
216 let created_at = created_at
217 .parse::<DateTime<Utc>>()
218 .map_err(|e| SqzError::Other(format!("invalid created_at timestamp: {e}")))?;
219 let updated_at = updated_at
220 .parse::<DateTime<Utc>>()
221 .map_err(|e| SqzError::Other(format!("invalid updated_at timestamp: {e}")))?;
222 Ok(SessionSummary {
223 id,
224 project_dir: PathBuf::from(project_dir),
225 compressed_summary,
226 created_at,
227 updated_at,
228 })
229}
230
231impl SessionStore {
234 #[cfg(test)]
237 pub(crate) fn from_connection(conn: Connection) -> Self {
238 Self { db: conn }
239 }
240
241 pub fn open(path: &Path) -> Result<Self> {
244 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
245 apply_schema(&conn)?;
246 Ok(Self { db: conn })
247 }
248
249 pub fn open_or_create(path: &Path) -> Result<Self> {
253 match open_connection(path) {
254 Ok(conn) => Ok(Self { db: conn }),
255 Err(e) => {
256 eprintln!(
257 "sqz warning: session store at '{}' is corrupted or inaccessible ({e}). \
258 Creating a new database. Prior session data has been lost.",
259 path.display()
260 );
261 if path.exists() {
263 let _ = std::fs::remove_file(path);
264 }
265 let conn = open_connection(path)
266 .map_err(|e2| SqzError::Other(format!("failed to create new session store: {e2}")))?;
267 Ok(Self { db: conn })
268 }
269 }
270 }
271
272 pub fn save_session(&self, session: &SessionState) -> Result<SessionId> {
276 let data = serde_json::to_vec(session)?;
277 let project_dir = session.project_dir.to_string_lossy().to_string();
278 let created_at = session.created_at.to_rfc3339();
279 let updated_at = session.updated_at.to_rfc3339();
280
281 self.db.execute(
282 r#"INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data)
283 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
284 ON CONFLICT(id) DO UPDATE SET
285 project_dir = excluded.project_dir,
286 compressed_summary = excluded.compressed_summary,
287 created_at = excluded.created_at,
288 updated_at = excluded.updated_at,
289 data = excluded.data"#,
290 params![
291 session.id,
292 project_dir,
293 session.compressed_summary,
294 created_at,
295 updated_at,
296 data,
297 ],
298 )?;
299 if !session.compressed_summary.is_empty() {
300 self.recall_index_upsert(
301 &session.id,
302 "session",
303 session.compressed_summary.as_bytes(),
304 );
305 }
306
307 Ok(session.id.clone())
308 }
309
310 pub fn save_dashboard_session(&self, id: &str, summary: &str) -> Result<()> {
312 let now = Utc::now().to_rfc3339();
313 let project_dir = std::env::current_dir()
314 .unwrap_or_else(|_| std::path::PathBuf::from("."))
315 .to_string_lossy()
316 .to_string();
317 let empty_data = serde_json::to_vec(&serde_json::json!({"conversation":[]}))?;
318
319 self.db
320 .execute(
321 "INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data) \
322 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
323 ON CONFLICT(id) DO UPDATE SET \
324 compressed_summary = excluded.compressed_summary, \
325 updated_at = excluded.updated_at",
326 params![id, project_dir, summary, now, now, empty_data],
327 )
328 .map_err(SqzError::SessionStore)?;
329 Ok(())
330 }
331
332 pub fn load_session(&self, id: SessionId) -> Result<SessionState> {
334 let data: Vec<u8> = self.db.query_row(
335 "SELECT data FROM sessions WHERE id = ?1",
336 params![id],
337 |row| row.get(0),
338 )?;
339 let session: SessionState = serde_json::from_slice(&data)?;
340 Ok(session)
341 }
342
343 pub fn search(&self, query: &str) -> Result<Vec<SessionSummary>> {
347 let mut stmt = self.db.prepare(
348 r#"SELECT s.id, s.project_dir, s.compressed_summary, s.created_at, s.updated_at
349 FROM sessions s
350 JOIN sessions_fts f ON s.rowid = f.rowid
351 WHERE sessions_fts MATCH ?1
352 ORDER BY rank"#,
353 )?;
354
355 let rows = stmt.query_map(params![query], |row| {
356 Ok((
357 row.get::<_, String>(0)?,
358 row.get::<_, String>(1)?,
359 row.get::<_, String>(2)?,
360 row.get::<_, String>(3)?,
361 row.get::<_, String>(4)?,
362 ))
363 })?;
364
365 let mut results = Vec::new();
366 for row in rows {
367 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
368 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
369 }
370 Ok(results)
371 }
372
373 pub fn search_by_date(
375 &self,
376 from: DateTime<Utc>,
377 to: DateTime<Utc>,
378 ) -> Result<Vec<SessionSummary>> {
379 let mut stmt = self.db.prepare(
380 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
381 FROM sessions
382 WHERE updated_at >= ?1 AND updated_at <= ?2
383 ORDER BY updated_at DESC"#,
384 )?;
385
386 let rows = stmt.query_map(params![from.to_rfc3339(), to.to_rfc3339()], |row| {
387 Ok((
388 row.get::<_, String>(0)?,
389 row.get::<_, String>(1)?,
390 row.get::<_, String>(2)?,
391 row.get::<_, String>(3)?,
392 row.get::<_, String>(4)?,
393 ))
394 })?;
395
396 let mut results = Vec::new();
397 for row in rows {
398 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
399 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
400 }
401 Ok(results)
402 }
403
404 pub fn latest_session(&self) -> Result<Option<SessionSummary>> {
406 let mut stmt = self.db.prepare(
407 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
408 FROM sessions
409 ORDER BY updated_at DESC
410 LIMIT 1"#,
411 ).map_err(SqzError::SessionStore)?;
412
413 let rows = stmt.query_map([], |row| {
414 Ok((
415 row.get::<_, String>(0)?,
416 row.get::<_, String>(1)?,
417 row.get::<_, String>(2)?,
418 row.get::<_, String>(3)?,
419 row.get::<_, String>(4)?,
420 ))
421 }).map_err(SqzError::SessionStore)?;
422
423 for row in rows {
424 let (id, project_dir, compressed_summary, created_at, updated_at) =
425 row.map_err(SqzError::SessionStore)?;
426 return Ok(Some(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?));
427 }
428 Ok(None)
429 }
430
431 pub fn search_by_project(&self, dir: &Path) -> Result<Vec<SessionSummary>> {
433 let dir_str = dir.to_string_lossy().to_string();
434 let mut stmt = self.db.prepare(
435 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
436 FROM sessions
437 WHERE project_dir = ?1
438 ORDER BY updated_at DESC"#,
439 )?;
440
441 let rows = stmt.query_map(params![dir_str], |row| {
442 Ok((
443 row.get::<_, String>(0)?,
444 row.get::<_, String>(1)?,
445 row.get::<_, String>(2)?,
446 row.get::<_, String>(3)?,
447 row.get::<_, String>(4)?,
448 ))
449 })?;
450
451 let mut results = Vec::new();
452 for row in rows {
453 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
454 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
455 }
456 Ok(results)
457 }
458
459 pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
472 self.save_cache_entry_with_original(hash, compressed, None)
473 }
474
475 pub fn save_cache_entry_with_original(
482 &self,
483 hash: &str,
484 compressed: &CompressedContent,
485 original: Option<&[u8]>,
486 ) -> Result<()> {
487 let data = serde_json::to_string(compressed)?;
488 let now = Utc::now().to_rfc3339();
489 self.db.execute(
490 r#"INSERT INTO cache_entries (hash, data, accessed_at, original)
491 VALUES (?1, ?2, ?3, ?4)
492 ON CONFLICT(hash) DO UPDATE
493 SET data = excluded.data,
494 accessed_at = excluded.accessed_at,
495 -- Don't overwrite a previously-stored `original`
496 -- with NULL. Older callers (that go through
497 -- save_cache_entry rather than the _with_original
498 -- variant) shouldn't erase the expand-able bytes.
499 original = COALESCE(excluded.original, original)"#,
500 params![hash, data, now, original],
501 )?;
502 if let Some(bytes) = original {
503 self.recall_index_upsert(hash, "output", bytes);
504 }
505 Ok(())
506 }
507
508 fn recall_index_upsert(&self, ref_hash: &str, kind: &str, content: &[u8]) {
512 const MAX_INDEXED_BYTES: usize = 262_144;
513 if content.is_empty() || content.contains(&0) {
514 return;
515 }
516 let text = String::from_utf8_lossy(content);
517 let capped: &str = if text.len() > MAX_INDEXED_BYTES {
518 let mut end = MAX_INDEXED_BYTES;
519 while !text.is_char_boundary(end) {
520 end -= 1;
521 }
522 &text[..end]
523 } else {
524 &text
525 };
526 let now = Utc::now().to_rfc3339();
527 let _ = self.db.execute(
528 "DELETE FROM recall_index WHERE ref_hash = ?1",
529 params![ref_hash],
530 );
531 let _ = self.db.execute(
532 "INSERT INTO recall_index (content, ref_hash, kind, created_at) VALUES (?1, ?2, ?3, ?4)",
533 params![capped, ref_hash, kind, now],
534 );
535 }
536
537 pub fn recall_search(&self, query: &str, limit: u32) -> Result<Vec<RecallHit>> {
540 let match_expr = fts_query(query);
541 if match_expr.is_empty() {
542 return Ok(Vec::new());
543 }
544 let mut stmt = self.db.prepare(
545 "SELECT ref_hash, kind, snippet(recall_index, 0, '>>', '<<', ' … ', 24), created_at \
546 FROM recall_index WHERE recall_index MATCH ?1 \
547 ORDER BY bm25(recall_index) LIMIT ?2",
548 ).map_err(SqzError::SessionStore)?;
549 let rows = stmt.query_map(params![match_expr, limit], |row| {
550 Ok(RecallHit {
551 ref_hash: row.get(0)?,
552 kind: row.get(1)?,
553 snippet: row.get(2)?,
554 created_at: row.get(3)?,
555 })
556 }).map_err(SqzError::SessionStore)?;
557 let mut hits = Vec::new();
558 for row in rows {
559 hits.push(row.map_err(SqzError::SessionStore)?);
560 }
561 Ok(hits)
562 }
563
564 pub fn get_cache_entry_original(&self, hash: &str) -> Result<Option<Vec<u8>>> {
573 let result: rusqlite::Result<Option<Vec<u8>>> = self.db.query_row(
574 "SELECT original FROM cache_entries WHERE hash = ?1",
575 params![hash],
576 |row| row.get(0),
577 );
578 match result {
579 Ok(v) => Ok(v),
580 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
581 Err(e) => Err(SqzError::SessionStore(e)),
582 }
583 }
584
585 pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
587 self.db.execute(
588 "DELETE FROM cache_entries WHERE hash = ?1",
589 params![hash],
590 )?;
591 let _ = self.db.execute(
592 "DELETE FROM recall_index WHERE ref_hash = ?1",
593 params![hash],
594 );
595 Ok(())
596 }
597
598 pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
602 let mut stmt = self.db.prepare(
603 "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
604 )?;
605 let rows = stmt.query_map([], |row| {
606 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
607 })?;
608 let mut entries = Vec::new();
609 for row in rows {
610 let (hash, size) = row?;
611 entries.push((hash, size as u64));
612 }
613 Ok(entries)
614 }
615
616 pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
618 let result: rusqlite::Result<String> = self.db.query_row(
619 "SELECT data FROM cache_entries WHERE hash = ?1",
620 params![hash],
621 |row| row.get(0),
622 );
623
624 match result {
625 Ok(data) => {
626 let now = Utc::now().to_rfc3339();
628 let _ = self.db.execute(
629 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
630 params![now, hash],
631 );
632 let entry: CompressedContent = serde_json::from_str(&data)?;
633 Ok(Some(entry))
634 }
635 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
636 Err(e) => Err(SqzError::SessionStore(e)),
637 }
638 }
639
640 pub fn get_cache_entry_by_prefix(
660 &self,
661 prefix: &str,
662 ) -> Result<Option<(String, CompressedContent)>> {
663 if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
668 return Ok(None);
669 }
670 let pattern = format!("{prefix}%");
671 let mut stmt = self
672 .db
673 .prepare("SELECT hash, data FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
674 let mut rows = stmt.query(params![pattern])?;
675
676 let first = match rows.next()? {
677 Some(r) => {
678 let hash: String = r.get(0)?;
679 let data: String = r.get(1)?;
680 (hash, data)
681 }
682 None => return Ok(None),
683 };
684
685 if rows.next()?.is_some() {
689 return Err(SqzError::Other(format!(
690 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
691 )));
692 }
693 drop(rows);
694 drop(stmt);
695
696 let now = Utc::now().to_rfc3339();
698 let _ = self.db.execute(
699 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
700 params![now, first.0],
701 );
702 let entry: CompressedContent = serde_json::from_str(&first.1)?;
703 Ok(Some((first.0, entry)))
704 }
705
706 pub fn get_cache_entry_accessed_at(&self, hash: &str) -> Result<Option<DateTime<Utc>>> {
713 let result: rusqlite::Result<String> = self.db.query_row(
714 "SELECT accessed_at FROM cache_entries WHERE hash = ?1",
715 params![hash],
716 |row| row.get(0),
717 );
718 match result {
719 Ok(s) => {
720 let ts = s
721 .parse::<DateTime<Utc>>()
722 .map_err(|e| SqzError::Other(format!("invalid accessed_at: {e}")))?;
723 Ok(Some(ts))
724 }
725 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
726 Err(e) => Err(SqzError::SessionStore(e)),
727 }
728 }
729
730 pub fn cache_entry_exists(&self, hash: &str) -> Result<bool> {
732 let result: rusqlite::Result<i64> = self.db.query_row(
733 "SELECT 1 FROM cache_entries WHERE hash = ?1",
734 params![hash],
735 |row| row.get(0),
736 );
737 match result {
738 Ok(_) => Ok(true),
739 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
740 Err(e) => Err(SqzError::SessionStore(e)),
741 }
742 }
743
744 pub fn touch_cache_entry(&self, hash: &str) -> Result<()> {
748 let now = Utc::now().to_rfc3339();
749 self.db.execute(
750 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
751 params![now, hash],
752 )?;
753 Ok(())
754 }
755
756 pub fn resolve_cache_hash_by_prefix(&self, prefix: &str) -> Result<Option<String>> {
762 if prefix.is_empty()
763 || !prefix
764 .chars()
765 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
766 {
767 return Ok(None);
768 }
769 let pattern = format!("{prefix}%");
770 let mut stmt = self
771 .db
772 .prepare("SELECT hash FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
773 let mut rows = stmt.query(params![pattern])?;
774 let first: Option<String> = match rows.next()? {
775 Some(r) => Some(r.get(0)?),
776 None => None,
777 };
778 if rows.next()?.is_some() {
779 return Err(SqzError::Other(format!(
780 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
781 )));
782 }
783 Ok(first)
784 }
785
786 pub fn pin_cache_entry(&self, hash: &str) -> Result<bool> {
790 let n = self.db.execute(
791 "UPDATE cache_entries SET pinned = 1 WHERE hash = ?1",
792 params![hash],
793 )?;
794 Ok(n > 0)
795 }
796
797 pub fn unpin_cache_entry(&self, hash: &str) -> Result<bool> {
800 let n = self.db.execute(
801 "UPDATE cache_entries SET pinned = 0 WHERE hash = ?1",
802 params![hash],
803 )?;
804 Ok(n > 0)
805 }
806
807 pub fn unpin_all_cache_entries(&self) -> Result<usize> {
810 let n = self
811 .db
812 .execute("UPDATE cache_entries SET pinned = 0 WHERE pinned = 1", [])?;
813 Ok(n)
814 }
815
816 pub fn is_cache_entry_pinned(&self, hash: &str) -> Result<bool> {
819 let result: rusqlite::Result<i64> = self.db.query_row(
820 "SELECT pinned FROM cache_entries WHERE hash = ?1",
821 params![hash],
822 |row| row.get(0),
823 );
824 match result {
825 Ok(v) => Ok(v != 0),
826 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
827 Err(e) => Err(SqzError::SessionStore(e)),
828 }
829 }
830
831 pub fn list_pinned_cache_entries(
834 &self,
835 ) -> Result<Vec<(String, DateTime<Utc>, u64)>> {
836 let mut stmt = self
837 .db
838 .prepare(
839 "SELECT hash, accessed_at, length(data) FROM cache_entries \
840 WHERE pinned = 1 ORDER BY accessed_at DESC",
841 )
842 .map_err(SqzError::SessionStore)?;
843 let rows = stmt
844 .query_map([], |row| {
845 Ok((
846 row.get::<_, String>(0)?,
847 row.get::<_, String>(1)?,
848 row.get::<_, i64>(2)? as u64,
849 ))
850 })
851 .map_err(SqzError::SessionStore)?;
852 let mut out = Vec::new();
853 for row in rows {
854 let (hash, accessed_at_str, size) = row?;
855 let accessed_at = accessed_at_str.parse::<DateTime<Utc>>().map_err(|e| {
856 SqzError::Other(format!("invalid accessed_at on pinned entry: {e}"))
857 })?;
858 out.push((hash, accessed_at, size));
859 }
860 Ok(out)
861 }
862
863 pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
866 self.db.execute(
867 "INSERT INTO metadata (key, value) VALUES (?1, ?2)
868 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
869 params![key, value],
870 )?;
871 Ok(())
872 }
873
874 pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
876 let result: rusqlite::Result<String> = self.db.query_row(
877 "SELECT value FROM metadata WHERE key = ?1",
878 params![key],
879 |row| row.get(0),
880 );
881 match result {
882 Ok(v) => Ok(Some(v)),
883 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
884 Err(e) => Err(SqzError::SessionStore(e)),
885 }
886 }
887
888 pub fn log_compression(
890 &self,
891 tokens_original: u32,
892 tokens_compressed: u32,
893 stages: &[String],
894 mode: &str,
895 ) -> Result<()> {
896 self.log_compression_with_project(tokens_original, tokens_compressed, stages, mode, None)
897 }
898
899 pub fn log_compression_with_project(
901 &self,
902 tokens_original: u32,
903 tokens_compressed: u32,
904 stages: &[String],
905 mode: &str,
906 project_dir: Option<&str>,
907 ) -> Result<()> {
908 let now = Utc::now().to_rfc3339();
909 let stages_str = stages.join(",");
910 self.db.execute(
911 "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at, project_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
912 params![tokens_original, tokens_compressed, stages_str, mode, now, project_dir],
913 ).map_err(SqzError::SessionStore)?;
914 Ok(())
915 }
916
917 pub fn log_regret(&self, kind: &str, cmd: &str, hash: &str, gap_secs: f64) -> Result<()> {
922 let now = Utc::now().to_rfc3339();
923 self.db.execute(
924 "INSERT INTO regret_log (kind, cmd, hash, gap_secs, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
925 params![kind, cmd, hash, gap_secs, now],
926 ).map_err(SqzError::SessionStore)?;
927 Ok(())
928 }
929
930 pub fn regret_stats(&self) -> Result<RegretStats> {
932 let mut stmt = self.db.prepare(
933 "SELECT \
934 COALESCE(SUM(CASE WHEN kind = 'rerun' THEN 1 ELSE 0 END), 0), \
935 COALESCE(SUM(CASE WHEN kind = 'expand' THEN 1 ELSE 0 END), 0), \
936 COALESCE(AVG(CASE WHEN kind = 'rerun' THEN gap_secs END), 0.0) \
937 FROM regret_log",
938 ).map_err(SqzError::SessionStore)?;
939 let stats = stmt.query_row([], |row| {
940 Ok(RegretStats {
941 reruns: row.get::<_, u32>(0)?,
942 expands: row.get::<_, u32>(1)?,
943 avg_rerun_gap_secs: row.get::<_, f64>(2)?,
944 })
945 }).map_err(SqzError::SessionStore)?;
946 Ok(stats)
947 }
948
949 pub fn formatter_gaps(&self, days: u32, limit: u32) -> Result<Vec<CommandStats>> {
955 let offset = format!("-{days} days");
956 let mut stmt = self.db.prepare(
957 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
958 FROM compression_log \
959 WHERE created_at >= date('now', ?1) AND mode != 'dedup' \
960 GROUP BY mode \
961 HAVING SUM(tokens_original) >= 500 \
962 AND (SUM(tokens_original) - SUM(tokens_compressed)) * 100.0 \
963 / SUM(tokens_original) < 25.0 \
964 ORDER BY SUM(tokens_compressed) DESC \
965 LIMIT ?2",
966 ).map_err(SqzError::SessionStore)?;
967 let rows = stmt.query_map(params![offset, limit], |row| {
968 let tokens_in: u64 = row.get(2)?;
969 let tokens_out: u64 = row.get(3)?;
970 Ok(CommandStats {
971 command: row.get(0)?,
972 invocations: row.get(1)?,
973 tokens_in,
974 tokens_out,
975 tokens_saved: tokens_in.saturating_sub(tokens_out),
976 })
977 }).map_err(SqzError::SessionStore)?;
978 let mut results = Vec::new();
979 for row in rows {
980 results.push(row.map_err(SqzError::SessionStore)?);
981 }
982 Ok(results)
983 }
984
985 pub fn regret_by_command(&self, limit: u32) -> Result<Vec<(String, u32)>> {
987 let mut stmt = self.db.prepare(
988 "SELECT cmd, COUNT(*) FROM regret_log \
989 WHERE kind = 'rerun' AND cmd != '' \
990 GROUP BY cmd ORDER BY COUNT(*) DESC LIMIT ?1",
991 ).map_err(SqzError::SessionStore)?;
992 let rows = stmt.query_map(params![limit], |row| {
993 Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
994 }).map_err(SqzError::SessionStore)?;
995 let mut results = Vec::new();
996 for row in rows {
997 results.push(row.map_err(SqzError::SessionStore)?);
998 }
999 Ok(results)
1000 }
1001
1002 pub fn compression_stats(&self) -> Result<CompressionStats> {
1004 let mut stmt = self.db.prepare(
1005 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
1006 ).map_err(SqzError::SessionStore)?;
1007
1008 let stats = stmt.query_row([], |row| {
1009 Ok(CompressionStats {
1010 total_compressions: row.get::<_, u32>(0)?,
1011 total_tokens_in: row.get::<_, u64>(1)?,
1012 total_tokens_out: row.get::<_, u64>(2)?,
1013 })
1014 }).map_err(SqzError::SessionStore)?;
1015
1016 Ok(stats)
1017 }
1018
1019 pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
1021 let mut stmt = self.db.prepare(
1022 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1023 FROM compression_log \
1024 WHERE created_at >= date('now', ?1) \
1025 GROUP BY d ORDER BY d",
1026 ).map_err(SqzError::SessionStore)?;
1027
1028 let offset = format!("-{days} days");
1029 let rows = stmt.query_map(params![offset], |row| {
1030 let tokens_in: u64 = row.get(2)?;
1031 let tokens_out: u64 = row.get(3)?;
1032 Ok(DailyGain {
1033 date: row.get(0)?,
1034 compressions: row.get(1)?,
1035 tokens_in,
1036 tokens_saved: tokens_in.saturating_sub(tokens_out),
1037 })
1038 }).map_err(SqzError::SessionStore)?;
1039
1040 let mut gains = Vec::new();
1041 for row in rows {
1042 gains.push(row.map_err(SqzError::SessionStore)?);
1043 }
1044 Ok(gains)
1045 }
1046
1047 pub fn compression_stats_for_project(&self, project_dir: &str) -> Result<CompressionStats> {
1049 let mut stmt = self.db.prepare(
1050 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) \
1051 FROM compression_log WHERE project_dir = ?1",
1052 ).map_err(SqzError::SessionStore)?;
1053
1054 let stats = stmt.query_row(params![project_dir], |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 daily_gains_for_project(&self, days: u32, project_dir: &str) -> Result<Vec<DailyGain>> {
1067 let mut stmt = self.db.prepare(
1068 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1069 FROM compression_log \
1070 WHERE created_at >= date('now', ?1) AND project_dir = ?2 \
1071 GROUP BY d ORDER BY d",
1072 ).map_err(SqzError::SessionStore)?;
1073
1074 let offset = format!("-{days} days");
1075 let rows = stmt.query_map(params![offset, project_dir], |row| {
1076 let tokens_in: u64 = row.get(2)?;
1077 let tokens_out: u64 = row.get(3)?;
1078 Ok(DailyGain {
1079 date: row.get(0)?,
1080 compressions: row.get(1)?,
1081 tokens_in,
1082 tokens_saved: tokens_in.saturating_sub(tokens_out),
1083 })
1084 }).map_err(SqzError::SessionStore)?;
1085
1086 let mut gains = Vec::new();
1087 for row in rows {
1088 gains.push(row.map_err(SqzError::SessionStore)?);
1089 }
1090 Ok(gains)
1091 }
1092
1093 pub fn list_projects(&self) -> Result<Vec<(String, u32, u64)>> {
1095 let mut stmt = self.db.prepare(
1096 "SELECT project_dir, COUNT(*), COALESCE(SUM(tokens_original) - SUM(tokens_compressed), 0) \
1097 FROM compression_log \
1098 WHERE project_dir IS NOT NULL \
1099 GROUP BY project_dir \
1100 ORDER BY COUNT(*) DESC",
1101 ).map_err(SqzError::SessionStore)?;
1102
1103 let rows = stmt.query_map([], |row| {
1104 Ok((
1105 row.get::<_, String>(0)?,
1106 row.get::<_, u32>(1)?,
1107 row.get::<_, u64>(2)?,
1108 ))
1109 }).map_err(SqzError::SessionStore)?;
1110
1111 let mut projects = Vec::new();
1112 for row in rows {
1113 projects.push(row.map_err(SqzError::SessionStore)?);
1114 }
1115 Ok(projects)
1116 }
1117
1118 pub fn command_breakdown(&self, limit: u32) -> Result<Vec<CommandStats>> {
1122 let mut stmt = self.db.prepare(
1123 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1124 FROM compression_log \
1125 GROUP BY mode \
1126 ORDER BY SUM(tokens_original) DESC \
1127 LIMIT ?1",
1128 ).map_err(SqzError::SessionStore)?;
1129
1130 let rows = stmt.query_map(params![limit], |row| {
1131 let tokens_in: u64 = row.get(2)?;
1132 let tokens_out: u64 = row.get(3)?;
1133 Ok(CommandStats {
1134 command: row.get(0)?,
1135 invocations: row.get(1)?,
1136 tokens_in,
1137 tokens_out,
1138 tokens_saved: tokens_in.saturating_sub(tokens_out),
1139 })
1140 }).map_err(SqzError::SessionStore)?;
1141
1142 let mut results = Vec::new();
1143 for row in rows {
1144 results.push(row.map_err(SqzError::SessionStore)?);
1145 }
1146 Ok(results)
1147 }
1148
1149 pub fn command_breakdown_for_project(&self, limit: u32, project_dir: &str) -> Result<Vec<CommandStats>> {
1151 let mut stmt = self.db.prepare(
1152 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
1153 FROM compression_log \
1154 WHERE project_dir = ?1 \
1155 GROUP BY mode \
1156 ORDER BY SUM(tokens_original) DESC \
1157 LIMIT ?2",
1158 ).map_err(SqzError::SessionStore)?;
1159
1160 let rows = stmt.query_map(params![project_dir, limit], |row| {
1161 let tokens_in: u64 = row.get(2)?;
1162 let tokens_out: u64 = row.get(3)?;
1163 Ok(CommandStats {
1164 command: row.get(0)?,
1165 invocations: row.get(1)?,
1166 tokens_in,
1167 tokens_out,
1168 tokens_saved: tokens_in.saturating_sub(tokens_out),
1169 })
1170 }).map_err(SqzError::SessionStore)?;
1171
1172 let mut results = Vec::new();
1173 for row in rows {
1174 results.push(row.map_err(SqzError::SessionStore)?);
1175 }
1176 Ok(results)
1177 }
1178
1179 pub fn session_pressure(&self, since_minutes: u32) -> Result<u64> {
1183 let mut stmt = self.db.prepare(
1184 "SELECT COALESCE(SUM(tokens_compressed), 0) \
1185 FROM compression_log \
1186 WHERE created_at >= datetime('now', ?1)",
1187 ).map_err(SqzError::SessionStore)?;
1188
1189 let offset = format!("-{since_minutes} minutes");
1190 let total: u64 = stmt.query_row(params![offset], |row| {
1191 row.get(0)
1192 }).map_err(SqzError::SessionStore)?;
1193
1194 Ok(total)
1195 }
1196
1197 pub fn add_known_file(&self, path: &str) -> Result<()> {
1202 let now = Utc::now().to_rfc3339();
1203 self.db.execute(
1204 "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
1205 params![path, now],
1206 ).map_err(SqzError::SessionStore)?;
1207 Ok(())
1208 }
1209
1210 pub fn known_files(&self) -> Result<Vec<String>> {
1212 let mut stmt = self.db.prepare(
1213 "SELECT path FROM known_files ORDER BY added_at DESC",
1214 ).map_err(SqzError::SessionStore)?;
1215
1216 let rows = stmt.query_map([], |row| {
1217 row.get::<_, String>(0)
1218 }).map_err(SqzError::SessionStore)?;
1219
1220 let mut files = Vec::new();
1221 for row in rows {
1222 files.push(row.map_err(SqzError::SessionStore)?);
1223 }
1224 Ok(files)
1225 }
1226 pub fn clear_known_files(&self) -> Result<()> {
1228 self.db
1229 .execute("DELETE FROM known_files", [])
1230 .map_err(SqzError::SessionStore)?;
1231 Ok(())
1232 }
1233
1234 pub fn cache_entry_count(&self) -> Result<u64> {
1236 self.db.query_row(
1237 "SELECT COUNT(*) FROM cache_entries", [],
1238 |row| row.get(0),
1239 ).map_err(SqzError::SessionStore)
1240 }
1241
1242 pub fn record_cache_hit(&self) -> Result<()> {
1244 let count: u64 = self.db.query_row(
1245 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1246 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1247 ).unwrap_or(0);
1248 let new = count + 1;
1249 self.db.execute(
1250 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_hits', ?1)",
1251 params![new.to_string()],
1252 )?;
1253 Ok(())
1254 }
1255
1256 pub fn record_cache_miss(&self) -> Result<()> {
1258 let count: u64 = self.db.query_row(
1259 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1260 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1261 ).unwrap_or(0);
1262 let new = count + 1;
1263 self.db.execute(
1264 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_misses', ?1)",
1265 params![new.to_string()],
1266 )?;
1267 Ok(())
1268 }
1269
1270 pub fn cache_hit_miss_counts(&self) -> Result<(u64, u64)> {
1272 let hits: u64 = self.db.query_row(
1273 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1274 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1275 ).unwrap_or(0);
1276 let misses: u64 = self.db.query_row(
1277 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1278 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1279 ).unwrap_or(0);
1280 Ok((hits, misses))
1281 }
1282
1283 pub fn clear_cache(&self) -> Result<u64> {
1287 let count: u64 = self.db.query_row(
1288 "SELECT COUNT(*) FROM cache_entries", [],
1289 |row| row.get(0),
1290 ).unwrap_or(0);
1291 self.db.execute("DELETE FROM cache_entries", [])
1292 .map_err(SqzError::SessionStore)?;
1293 let _ = self.db.execute(
1294 "DELETE FROM recall_index WHERE kind = 'output'", [],
1295 );
1296 let _ = self.db.execute(
1299 "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1300 );
1301 Ok(count)
1302 }
1303
1304 pub fn clear_stats(&self) -> Result<u64> {
1308 let count: u64 = self.db.query_row(
1309 "SELECT COUNT(*) FROM compression_log", [],
1310 |row| row.get(0),
1311 ).unwrap_or(0);
1312 self.db.execute("DELETE FROM compression_log", [])
1313 .map_err(SqzError::SessionStore)?;
1314 Ok(count)
1315 }
1316
1317 pub fn clear_stats_for_project(&self, project_dir: &str) -> Result<u64> {
1319 let count: u64 = self.db.query_row(
1320 "SELECT COUNT(*) FROM compression_log WHERE project_dir = ?1",
1321 rusqlite::params![project_dir],
1322 |row| row.get(0),
1323 ).unwrap_or(0);
1324 self.db.execute(
1325 "DELETE FROM compression_log WHERE project_dir = ?1",
1326 rusqlite::params![project_dir],
1327 ).map_err(SqzError::SessionStore)?;
1328 Ok(count)
1329 }
1330
1331 pub fn reset_all(&self) -> Result<()> {
1334 self.db.execute("DELETE FROM cache_entries", []).map_err(SqzError::SessionStore)?;
1335 self.db.execute("DELETE FROM compression_log", []).map_err(SqzError::SessionStore)?;
1336 self.db.execute("DELETE FROM sessions", []).map_err(SqzError::SessionStore)?;
1337 self.db.execute("DELETE FROM known_files", []).map_err(SqzError::SessionStore)?;
1338 self.db.execute("DELETE FROM metadata", []).map_err(SqzError::SessionStore)?;
1339 let _ = self.db.execute("DELETE FROM recall_index", []);
1340 let _ = self.db.execute("VACUUM", []);
1342 Ok(())
1343 }
1344
1345 pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionSummary>> {
1347 let mut stmt = self
1348 .db
1349 .prepare(
1350 "SELECT id, project_dir, compressed_summary, created_at, updated_at \
1351 FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1352 )
1353 .map_err(SqzError::SessionStore)?;
1354
1355 let rows = stmt
1356 .query_map(params![limit], |row| {
1357 Ok((
1358 row.get::<_, String>(0)?,
1359 row.get::<_, String>(1)?,
1360 row.get::<_, String>(2)?,
1361 row.get::<_, String>(3)?,
1362 row.get::<_, String>(4)?,
1363 ))
1364 })
1365 .map_err(SqzError::SessionStore)?;
1366
1367 let mut results = Vec::new();
1368 for row in rows {
1369 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
1370 results.push(row_to_summary(
1371 id,
1372 project_dir,
1373 compressed_summary,
1374 created_at,
1375 updated_at,
1376 )?);
1377 }
1378 Ok(results)
1379 }
1380
1381 pub fn per_tool_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1383 let mut stmt = self
1384 .db
1385 .prepare(
1386 "SELECT stages_applied, SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1387 FROM compression_log GROUP BY stages_applied ORDER BY COUNT(*) DESC LIMIT 20",
1388 )
1389 .map_err(SqzError::SessionStore)?;
1390
1391 let rows = stmt
1392 .query_map([], |row| {
1393 Ok((
1394 row.get::<_, String>(0)?,
1395 row.get::<_, u64>(1)?,
1396 row.get::<_, u64>(2)?,
1397 row.get::<_, u32>(3)?,
1398 ))
1399 })
1400 .map_err(SqzError::SessionStore)?;
1401
1402 let mut tool_map: std::collections::HashMap<String, (u64, u64, u32)> =
1403 std::collections::HashMap::new();
1404 for row in rows {
1405 let (stages, tokens_in, tokens_out, count) = row?;
1406 for stage in stages.split(',') {
1407 let stage = stage.trim();
1408 if stage.is_empty() {
1409 continue;
1410 }
1411 let entry = tool_map.entry(stage.to_string()).or_default();
1412 entry.0 += tokens_in;
1413 entry.1 += tokens_out;
1414 entry.2 += count;
1415 }
1416 }
1417
1418 let mut result: Vec<_> = tool_map.into_iter().collect();
1419 result.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
1420 Ok(result
1421 .into_iter()
1422 .map(|(name, (tin, tout, cnt))| (name, tin, tout, cnt))
1423 .collect())
1424 }
1425
1426 pub fn per_command_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1428 let mut stmt = self
1429 .db
1430 .prepare(
1431 "SELECT COALESCE(mode, 'auto'), SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1432 FROM compression_log GROUP BY mode ORDER BY COUNT(*) DESC LIMIT 20",
1433 )
1434 .map_err(SqzError::SessionStore)?;
1435
1436 let rows = stmt
1437 .query_map([], |row| {
1438 Ok((
1439 row.get::<_, String>(0)?,
1440 row.get::<_, u64>(1)?,
1441 row.get::<_, u64>(2)?,
1442 row.get::<_, u32>(3)?,
1443 ))
1444 })
1445 .map_err(SqzError::SessionStore)?;
1446
1447 let mut result = Vec::new();
1448 for row in rows {
1449 result.push(row?);
1450 }
1451 Ok(result)
1452 }
1453}
1454
1455#[derive(Debug, Clone, Default)]
1457pub struct CompressionStats {
1458 pub total_compressions: u32,
1459 pub total_tokens_in: u64,
1460 pub total_tokens_out: u64,
1461}
1462
1463impl CompressionStats {
1464 pub fn tokens_saved(&self) -> u64 {
1465 self.total_tokens_in.saturating_sub(self.total_tokens_out)
1466 }
1467
1468 pub fn reduction_pct(&self) -> f64 {
1469 if self.total_tokens_in == 0 {
1470 0.0
1471 } else {
1472 (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
1473 }
1474 }
1475}
1476
1477#[derive(Debug, Clone)]
1479pub struct DailyGain {
1480 pub date: String,
1481 pub compressions: u32,
1482 pub tokens_saved: u64,
1483 pub tokens_in: u64,
1484}
1485
1486#[derive(Debug, Clone)]
1488pub struct CommandStats {
1489 pub command: String,
1490 pub invocations: u32,
1491 pub tokens_in: u64,
1492 pub tokens_out: u64,
1493 pub tokens_saved: u64,
1494}
1495
1496#[derive(Debug, Clone)]
1498pub struct RecallHit {
1499 pub ref_hash: String,
1502 pub kind: String,
1503 pub snippet: String,
1505 pub created_at: String,
1506}
1507
1508fn fts_query(raw: &str) -> String {
1512 raw.split_whitespace()
1513 .filter_map(|term| {
1514 let cleaned = term.replace('"', "");
1515 if cleaned.is_empty() {
1516 None
1517 } else {
1518 Some(format!("\"{cleaned}\""))
1519 }
1520 })
1521 .collect::<Vec<_>>()
1522 .join(" ")
1523}
1524
1525#[derive(Debug, Clone, Default)]
1527pub struct RegretStats {
1528 pub reruns: u32,
1530 pub expands: u32,
1532 pub avg_rerun_gap_secs: f64,
1534}
1535
1536impl CommandStats {
1537 pub fn reduction_pct(&self) -> f64 {
1538 if self.tokens_in == 0 {
1539 0.0
1540 } else {
1541 (1.0 - self.tokens_out as f64 / self.tokens_in as f64) * 100.0
1542 }
1543 }
1544}
1545
1546#[cfg(test)]
1549mod tests {
1550 use super::*;
1551 use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
1552 use chrono::Utc;
1553 use proptest::prelude::*;
1554 use std::path::PathBuf;
1555
1556 fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
1557 let now = Utc::now();
1558 SessionState {
1559 id: id.to_string(),
1560 project_dir: PathBuf::from(project_dir),
1561 conversation: vec![],
1562 corrections: CorrectionLog::default(),
1563 pins: vec![],
1564 learnings: vec![],
1565 compressed_summary: summary.to_string(),
1566 budget: BudgetState {
1567 window_size: 200_000,
1568 consumed: 0,
1569 pinned: 0,
1570 model_family: ModelFamily::AnthropicClaude,
1571 },
1572 tool_usage: vec![],
1573 created_at: now,
1574 updated_at: now,
1575 }
1576 }
1577
1578 fn in_memory_store() -> SessionStore {
1579 let conn = Connection::open_in_memory().unwrap();
1580 apply_schema(&conn).unwrap();
1581 SessionStore { db: conn }
1582 }
1583
1584 fn test_content(data: &str) -> CompressedContent {
1585 CompressedContent {
1586 data: data.to_string(),
1587 tokens_compressed: 1,
1588 tokens_original: 2,
1589 stages_applied: vec![],
1590 compression_ratio: 0.5,
1591 provenance: crate::types::Provenance::default(),
1592 verify: None,
1593 }
1594 }
1595
1596 #[test]
1597 fn recall_indexes_cache_originals_and_searches() {
1598 let store = in_memory_store();
1599 let content = test_content("compressed");
1600 store
1601 .save_cache_entry_with_original(
1602 "hash-auth",
1603 &content,
1604 Some(b"error: auth middleware rejected the session token"),
1605 )
1606 .unwrap();
1607 store
1608 .save_cache_entry_with_original(
1609 "hash-build",
1610 &content,
1611 Some(b"cargo build finished in 32s with zero warnings"),
1612 )
1613 .unwrap();
1614
1615 let hits = store.recall_search("auth middleware", 5).unwrap();
1616 assert_eq!(hits.len(), 1);
1617 assert_eq!(hits[0].ref_hash, "hash-auth");
1618 assert_eq!(hits[0].kind, "output");
1619 assert!(hits[0].snippet.contains(">>auth<<"), "snippet: {}", hits[0].snippet);
1620
1621 store
1623 .save_cache_entry_with_original(
1624 "hash-auth",
1625 &content,
1626 Some(b"error: auth middleware rejected the session token"),
1627 )
1628 .unwrap();
1629 assert_eq!(store.recall_search("middleware", 5).unwrap().len(), 1);
1630 }
1631
1632 #[test]
1633 fn recall_skips_binary_and_survives_odd_queries() {
1634 let store = in_memory_store();
1635 let content = test_content("c");
1636 store
1637 .save_cache_entry_with_original("hash-bin", &content, Some(b"binary\x00payload"))
1638 .unwrap();
1639 assert!(store.recall_search("binary", 5).unwrap().is_empty());
1640
1641 for q in ["NOT AND OR", "\"unbalanced", "col:umn", "wild*", "(paren", "-", ""] {
1643 let _ = store.recall_search(q, 5).unwrap();
1644 }
1645 }
1646
1647 #[test]
1648 fn recall_deletion_and_clear_coherence() {
1649 let store = in_memory_store();
1650 let content = test_content("c");
1651 store
1652 .save_cache_entry_with_original("hash-x", &content, Some(b"kubectl rollout restarted deployment"))
1653 .unwrap();
1654 assert_eq!(store.recall_search("rollout", 5).unwrap().len(), 1);
1655
1656 store.delete_cache_entry("hash-x").unwrap();
1657 assert!(store.recall_search("rollout", 5).unwrap().is_empty());
1658
1659 store
1661 .save_cache_entry_with_original("hash-y", &content, Some(b"terraform plan shows three changes"))
1662 .unwrap();
1663 let session = make_session("sess-recall", "/tmp/p", "refactored the billing retry loop");
1664 store.save_session(&session).unwrap();
1665 store.clear_cache().unwrap();
1666 assert!(store.recall_search("terraform", 5).unwrap().is_empty());
1667 let session_hits = store.recall_search("billing retry", 5).unwrap();
1668 assert_eq!(session_hits.len(), 1);
1669 assert_eq!(session_hits[0].kind, "session");
1670 assert_eq!(session_hits[0].ref_hash, "sess-recall");
1671
1672 store.reset_all().unwrap();
1673 assert!(store.recall_search("billing", 5).unwrap().is_empty());
1674 }
1675
1676 #[test]
1677 fn recall_ranks_better_matches_first() {
1678 let store = in_memory_store();
1679 let content = test_content("c");
1680 store
1681 .save_cache_entry_with_original(
1682 "hash-dense",
1683 &content,
1684 Some(b"panic unwrap panic unwrap panic in worker thread"),
1685 )
1686 .unwrap();
1687 store
1688 .save_cache_entry_with_original(
1689 "hash-sparse",
1690 &content,
1691 Some(format!("{} panic once at the end", "filler line of ordinary words\n".repeat(50)).as_bytes()),
1692 )
1693 .unwrap();
1694 let hits = store.recall_search("panic", 5).unwrap();
1695 assert_eq!(hits.len(), 2);
1696 assert_eq!(hits[0].ref_hash, "hash-dense", "bm25 should rank the denser match first");
1697 }
1698
1699 #[test]
1700 fn formatter_gaps_ranks_low_reduction_high_volume() {
1701 let store = in_memory_store();
1702 for _ in 0..4 {
1704 store.log_compression(1_000, 950, &[], "xcodebuild").unwrap();
1705 }
1706 for _ in 0..4 {
1708 store.log_compression(1_000, 100, &[], "cargo test").unwrap();
1709 }
1710 store.log_compression(100, 95, &[], "tiny-cmd").unwrap();
1712 store.log_compression(5_000, 13, &["dedup".to_string()], "dedup").unwrap();
1714
1715 let gaps = store.formatter_gaps(7, 10).unwrap();
1716 assert_eq!(gaps.len(), 1, "{gaps:?}");
1717 assert_eq!(gaps[0].command, "xcodebuild");
1718 assert_eq!(gaps[0].invocations, 4);
1719 assert!(gaps[0].reduction_pct() < 25.0);
1720 }
1721
1722 #[test]
1723 fn regret_log_roundtrip() {
1724 let store = in_memory_store();
1725 let empty = store.regret_stats().unwrap();
1727 assert_eq!(empty.reruns, 0);
1728 assert_eq!(empty.expands, 0);
1729 assert_eq!(empty.avg_rerun_gap_secs, 0.0);
1730
1731 store.log_regret("rerun", "git log", "aaaa", 10.0).unwrap();
1732 store.log_regret("rerun", "git log", "bbbb", 30.0).unwrap();
1733 store.log_regret("rerun", "cargo test", "cccc", 20.0).unwrap();
1734 store.log_regret("expand", "", "dddd", 0.0).unwrap();
1735
1736 let stats = store.regret_stats().unwrap();
1737 assert_eq!(stats.reruns, 3);
1738 assert_eq!(stats.expands, 1);
1739 assert!((stats.avg_rerun_gap_secs - 20.0).abs() < 1e-9);
1740
1741 let top = store.regret_by_command(10).unwrap();
1742 assert_eq!(top[0], ("git log".to_string(), 2));
1743 assert_eq!(top[1], ("cargo test".to_string(), 1));
1744 assert_eq!(top.len(), 2);
1746 }
1747
1748 #[test]
1749 fn test_save_and_load_session() {
1750 let store = in_memory_store();
1751 let session = make_session("sess-1", "/home/user/project", "REST API refactor");
1752
1753 let id = store.save_session(&session).unwrap();
1754 assert_eq!(id, "sess-1");
1755
1756 let loaded = store.load_session("sess-1".to_string()).unwrap();
1757 assert_eq!(loaded.id, session.id);
1758 assert_eq!(loaded.compressed_summary, session.compressed_summary);
1759 assert_eq!(loaded.project_dir, session.project_dir);
1760 }
1761
1762 #[test]
1763 fn test_save_session_upsert() {
1764 let store = in_memory_store();
1765 let mut session = make_session("sess-2", "/proj", "initial summary");
1766 store.save_session(&session).unwrap();
1767
1768 session.compressed_summary = "updated summary".to_string();
1769 store.save_session(&session).unwrap();
1770
1771 let loaded = store.load_session("sess-2".to_string()).unwrap();
1772 assert_eq!(loaded.compressed_summary, "updated summary");
1773 }
1774
1775 #[test]
1776 fn test_load_nonexistent_session_errors() {
1777 let store = in_memory_store();
1778 let result = store.load_session("does-not-exist".to_string());
1779 assert!(result.is_err());
1780 }
1781
1782 #[test]
1783 fn test_search_fts() {
1784 let store = in_memory_store();
1785 store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
1786 store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
1787
1788 let results = store.search("authentication").unwrap();
1789 assert_eq!(results.len(), 1);
1790 assert_eq!(results[0].id, "s1");
1791 }
1792
1793 #[test]
1794 fn test_search_by_date() {
1795 let store = in_memory_store();
1796 let now = Utc::now();
1797 let past = now - chrono::Duration::hours(2);
1798 let future = now + chrono::Duration::hours(2);
1799
1800 store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
1801
1802 let results = store.search_by_date(past, future).unwrap();
1803 assert!(!results.is_empty());
1804 assert!(results.iter().any(|r| r.id == "s1"));
1805 }
1806
1807 #[test]
1808 fn test_search_by_project() {
1809 let store = in_memory_store();
1810 store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
1811 store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
1812
1813 let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
1814 assert_eq!(results.len(), 1);
1815 assert_eq!(results[0].id, "s1");
1816 }
1817
1818 #[test]
1819 fn test_cache_entry_round_trip() {
1820 let store = in_memory_store();
1821 let entry = CompressedContent {
1822 data: "compressed data".to_string(),
1823 tokens_compressed: 10,
1824 tokens_original: 50,
1825 stages_applied: vec!["strip_nulls".to_string()],
1826 compression_ratio: 0.2,
1827 provenance: crate::types::Provenance::default(),
1828 verify: None,
1829 };
1830
1831 store.save_cache_entry("abc123", &entry).unwrap();
1832
1833 let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
1834 assert_eq!(loaded.data, entry.data);
1835 assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
1836 assert_eq!(loaded.tokens_original, entry.tokens_original);
1837 }
1838
1839 #[test]
1840 fn test_get_cache_entry_missing_returns_none() {
1841 let store = in_memory_store();
1842 let result = store.get_cache_entry("nonexistent").unwrap();
1843 assert!(result.is_none());
1844 }
1845
1846 #[test]
1847 fn test_open_or_create_corrupted_db() {
1848 let dir = tempfile::tempdir().unwrap();
1849 let path = dir.path().join("store.db");
1850
1851 std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
1853
1854 let store = SessionStore::open_or_create(&path).unwrap();
1856 let session = make_session("s1", "/proj", "after corruption");
1857 store.save_session(&session).unwrap();
1858 let loaded = store.load_session("s1".to_string()).unwrap();
1859 assert_eq!(loaded.id, "s1");
1860 }
1861
1862 fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
1866 let now = Utc::now();
1867 SessionState {
1868 id: id.to_string(),
1869 project_dir: PathBuf::from("/proj"),
1870 conversation: vec![],
1871 corrections: CorrectionLog::default(),
1872 pins: vec![],
1873 learnings: vec![],
1874 compressed_summary: summary.to_string(),
1875 budget: BudgetState {
1876 window_size: 200_000,
1877 consumed: 0,
1878 pinned: 0,
1879 model_family: ModelFamily::AnthropicClaude,
1880 },
1881 tool_usage: vec![],
1882 created_at: now,
1883 updated_at,
1884 }
1885 }
1886
1887 proptest! {
1895 #[test]
1901 fn prop_search_correctness(
1902 keyword in "[b-df-hj-np-tv-z]{5,8}",
1905 matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
1907 non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
1909 ) {
1910 for s in &non_matching {
1912 prop_assume!(!s.contains(keyword.as_str()));
1913 }
1914
1915 let store = in_memory_store();
1916
1917 let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1919 for (i, suffix) in matching_suffixes.iter().enumerate() {
1920 let id = format!("match-{i}");
1921 let summary = format!("{} {} end", suffix, keyword);
1922 store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
1923 matching_ids.insert(id);
1924 }
1925
1926 let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1928 for (i, summary) in non_matching.iter().enumerate() {
1929 let id = format!("nomatch-{i}");
1930 store.save_session(&make_session(&id, "/proj", summary)).unwrap();
1931 non_matching_ids.insert(id);
1932 }
1933
1934 let results = store.search(&keyword).unwrap();
1935 let result_ids: std::collections::HashSet<String> =
1936 results.iter().map(|r| r.id.clone()).collect();
1937
1938 for id in &matching_ids {
1940 prop_assert!(
1941 result_ids.contains(id),
1942 "matching session '{}' not found in search results for keyword '{}'",
1943 id, keyword
1944 );
1945 }
1946
1947 for id in &non_matching_ids {
1949 prop_assert!(
1950 !result_ids.contains(id),
1951 "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
1952 id, keyword
1953 );
1954 }
1955 }
1956 }
1957
1958 proptest! {
1966 #[test]
1972 fn prop_search_by_date_correctness(
1973 offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
1975 window_start_delta in 0i64..=3600i64,
1977 window_end_delta in 3600i64..=7200i64,
1978 ) {
1979 use chrono::TimeZone;
1980
1981 let mut unique_offsets: Vec<i64> = offsets.clone();
1983 unique_offsets.sort_unstable();
1984 unique_offsets.dedup();
1985 prop_assume!(unique_offsets.len() >= 2);
1986
1987 let base_offset = unique_offsets[0];
1988 let from_offset = base_offset + window_start_delta;
1989 let to_offset = base_offset + window_end_delta;
1990
1991 let from = Utc.timestamp_opt(from_offset, 0).unwrap();
1992 let to = Utc.timestamp_opt(to_offset, 0).unwrap();
1993
1994 let store = in_memory_store();
1995
1996 let mut in_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1997 let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1998
1999 for (i, &offset) in unique_offsets.iter().enumerate() {
2000 let ts = Utc.timestamp_opt(offset, 0).unwrap();
2001 let id = format!("sess-{i}");
2002 let session = make_session_at(&id, "some summary", ts);
2003 store.save_session(&session).unwrap();
2004
2005 if ts >= from && ts <= to {
2006 in_range_ids.insert(id);
2007 } else {
2008 out_range_ids.insert(id);
2009 }
2010 }
2011
2012 let results = store.search_by_date(from, to).unwrap();
2013 let result_ids: std::collections::HashSet<String> =
2014 results.iter().map(|r| r.id.clone()).collect();
2015
2016 for id in &in_range_ids {
2018 prop_assert!(
2019 result_ids.contains(id),
2020 "in-range session '{}' missing from search_by_date results",
2021 id
2022 );
2023 }
2024
2025 for id in &out_range_ids {
2027 prop_assert!(
2028 !result_ids.contains(id),
2029 "out-of-range session '{}' incorrectly appeared in search_by_date results",
2030 id
2031 );
2032 }
2033 }
2034 }
2035}