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 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 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 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 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
748fn 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}