ytil_agents/agent/session_loader/
cursor.rs1use 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
15pub 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
30pub 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}