Skip to main content

ytil_agents/agent/session_loader/
cursor.rs

1use std::path::Path;
2use std::path::PathBuf;
3use std::process::Command;
4
5use rootcause::prelude::ResultExt;
6use rootcause::report;
7use rusqlite::Connection;
8use rusqlite::OpenFlags;
9use rusqlite::OptionalExtension;
10
11use crate::agent::Agent;
12use crate::agent::session::Session;
13use crate::agent::session::SessionKey;
14
15/// Load Cursor agent sessions from local Cursor chat databases.
16///
17/// # Errors
18/// Returns an error when Cursor metadata cannot be read or parsed.
19pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
20    let chats_root = ytil_sys::dir::build_home_path(Agent::Cursor.sessions_root_path())?;
21    let session_paths = crate::agent::session_loader::find_session_paths(
22        &chats_root,
23        |entry| entry.path().file_name().is_some_and(|name| name == "store.db"),
24        |_| false,
25    )?;
26
27    load_sessions_from_paths(session_paths, read_strings_output, None)
28}
29
30/// Load only requested Cursor sessions from local Cursor chat databases.
31///
32/// # Errors
33/// Returns an error when a matching Cursor session cannot be read or parsed.
34pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
35    let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Cursor);
36    if requested_ids.is_empty() {
37        return Ok(Vec::new());
38    }
39    let chats_root = ytil_sys::dir::build_home_path(Agent::Cursor.sessions_root_path())?;
40    let session_paths = crate::agent::session_loader::find_session_paths(
41        &chats_root,
42        |entry| entry.path().file_name().is_some_and(|name| name == "store.db"),
43        |_| false,
44    )?;
45
46    load_sessions_from_paths(session_paths, read_strings_output, Some(&requested_ids))
47}
48
49fn load_sessions_from_paths(
50    session_paths: Vec<PathBuf>,
51    mut read_strings: impl FnMut(&Path) -> rootcause::Result<String>,
52    requested_ids: Option<&std::collections::HashSet<&str>>,
53) -> rootcause::Result<Vec<Session>> {
54    let known_workspaces = load_known_workspaces()?;
55    let ignored_roots = vec![ytil_sys::dir::build_home_path(Agent::Cursor.root_path())?];
56
57    let mut sessions = Vec::new();
58    for store_db in session_paths {
59        let meta_hex = read_meta_hex(&store_db)?;
60        let Some(meta_hex) = meta_hex.filter(|value| !value.trim().is_empty()) else {
61            continue;
62        };
63        if let Some(requested_ids) = requested_ids {
64            let session_id = crate::agent::session_parser::cursor::parse_session_id(&meta_hex)
65                .attach_with(|| format!("store_db={}", store_db.display()))?;
66            if !requested_ids.contains(session_id.as_str()) {
67                continue;
68            }
69        }
70        let strings_output = read_strings(&store_db)?;
71        let Some(workspace) = crate::agent::session_parser::cursor::extract_cursor_workspace_from_strings(
72            &strings_output,
73            &known_workspaces,
74            &ignored_roots,
75        ) else {
76            continue;
77        };
78        if !workspace.is_dir() {
79            continue;
80        }
81        let mut cursor_session = crate::agent::session_parser::cursor::parse(&meta_hex, workspace)
82            .attach_with(|| format!("store_db={}", store_db.display()))?;
83        cursor_session.search_text =
84            crate::agent::session_parser::cursor::build_search_text_from_strings(&cursor_session.name, &strings_output);
85        cursor_session.updated_at =
86            crate::agent::session_loader::file_updated_at(&store_db)?.unwrap_or(cursor_session.created_at);
87        let path = store_db.parent().map_or_else(|| store_db.clone(), Path::to_path_buf);
88        sessions.push(cursor_session.into_session(path));
89    }
90
91    Ok(sessions)
92}
93
94fn load_known_workspaces() -> rootcause::Result<Vec<PathBuf>> {
95    let root = ytil_sys::dir::build_home_path(&[".cursor", "projects"])?;
96
97    let mut workspaces = Vec::new();
98    for path in crate::agent::session_loader::find_session_paths(
99        &root,
100        |entry| {
101            entry
102                .path()
103                .file_name()
104                .is_some_and(|name| name == ".workspace-trusted")
105        },
106        |_| false,
107    )? {
108        let content = std::fs::read_to_string(&path)
109            .context("failed to read Cursor workspace marker")
110            .attach_with(|| format!("path={}", path.display()))?;
111        let trimmed = content.trim();
112        if trimmed.is_empty() {
113            continue;
114        }
115        let candidate = PathBuf::from(trimmed);
116        if candidate.is_dir() {
117            workspaces.push(candidate);
118        }
119    }
120
121    workspaces.sort();
122    workspaces.dedup();
123
124    Ok(workspaces)
125}
126
127fn read_meta_hex(store_db: &Path) -> rootcause::Result<Option<String>> {
128    let connection = Connection::open_with_flags(store_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
129        .context("failed to open Cursor store db")
130        .attach_with(|| format!("store_db={}", store_db.display()))?;
131    Ok(connection
132        .query_row("select value from meta limit 1", [], |row| row.get::<_, String>(0))
133        .optional()
134        .context("failed to query Cursor session metadata")
135        .attach_with(|| format!("store_db={}", store_db.display()))?)
136}
137
138fn read_strings_output(store_db: &Path) -> rootcause::Result<String> {
139    let output = Command::new("strings")
140        .arg(store_db)
141        .output()
142        .context("failed to run strings for Cursor store db")
143        .attach_with(|| format!("store_db={}", store_db.display()))?;
144
145    if !output.status.success() {
146        return Err(report!("strings exited with non-zero status")
147            .attach(format!("store_db={}", store_db.display()))
148            .attach(format!("status={}", output.status)));
149    }
150
151    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
152}
153
154#[cfg(test)]
155mod tests {
156    use std::cell::Cell;
157    use std::fmt::Write;
158
159    use rusqlite::Connection;
160    use tempfile::tempdir;
161
162    use super::*;
163
164    #[test]
165    fn test_load_sessions_from_paths_by_key_runs_strings_only_for_matching_cursor_db() {
166        let dir = tempdir().expect("tempdir should be created");
167        let workspace = dir.path().join("workspace");
168        std::fs::create_dir_all(&workspace).expect("workspace should be created");
169        let target_db = dir.path().join("target").join("store.db");
170        let other_db = dir.path().join("other").join("store.db");
171        create_store_db(&target_db, "target");
172        create_store_db(&other_db, "other");
173        let keys = vec![SessionKey::new(Agent::Cursor, "target")];
174        let requested_ids = crate::agent::session_loader::requested_ids(&keys, Agent::Cursor);
175        let strings_calls = Cell::new(0);
176
177        assert2::assert!(let Ok(sessions) = load_sessions_from_paths(
178            vec![target_db, other_db],
179            |_| {
180                strings_calls.set(strings_calls.get() + 1);
181                Ok(workspace.display().to_string())
182            },
183            Some(&requested_ids),
184        ));
185
186        pretty_assertions::assert_eq!(strings_calls.get(), 1);
187        pretty_assertions::assert_eq!(sessions.len(), 1);
188        pretty_assertions::assert_eq!(sessions[0].id, "target");
189    }
190
191    fn create_store_db(path: &Path, session_id: &str) {
192        let parent = path.parent().expect("test db path should have parent");
193        std::fs::create_dir_all(parent).expect("test db parent should be created");
194        let connection = Connection::open(path).expect("test db should open");
195        connection
196            .execute("create table meta (value text)", [])
197            .expect("meta table should be created");
198        let meta = hex(&format!(
199            r#"{{"agentId":"{session_id}","name":"Cursor Session","createdAt":1774877738013}}"#
200        ));
201        connection
202            .execute("insert into meta (value) values (?1)", [&meta])
203            .expect("meta row should be inserted");
204    }
205
206    fn hex(value: &str) -> String {
207        let mut out = String::with_capacity(value.len().saturating_mul(2));
208        for byte in value.as_bytes() {
209            write!(&mut out, "{byte:02x}").expect("writing to string should not fail");
210        }
211        out
212    }
213}