Skip to main content

a_agent/session/
store.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use rusqlite::{Connection, OptionalExtension, params};
7use uuid::Uuid;
8
9use crate::model::{ContentBlock, ConversationItem, Role, Usage};
10use crate::pricing::Schedule;
11use crate::tools::patch::{FileChange, FileSnapshot};
12
13const SCHEMA: &str = r#"
14CREATE TABLE IF NOT EXISTS sessions (
15    id              TEXT PRIMARY KEY,
16    cwd             TEXT NOT NULL,
17    client_session_key TEXT,
18    title           TEXT,
19    head_item_id    TEXT,
20    provider_type   TEXT NOT NULL,
21    model           TEXT NOT NULL,
22    model_profile   TEXT,
23    effort          TEXT,
24    created_at      INTEGER NOT NULL,
25    updated_at      INTEGER NOT NULL
26);
27CREATE INDEX IF NOT EXISTS idx_sessions_cwd_updated ON sessions(cwd, updated_at DESC);
28
29CREATE TABLE IF NOT EXISTS conversation_items (
30    id              TEXT PRIMARY KEY,
31    session_id      TEXT NOT NULL,
32    parent_id       TEXT,
33    role            TEXT NOT NULL,
34    kind            TEXT NOT NULL,
35    content_json    TEXT NOT NULL,
36    usage_json      TEXT,
37    created_at      INTEGER NOT NULL,
38    FOREIGN KEY(session_id) REFERENCES sessions(id)
39);
40CREATE INDEX IF NOT EXISTS idx_items_session ON conversation_items(session_id);
41CREATE INDEX IF NOT EXISTS idx_items_parent ON conversation_items(parent_id);
42
43CREATE TABLE IF NOT EXISTS provider_state (
44    session_id      TEXT NOT NULL,
45    key             TEXT NOT NULL,
46    value_json      TEXT NOT NULL,
47    PRIMARY KEY(session_id, key),
48    FOREIGN KEY(session_id) REFERENCES sessions(id)
49);
50
51CREATE TABLE IF NOT EXISTS shell_history (
52    id              INTEGER PRIMARY KEY AUTOINCREMENT,
53    cwd             TEXT NOT NULL,
54    client_session_key TEXT,
55    command         TEXT NOT NULL,
56    exit_code       INTEGER,
57    pipe_status     TEXT,
58    started_at      INTEGER NOT NULL,
59    duration_ms     INTEGER
60);
61CREATE INDEX IF NOT EXISTS idx_shell_cwd_time ON shell_history(cwd, started_at DESC);
62
63CREATE TABLE IF NOT EXISTS input_history (
64    id              INTEGER PRIMARY KEY AUTOINCREMENT,
65    text            TEXT NOT NULL,
66    created_at      INTEGER NOT NULL
67);
68
69CREATE TABLE IF NOT EXISTS file_snapshots (
70    id              INTEGER PRIMARY KEY AUTOINCREMENT,
71    session_id      TEXT NOT NULL,
72    turn_item_id    TEXT NOT NULL,
73    path            TEXT NOT NULL,
74    change          TEXT NOT NULL,
75    before          TEXT,
76    after_len       INTEGER NOT NULL,
77    after_hash      TEXT NOT NULL,
78    added           INTEGER NOT NULL,
79    removed         INTEGER NOT NULL,
80    restorable      INTEGER NOT NULL,
81    created_at      INTEGER NOT NULL,
82    FOREIGN KEY(session_id) REFERENCES sessions(id)
83);
84CREATE INDEX IF NOT EXISTS idx_snapshots_turn ON file_snapshots(session_id, turn_item_id);
85
86CREATE TABLE IF NOT EXISTS model_prices (
87    key             TEXT PRIMARY KEY,
88    source          TEXT NOT NULL,
89    schedule_json   TEXT NOT NULL,
90    fetched_at      INTEGER NOT NULL
91);
92"#;
93
94pub const TURN_INTERRUPTED_NOTICE: &str = "[The user interrupted the previous turn. Do not continue or retry its unfinished task unless the user explicitly asks you to.]";
95pub const CONVERSATION_SUMMARY_PREFIX: &str = "[Compacted conversation summary]";
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Session {
99    pub id: String,
100    pub cwd: String,
101    pub title: Option<String>,
102    pub head_item_id: Option<String>,
103    pub provider_type: String,
104    pub model: String,
105    pub model_profile: Option<String>,
106    pub effort: Option<String>,
107    pub created_at: i64,
108    pub updated_at: i64,
109}
110
111#[derive(Debug, Clone)]
112pub struct NewSession {
113    pub cwd: String,
114    pub provider_type: String,
115    pub model: String,
116    pub client_session_key: Option<String>,
117    pub model_profile: Option<String>,
118    pub effort: Option<String>,
119}
120
121impl NewSession {
122    pub fn new(
123        cwd: impl Into<String>,
124        provider_type: impl Into<String>,
125        model: impl Into<String>,
126    ) -> Self {
127        Self {
128            cwd: cwd.into(),
129            provider_type: provider_type.into(),
130            model: model.into(),
131            client_session_key: None,
132            model_profile: None,
133            effort: None,
134        }
135    }
136
137    pub fn with_client_session_key(mut self, key: impl Into<String>) -> Self {
138        self.client_session_key = Some(key.into());
139        self
140    }
141
142    pub fn with_model_selection(
143        mut self,
144        profile: impl Into<String>,
145        effort: Option<&str>,
146    ) -> Self {
147        self.model_profile = Some(profile.into());
148        self.effort = effort.map(str::to_owned);
149        self
150    }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct ShellHistoryItem {
155    pub id: i64,
156    pub cwd: String,
157    pub command: String,
158    pub exit_code: Option<i32>,
159    pub pipe_status: Option<String>,
160    pub started_at: i64,
161    pub duration_ms: Option<i64>,
162}
163
164pub struct SessionStore {
165    connection: Connection,
166}
167
168impl SessionStore {
169    pub fn open(path: &Path) -> Result<Self> {
170        if let Some(parent) = path.parent() {
171            fs::create_dir_all(parent)
172                .with_context(|| format!("create state directory {}", parent.display()))?;
173        }
174        let connection = Connection::open(path)
175            .with_context(|| format!("open session database {}", path.display()))?;
176        Self::initialize(connection)
177    }
178
179    pub fn open_in_memory() -> Result<Self> {
180        Self::initialize(Connection::open_in_memory()?)
181    }
182
183    fn initialize(connection: Connection) -> Result<Self> {
184        connection.busy_timeout(std::time::Duration::from_millis(1000))?;
185        connection.pragma_update(None, "foreign_keys", "ON")?;
186        connection.pragma_update(None, "synchronous", "NORMAL")?;
187        connection.pragma_update(None, "journal_mode", "WAL")?;
188        connection.execute_batch(SCHEMA)?;
189        migrate_schema(&connection)?;
190        Ok(Self { connection })
191    }
192
193    pub fn create_session(&mut self, new: NewSession) -> Result<Session> {
194        let id = format!("a_{}", Uuid::new_v4().simple());
195        let now = now_millis();
196        self.connection.execute(
197            "INSERT INTO sessions (id, cwd, client_session_key, provider_type, model, model_profile, effort, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
198            params![id, new.cwd, new.client_session_key, new.provider_type, new.model, new.model_profile, new.effort, now],
199        )?;
200        self.get_session(&id)?.context("new session disappeared")
201    }
202
203    pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
204        self.connection
205            .query_row(
206                "SELECT id, cwd, title, head_item_id, provider_type, model, model_profile, effort, created_at, updated_at FROM sessions WHERE id = ?1",
207                [id],
208                row_to_session,
209            )
210            .optional()
211            .map_err(Into::into)
212    }
213
214    pub fn find_latest_session(&self, cwd: &str) -> Result<Option<Session>> {
215        self.connection
216            .query_row(
217                "SELECT id, cwd, title, head_item_id, provider_type, model, model_profile, effort, created_at, updated_at FROM sessions WHERE cwd = ?1 ORDER BY updated_at DESC, rowid DESC LIMIT 1",
218                [cwd],
219                row_to_session,
220            )
221            .optional()
222            .map_err(Into::into)
223    }
224
225    pub fn find_client_session(&self, cwd: &str, key: &str) -> Result<Option<Session>> {
226        self.connection
227            .query_row(
228                "SELECT id, cwd, title, head_item_id, provider_type, model, model_profile, effort, created_at, updated_at FROM sessions WHERE cwd = ?1 AND client_session_key = ?2 LIMIT 1",
229                params![cwd, key],
230                row_to_session,
231            )
232            .optional()
233            .map_err(Into::into)
234    }
235
236    pub fn recent_sessions(&self, cwd: &str, limit: usize) -> Result<Vec<Session>> {
237        let mut statement = self.connection.prepare(
238            "SELECT id, cwd, title, head_item_id, provider_type, model, model_profile, effort, created_at, updated_at FROM sessions WHERE cwd = ?1 AND EXISTS (SELECT 1 FROM conversation_items WHERE conversation_items.session_id = sessions.id AND conversation_items.kind = 'user_message') ORDER BY updated_at DESC, rowid DESC LIMIT ?2",
239        )?;
240        let rows = statement.query_map(params![cwd, limit], row_to_session)?;
241        rows.collect::<rusqlite::Result<Vec<_>>>()
242            .map_err(Into::into)
243    }
244
245    pub fn first_user_prompt(&self, session_id: &str) -> Result<Option<String>> {
246        let content = self
247            .connection
248            .query_row(
249                "SELECT content_json FROM conversation_items WHERE session_id = ?1 AND kind = 'user_message' ORDER BY created_at ASC, rowid ASC LIMIT 1",
250                [session_id],
251                |row| row.get::<_, String>(0),
252            )
253            .optional()?;
254        content
255            .map(|content| {
256                let blocks: Vec<ContentBlock> = serde_json::from_str(&content)?;
257                Ok(blocks.into_iter().find_map(|block| match block {
258                    ContentBlock::Text(text) => Some(text),
259                    _ => None,
260                }))
261            })
262            .transpose()
263            .map(Option::flatten)
264    }
265
266    pub fn rebind_client_session_key(
267        &mut self,
268        cwd: &str,
269        key: &str,
270        target_session_id: &str,
271    ) -> Result<()> {
272        let transaction = self.connection.transaction()?;
273        let (target_cwd, target_key): (String, Option<String>) = transaction
274            .query_row(
275                "SELECT cwd, client_session_key FROM sessions WHERE id = ?1",
276                [target_session_id],
277                |row| Ok((row.get(0)?, row.get(1)?)),
278            )
279            .optional()?
280            .with_context(|| format!("session not found: {target_session_id}"))?;
281        if target_cwd != cwd {
282            anyhow::bail!("cannot resume a session from a different cwd");
283        }
284        if target_key.as_deref().is_some_and(|target| target != key) {
285            anyhow::bail!("session is active in another Fish process");
286        }
287        transaction.execute(
288            "UPDATE sessions SET client_session_key = NULL WHERE cwd = ?1 AND client_session_key = ?2",
289            params![cwd, key],
290        )?;
291        transaction.execute(
292            "UPDATE sessions SET client_session_key = ?1, updated_at = ?2 WHERE id = ?3",
293            params![key, now_millis(), target_session_id],
294        )?;
295        transaction.commit()?;
296        Ok(())
297    }
298
299    pub fn append_item(
300        &mut self,
301        session_id: &str,
302        role: Role,
303        blocks: Vec<ContentBlock>,
304    ) -> Result<ConversationItem> {
305        let kind = item_kind(role, &blocks);
306        self.append_item_with_kind(session_id, role, blocks, kind, None)
307    }
308
309    pub fn append_assistant_item(
310        &mut self,
311        session_id: &str,
312        blocks: Vec<ContentBlock>,
313        usage: Option<Usage>,
314    ) -> Result<ConversationItem> {
315        let kind = item_kind(Role::Assistant, &blocks);
316        self.append_item_with_kind(session_id, Role::Assistant, blocks, kind, usage)
317    }
318
319    pub fn append_turn_interrupted(&mut self, session_id: &str) -> Result<ConversationItem> {
320        self.append_item_with_kind(
321            session_id,
322            Role::User,
323            vec![ContentBlock::Text(TURN_INTERRUPTED_NOTICE.into())],
324            "turn_interrupted",
325            None,
326        )
327    }
328
329    pub fn replace_branch_with_summary(
330        &mut self,
331        session_id: &str,
332        summary: &str,
333    ) -> Result<ConversationItem> {
334        self.get_session(session_id)?
335            .with_context(|| format!("session not found: {session_id}"))?;
336        let id = format!("i_{}", Uuid::new_v4().simple());
337        let created_at = now_millis();
338        let blocks = vec![ContentBlock::Text(format!(
339            "{CONVERSATION_SUMMARY_PREFIX}\n{summary}"
340        ))];
341        let content_json = serde_json::to_string(&blocks)?;
342        let transaction = self.connection.transaction()?;
343        transaction.execute(
344            "INSERT INTO conversation_items (id, session_id, parent_id, role, kind, content_json, usage_json, created_at) VALUES (?1, ?2, NULL, 'user', 'conversation_summary', ?3, NULL, ?4)",
345            params![id, session_id, content_json, created_at],
346        )?;
347        transaction.execute(
348            "UPDATE sessions SET head_item_id = ?1, updated_at = ?2 WHERE id = ?3",
349            params![id, created_at, session_id],
350        )?;
351        transaction.commit()?;
352        Ok(ConversationItem {
353            id,
354            session_id: session_id.into(),
355            parent_id: None,
356            role: Role::User,
357            blocks,
358            usage: None,
359            created_at,
360        })
361    }
362
363    fn append_item_with_kind(
364        &mut self,
365        session_id: &str,
366        role: Role,
367        blocks: Vec<ContentBlock>,
368        kind: &str,
369        usage: Option<Usage>,
370    ) -> Result<ConversationItem> {
371        let parent_id = self
372            .get_session(session_id)?
373            .with_context(|| format!("session not found: {session_id}"))?
374            .head_item_id;
375        let id = format!("i_{}", Uuid::new_v4().simple());
376        let created_at = now_millis();
377        let content_json = serde_json::to_string(&blocks)?;
378        let usage_json = usage
379            .map(|usage| serde_json::to_string(&usage))
380            .transpose()?;
381        let role_text = role_to_str(role);
382        let transaction = self.connection.transaction()?;
383        transaction.execute(
384            "INSERT INTO conversation_items (id, session_id, parent_id, role, kind, content_json, usage_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
385            params![id, session_id, parent_id, role_text, kind, content_json, usage_json, created_at],
386        )?;
387        transaction.execute(
388            "UPDATE sessions SET head_item_id = ?1, updated_at = ?2 WHERE id = ?3",
389            params![id, created_at, session_id],
390        )?;
391        transaction.commit()?;
392        Ok(ConversationItem {
393            id,
394            session_id: session_id.into(),
395            parent_id,
396            role,
397            blocks,
398            usage,
399            created_at,
400        })
401    }
402
403    pub fn get_item(&self, id: &str) -> Result<Option<ConversationItem>> {
404        self.connection
405            .query_row(
406                "SELECT id, session_id, parent_id, role, content_json, usage_json, created_at FROM conversation_items WHERE id = ?1",
407                [id],
408                row_to_item,
409            )
410            .optional()
411            .map_err(Into::into)
412    }
413
414    pub fn active_branch(&self, session_id: &str) -> Result<Vec<ConversationItem>> {
415        let mut current = self
416            .get_session(session_id)?
417            .with_context(|| format!("session not found: {session_id}"))?
418            .head_item_id;
419        let mut branch = Vec::new();
420        while let Some(id) = current {
421            let item = self
422                .get_item(&id)?
423                .with_context(|| format!("conversation item not found: {id}"))?;
424            current = item.parent_id.clone();
425            branch.push(item);
426        }
427        branch.reverse();
428        Ok(branch)
429    }
430
431    pub fn rewind(&mut self, session_id: &str, item_id: &str) -> Result<()> {
432        let item = self
433            .get_item(item_id)?
434            .with_context(|| format!("rewind item not found: {item_id}"))?;
435        if item.session_id != session_id {
436            anyhow::bail!("rewind item does not belong to session {session_id}");
437        }
438        self.connection.execute(
439            "UPDATE sessions SET head_item_id = ?1, updated_at = ?2 WHERE id = ?3",
440            params![item_id, now_millis(), session_id],
441        )?;
442        Ok(())
443    }
444
445    pub fn user_checkpoints(&self, session_id: &str) -> Result<Vec<ConversationItem>> {
446        let mut statement = self.connection.prepare(
447            "SELECT id, session_id, parent_id, role, content_json, usage_json, created_at FROM conversation_items WHERE session_id = ?1 AND kind = 'user_message' ORDER BY created_at ASC, rowid ASC",
448        )?;
449        let rows = statement.query_map([session_id], row_to_item)?;
450        rows.collect::<rusqlite::Result<Vec<_>>>()
451            .map_err(Into::into)
452    }
453
454    pub fn count_items(&self, session_id: &str) -> Result<usize> {
455        Ok(self.connection.query_row(
456            "SELECT COUNT(*) FROM conversation_items WHERE session_id = ?1",
457            [session_id],
458            |row| row.get(0),
459        )?)
460    }
461
462    pub fn update_model_selection(
463        &self,
464        session_id: &str,
465        provider_type: &str,
466        model: &str,
467        model_profile: &str,
468        effort: Option<&str>,
469    ) -> Result<()> {
470        self.connection.execute(
471            "UPDATE sessions SET provider_type = ?1, model = ?2, model_profile = ?3, effort = ?4, updated_at = ?5 WHERE id = ?6",
472            params![provider_type, model, model_profile, effort, now_millis(), session_id],
473        )?;
474        Ok(())
475    }
476
477    pub fn clear_session(&self, session_id: &str) -> Result<()> {
478        self.connection.execute(
479            "UPDATE sessions SET head_item_id = NULL, updated_at = ?1 WHERE id = ?2",
480            params![now_millis(), session_id],
481        )?;
482        Ok(())
483    }
484
485    pub fn record_input_history(&self, text: &str) -> Result<()> {
486        self.connection.execute(
487            "INSERT INTO input_history (text, created_at) VALUES (?1, ?2)",
488            params![text, now_millis()],
489        )?;
490        Ok(())
491    }
492
493    pub fn recent_input_history(&self, limit: usize) -> Result<Vec<String>> {
494        let mut statement = self
495            .connection
496            .prepare("SELECT text FROM input_history ORDER BY id DESC LIMIT ?1")?;
497        let rows = statement.query_map([limit], |row| row.get::<_, String>(0))?;
498        let mut entries = rows.collect::<rusqlite::Result<Vec<_>>>()?;
499        entries.reverse();
500        Ok(entries)
501    }
502
503    pub fn prune_input_history(&self, maximum: usize) -> Result<()> {
504        self.connection.execute(
505            "DELETE FROM input_history WHERE id NOT IN (SELECT id FROM input_history ORDER BY id DESC LIMIT ?1)",
506            [maximum],
507        )?;
508        Ok(())
509    }
510
511    pub fn record_file_snapshots(
512        &self,
513        session_id: &str,
514        turn_item_id: &str,
515        snapshots: &[FileSnapshot],
516    ) -> Result<()> {
517        let now = now_millis();
518        for snapshot in snapshots {
519            self.connection.execute(
520                "INSERT INTO file_snapshots (session_id, turn_item_id, path, change, before, after_len, after_hash, added, removed, restorable, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
521                params![
522                    session_id,
523                    turn_item_id,
524                    snapshot.path.to_string_lossy(),
525                    snapshot.change.as_str(),
526                    snapshot.before,
527                    snapshot.after_len as i64,
528                    snapshot.after_hash.to_string(),
529                    snapshot.added as i64,
530                    snapshot.removed as i64,
531                    i64::from(snapshot.restorable),
532                    now
533                ],
534            )?;
535        }
536        Ok(())
537    }
538
539    /// Snapshots taken from `item_id`'s turn onwards, newest first so a restore
540    /// replays them in reverse order.
541    ///
542    /// Rewinding to a checkpoint means continuing from that message again, so the
543    /// work done in response to it is discarded too, which is why its own turn is
544    /// included rather than only later ones.
545    pub fn snapshots_from_turn(
546        &self,
547        session_id: &str,
548        item_id: &str,
549    ) -> Result<Vec<FileSnapshot>> {
550        let mut statement = self.connection.prepare(
551            "SELECT path, change, before, after_len, after_hash, added, removed, restorable FROM file_snapshots
552             WHERE session_id = ?1 AND created_at >= (
553                 SELECT created_at FROM conversation_items WHERE id = ?2
554             )
555             ORDER BY id DESC",
556        )?;
557        let rows = statement.query_map(params![session_id, item_id], |row| {
558            let change: String = row.get(1)?;
559            let hash: String = row.get(4)?;
560            Ok(FileSnapshot {
561                path: PathBuf::from(row.get::<_, String>(0)?),
562                change: FileChange::parse(&change).unwrap_or(FileChange::Modified),
563                before: row.get(2)?,
564                after_len: row.get::<_, i64>(3)? as u64,
565                after_hash: hash.parse().unwrap_or_default(),
566                added: row.get::<_, i64>(5)? as usize,
567                removed: row.get::<_, i64>(6)? as usize,
568                restorable: row.get::<_, i64>(7)? != 0,
569            })
570        })?;
571        rows.collect::<rusqlite::Result<Vec<_>>>()
572            .map_err(Into::into)
573    }
574
575    pub fn delete_snapshots_from_turn(&self, session_id: &str, item_id: &str) -> Result<()> {
576        self.connection.execute(
577            "DELETE FROM file_snapshots WHERE session_id = ?1 AND created_at >= (
578                 SELECT created_at FROM conversation_items WHERE id = ?2
579             )",
580            params![session_id, item_id],
581        )?;
582        Ok(())
583    }
584
585    /// Usage of every request the session ever made, one entry per request,
586    /// including requests on branches a rewind left behind: all of them were
587    /// paid for. Kept per request because tiered prices depend on how large that
588    /// individual request was.
589    pub fn session_request_usage(&self, session_id: &str) -> Result<Vec<Usage>> {
590        let mut statement = self.connection.prepare(
591            "SELECT usage_json FROM conversation_items WHERE session_id = ?1 AND usage_json IS NOT NULL ORDER BY created_at ASC, rowid ASC",
592        )?;
593        let rows = statement.query_map([session_id], |row| row.get::<_, String>(0))?;
594        let mut requests = Vec::new();
595        for row in rows {
596            if let Ok(usage) = serde_json::from_str::<Usage>(&row?) {
597                requests.push(usage);
598            }
599        }
600        Ok(requests)
601    }
602
603    /// Cached model prices, ignored once older than `ttl` so a price change is
604    /// picked up without making every `/status` hit the network. The whole
605    /// schedule is stored, tiers included, so a cache hit prices large requests
606    /// the same way a fresh lookup would.
607    pub fn cached_prices(&self, key: &str, ttl: Duration) -> Result<Option<(Schedule, String)>> {
608        let cutoff = now_millis() - ttl.as_millis() as i64;
609        let mut statement = self.connection.prepare(
610            "SELECT source, schedule_json FROM model_prices WHERE key = ?1 AND fetched_at >= ?2",
611        )?;
612        let mut rows = statement.query(params![key, cutoff])?;
613        let Some(row) = rows.next()? else {
614            return Ok(None);
615        };
616        let source: String = row.get(0)?;
617        let schedule: String = row.get(1)?;
618        let Ok(schedule) = serde_json::from_str::<Schedule>(&schedule) else {
619            return Ok(None);
620        };
621        Ok(Some((schedule, source)))
622    }
623
624    pub fn cache_prices(&self, key: &str, source: &str, schedule: &Schedule) -> Result<()> {
625        self.connection.execute(
626            "INSERT INTO model_prices (key, source, schedule_json, fetched_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(key) DO UPDATE SET source = excluded.source, schedule_json = excluded.schedule_json, fetched_at = excluded.fetched_at",
627            params![
628                key,
629                source,
630                serde_json::to_string(schedule)?,
631                now_millis()
632            ],
633        )?;
634        Ok(())
635    }
636
637    pub fn set_provider_state(
638        &self,
639        session_id: &str,
640        key: &str,
641        value: &serde_json::Value,
642    ) -> Result<()> {
643        self.connection.execute(
644            "INSERT INTO provider_state (session_id, key, value_json) VALUES (?1, ?2, ?3) ON CONFLICT(session_id, key) DO UPDATE SET value_json = excluded.value_json",
645            params![session_id, key, serde_json::to_string(value)?],
646        )?;
647        Ok(())
648    }
649
650    pub fn provider_state(&self, session_id: &str, key: &str) -> Result<Option<serde_json::Value>> {
651        let value = self
652            .connection
653            .query_row(
654                "SELECT value_json FROM provider_state WHERE session_id = ?1 AND key = ?2",
655                params![session_id, key],
656                |row| row.get::<_, String>(0),
657            )
658            .optional()?;
659        value
660            .map(|value| serde_json::from_str(&value).map_err(Into::into))
661            .transpose()
662    }
663
664    #[allow(clippy::too_many_arguments)]
665    pub fn record_shell_history(
666        &self,
667        cwd: &str,
668        client_session_key: Option<&str>,
669        command: &str,
670        exit_code: Option<i32>,
671        started_at: i64,
672        duration_ms: Option<i64>,
673        pipe_status: Option<&str>,
674    ) -> Result<()> {
675        self.connection.execute(
676            "INSERT INTO shell_history (cwd, client_session_key, command, exit_code, pipe_status, started_at, duration_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
677            params![cwd, client_session_key, command, exit_code, pipe_status, started_at, duration_ms],
678        )?;
679        Ok(())
680    }
681
682    pub fn recent_shell_history(
683        &self,
684        cwd: &str,
685        client_session_key: Option<&str>,
686        limit: usize,
687    ) -> Result<Vec<ShellHistoryItem>> {
688        let query = if client_session_key.is_some() {
689            "SELECT id, cwd, command, exit_code, pipe_status, started_at, duration_ms FROM shell_history WHERE cwd = ?1 AND client_session_key = ?2 ORDER BY started_at DESC, id DESC LIMIT ?3"
690        } else {
691            "SELECT id, cwd, command, exit_code, pipe_status, started_at, duration_ms FROM shell_history WHERE cwd = ?1 ORDER BY started_at DESC, id DESC LIMIT ?3"
692        };
693        let mut statement = self.connection.prepare(query)?;
694        let rows = statement.query_map(params![cwd, client_session_key, limit], |row| {
695            Ok(ShellHistoryItem {
696                id: row.get(0)?,
697                cwd: row.get(1)?,
698                command: row.get(2)?,
699                exit_code: row.get(3)?,
700                pipe_status: row.get(4)?,
701                started_at: row.get(5)?,
702                duration_ms: row.get(6)?,
703            })
704        })?;
705        rows.collect::<rusqlite::Result<Vec<_>>>()
706            .map_err(Into::into)
707    }
708
709    pub fn prune_shell_history(&self, maximum: usize) -> Result<()> {
710        self.connection.execute(
711            "DELETE FROM shell_history WHERE id NOT IN (SELECT id FROM shell_history ORDER BY started_at DESC, id DESC LIMIT ?1)",
712            [maximum],
713        )?;
714        Ok(())
715    }
716
717    pub fn shell_history_count(&self) -> Result<usize> {
718        Ok(self
719            .connection
720            .query_row("SELECT COUNT(*) FROM shell_history", [], |row| row.get(0))?)
721    }
722}
723
724pub fn default_database_path(home: &Path) -> PathBuf {
725    std::env::var_os("XDG_STATE_HOME")
726        .map(PathBuf::from)
727        .unwrap_or_else(|| home.join(".local/state"))
728        .join("a/sessions.db")
729}
730
731fn migrate_schema(connection: &Connection) -> Result<()> {
732    ensure_column(connection, "sessions", "client_session_key", "TEXT")?;
733    ensure_column(connection, "sessions", "model_profile", "TEXT")?;
734    ensure_column(connection, "sessions", "effort", "TEXT")?;
735    ensure_column(connection, "shell_history", "client_session_key", "TEXT")?;
736    ensure_column(connection, "conversation_items", "usage_json", "TEXT")?;
737    // model_prices only caches what models.dev already knows, so a shape change
738    // is cheaper to rebuild than to migrate column by column.
739    rebuild_cache_table(connection, "model_prices", "schedule_json")?;
740    connection.execute_batch(
741        "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_client_key ON sessions(cwd, client_session_key) WHERE client_session_key IS NOT NULL;
742         CREATE INDEX IF NOT EXISTS idx_shell_client_time ON shell_history(cwd, client_session_key, started_at DESC);
743         PRAGMA user_version = 4;",
744    )?;
745    Ok(())
746}
747
748/// Drops a cache table that predates `expected_column` so the schema statement
749/// can recreate it in its current shape.
750fn rebuild_cache_table(connection: &Connection, table: &str, expected_column: &str) -> Result<()> {
751    let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
752    let columns = statement
753        .query_map([], |row| row.get::<_, String>(1))?
754        .collect::<rusqlite::Result<Vec<_>>>()?;
755    if columns.is_empty() || columns.iter().any(|column| column == expected_column) {
756        return Ok(());
757    }
758    drop(statement);
759    connection.execute_batch(&format!("DROP TABLE {table}"))?;
760    connection.execute_batch(SCHEMA)?;
761    Ok(())
762}
763
764fn ensure_column(connection: &Connection, table: &str, column: &str, kind: &str) -> Result<()> {
765    let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
766    let columns = statement
767        .query_map([], |row| row.get::<_, String>(1))?
768        .collect::<rusqlite::Result<Vec<_>>>()?;
769    if !columns.iter().any(|existing| existing == column) {
770        connection.execute(
771            &format!("ALTER TABLE {table} ADD COLUMN {column} {kind}"),
772            [],
773        )?;
774    }
775    Ok(())
776}
777
778fn row_to_session(row: &rusqlite::Row<'_>) -> rusqlite::Result<Session> {
779    Ok(Session {
780        id: row.get(0)?,
781        cwd: row.get(1)?,
782        title: row.get(2)?,
783        head_item_id: row.get(3)?,
784        provider_type: row.get(4)?,
785        model: row.get(5)?,
786        model_profile: row.get(6)?,
787        effort: row.get(7)?,
788        created_at: row.get(8)?,
789        updated_at: row.get(9)?,
790    })
791}
792
793fn row_to_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<ConversationItem> {
794    let role: String = row.get(3)?;
795    let content: String = row.get(4)?;
796    let usage: Option<String> = row.get(5)?;
797    Ok(ConversationItem {
798        id: row.get(0)?,
799        session_id: row.get(1)?,
800        parent_id: row.get(2)?,
801        role: str_to_role(&role).map_err(|error| {
802            rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, error.into())
803        })?,
804        blocks: serde_json::from_str(&content).map_err(|error| {
805            rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, error.into())
806        })?,
807        usage: usage
808            .map(|usage| serde_json::from_str(&usage))
809            .transpose()
810            .map_err(|error| {
811                rusqlite::Error::FromSqlConversionFailure(
812                    5,
813                    rusqlite::types::Type::Text,
814                    error.into(),
815                )
816            })?,
817        created_at: row.get(6)?,
818    })
819}
820
821fn role_to_str(role: Role) -> &'static str {
822    match role {
823        Role::System => "system",
824        Role::User => "user",
825        Role::Assistant => "assistant",
826        Role::Tool => "tool",
827    }
828}
829
830fn str_to_role(role: &str) -> Result<Role, String> {
831    match role {
832        "system" => Ok(Role::System),
833        "user" => Ok(Role::User),
834        "assistant" => Ok(Role::Assistant),
835        "tool" => Ok(Role::Tool),
836        _ => Err(format!("unknown conversation role: {role}")),
837    }
838}
839
840fn item_kind(role: Role, blocks: &[ContentBlock]) -> &'static str {
841    match role {
842        Role::User => "user_message",
843        Role::Tool => "tool_result",
844        Role::System => "system_checkpoint",
845        Role::Assistant
846            if blocks
847                .iter()
848                .any(|block| matches!(block, ContentBlock::ToolCall(_))) =>
849        {
850            "tool_call"
851        }
852        Role::Assistant
853            if blocks
854                .iter()
855                .any(|block| matches!(block, ContentBlock::Reasoning(_))) =>
856        {
857            "assistant_reasoning"
858        }
859        Role::Assistant => "assistant_text",
860    }
861}
862
863fn now_millis() -> i64 {
864    SystemTime::now()
865        .duration_since(UNIX_EPOCH)
866        .unwrap_or_default()
867        .as_millis() as i64
868}