Skip to main content

ytil_agents/agent/session_loader/
cursor.rs

1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4
5use crate::agent::Agent;
6use crate::agent::session::Session;
7use crate::agent::session::SessionKey;
8
9/// Load Cursor agent sessions from local Cursor chat metadata.
10///
11/// Unreadable or invalid `meta.json` files are skipped.
12///
13/// # Errors
14/// Returns an error when the Cursor session store cannot be enumerated.
15pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
16    let chats_root = ytil_sys::dir::build_home_path(Agent::Cursor.sessions_root_path())?;
17    let session_paths = crate::agent::session_loader::find_session_paths(&chats_root, is_session_file, |_| false)?;
18
19    Ok(load_sessions_from_paths(&session_paths, None))
20}
21
22/// Load only requested Cursor sessions from local Cursor chat metadata.
23///
24/// Unreadable or invalid matching `meta.json` files are skipped.
25///
26/// # Errors
27/// Returns an error when the Cursor session store cannot be enumerated.
28pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
29    let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Cursor);
30    if requested_ids.is_empty() {
31        return Ok(Vec::new());
32    }
33    let chats_root = ytil_sys::dir::build_home_path(Agent::Cursor.sessions_root_path())?;
34    let session_paths = crate::agent::session_loader::find_session_paths(&chats_root, is_session_file, |_| false)?;
35
36    Ok(load_sessions_from_paths(&session_paths, Some(&requested_ids)))
37}
38
39pub(crate) fn is_session_file(entry: &std::fs::DirEntry) -> bool {
40    entry.path().file_name().is_some_and(|name| name == "meta.json")
41}
42
43fn load_sessions_from_paths(session_paths: &[PathBuf], requested_ids: Option<&HashSet<&str>>) -> Vec<Session> {
44    session_paths
45        .iter()
46        .filter_map(|meta_path| load_session_from_meta_json(meta_path, requested_ids))
47        .collect()
48}
49
50fn load_session_from_meta_json(meta_path: &Path, requested_ids: Option<&HashSet<&str>>) -> Option<Session> {
51    let session_dir = meta_path
52        .parent()
53        .map_or_else(|| meta_path.to_path_buf(), Path::to_path_buf);
54    let session_id = session_dir.file_name().and_then(|name| name.to_str())?;
55    if requested_ids.is_some_and(|ids| !ids.contains(session_id)) {
56        return None;
57    }
58
59    let json = std::fs::read_to_string(meta_path).ok()?;
60    let mut cursor_session =
61        crate::agent::session_parser::cursor::parse_chat_meta(&json, session_id.to_owned()).ok()?;
62    if !cursor_session.has_conversation || !cursor_session.workspace.is_dir() {
63        return None;
64    }
65
66    let prompts = read_prompt_history(&session_dir);
67    cursor_session.search_text =
68        crate::agent::session_parser::cursor::build_search_text_from_prompts(&cursor_session.name, &prompts);
69    let mut session = cursor_session.into_session(session_dir);
70    // Cursor writes `prompt_history.json` newest-first.
71    session.last_user_prompt = prompts.iter().find(|prompt| !prompt.trim().is_empty()).cloned();
72    Some(session)
73}
74
75fn read_prompt_history(session_dir: &Path) -> Vec<String> {
76    let path = session_dir.join("prompt_history.json");
77    let Ok(content) = std::fs::read_to_string(path) else {
78        return Vec::new();
79    };
80    serde_json::from_str(&content).unwrap_or_default()
81}
82
83#[cfg(test)]
84mod tests {
85    use tempfile::tempdir;
86    use test_that::prelude::*;
87
88    use super::*;
89
90    #[test]
91    fn test_load_sessions_from_paths_by_key_when_invoked_loads_only_matching_cursor_meta() {
92        let dir = tempdir().expect("tempdir should be created");
93        let workspace = dir.path().join("workspace");
94        std::fs::create_dir_all(&workspace).expect("workspace should be created");
95        let target_meta = dir.path().join("target").join("meta.json");
96        let other_meta = dir.path().join("other").join("meta.json");
97        write_meta_json(&target_meta, &workspace, true, "Target");
98        write_meta_json(&other_meta, &workspace, true, "Other");
99        let keys = vec![SessionKey::new(Agent::Cursor, "target")];
100        let requested_ids = crate::agent::session_loader::requested_ids(&keys, Agent::Cursor);
101
102        let sessions = load_sessions_from_paths(&[target_meta, other_meta], Some(&requested_ids));
103
104        assert_that!(sessions.len(), eq(1));
105        assert_that!(sessions[0].id, eq("target"));
106        assert_that!(sessions[0].name, eq("Target"));
107    }
108
109    #[test]
110    fn test_load_sessions_from_paths_when_meta_json_has_cwd_uses_workspace() {
111        let dir = tempdir().expect("tempdir should be created");
112        let workspace = dir.path().join("work").join("pws-api");
113        std::fs::create_dir_all(&workspace).expect("workspace should be created");
114        let meta_path = dir
115            .path()
116            .join("chats")
117            .join("hash")
118            .join("session-id")
119            .join("meta.json");
120        write_meta_json(&meta_path, &workspace, true, "Status Line");
121        std::fs::write(
122            meta_path
123                .parent()
124                .expect("meta.json should have a parent")
125                .join("prompt_history.json"),
126            r#"["first prompt"]"#,
127        )
128        .expect("prompt history should be written");
129
130        let sessions = load_sessions_from_paths(&[meta_path], None);
131
132        assert_that!(sessions.len(), eq(1));
133        assert_that!(sessions[0].id, eq("session-id"));
134        assert_that!(sessions[0].name, eq("Status Line"));
135        assert_that!(sessions[0].workspace, eq(workspace));
136        assert_that!(sessions[0].last_user_prompt.as_deref(), eq(Some("first prompt")));
137    }
138
139    #[test]
140    fn test_load_sessions_from_paths_when_prompt_history_is_newest_first_sets_last_user_prompt_to_newest() {
141        let dir = tempdir().expect("tempdir should be created");
142        let workspace = dir.path().join("work").join("pws-api");
143        std::fs::create_dir_all(&workspace).expect("workspace should be created");
144        let meta_path = dir.path().join("session-id").join("meta.json");
145        write_meta_json(&meta_path, &workspace, true, "Status Line");
146        std::fs::write(
147            meta_path
148                .parent()
149                .expect("meta.json should have a parent")
150                .join("prompt_history.json"),
151            r#"["newest", "oldest"]"#,
152        )
153        .expect("prompt history should be written");
154
155        let sessions = load_sessions_from_paths(&[meta_path], None);
156
157        assert_that!(sessions.len(), eq(1));
158        assert_that!(sessions[0].last_user_prompt.as_deref(), eq(Some("newest")));
159    }
160
161    #[test]
162    fn test_load_sessions_from_paths_when_meta_json_has_no_conversation_skips_session() {
163        let dir = tempdir().expect("tempdir should be created");
164        let workspace = dir.path().join("work").join("pws-api");
165        std::fs::create_dir_all(&workspace).expect("workspace should be created");
166        let meta_path = dir
167            .path()
168            .join("chats")
169            .join("hash")
170            .join("session-id")
171            .join("meta.json");
172        write_meta_json(&meta_path, &workspace, false, "Empty");
173
174        let sessions = load_sessions_from_paths(&[meta_path], None);
175
176        assert_that!(sessions.len(), eq(0));
177    }
178
179    #[test]
180    fn test_load_sessions_from_paths_when_one_meta_json_is_invalid_skips_and_loads_valid() {
181        let dir = tempdir().expect("tempdir should be created");
182        let workspace = dir.path().join("work").join("pws-api");
183        std::fs::create_dir_all(&workspace).expect("workspace should be created");
184        let invalid_meta = dir.path().join("invalid").join("meta.json");
185        let valid_meta = dir.path().join("valid").join("meta.json");
186        let parent = invalid_meta.parent().expect("invalid meta.json should have a parent");
187        std::fs::create_dir_all(parent).expect("invalid session dir should be created");
188        std::fs::write(&invalid_meta, r#"{"hasConversation":false}"#).expect("invalid meta.json should be written");
189        write_meta_json(&valid_meta, &workspace, true, "Valid");
190
191        let sessions = load_sessions_from_paths(&[invalid_meta, valid_meta], None);
192
193        assert_that!(sessions.len(), eq(1));
194        assert_that!(sessions[0].id, eq("valid"));
195        assert_that!(sessions[0].name, eq("Valid"));
196    }
197
198    fn write_meta_json(path: &Path, workspace: &Path, has_conversation: bool, title: &str) {
199        let parent = path.parent().expect("meta.json should have a parent");
200        std::fs::create_dir_all(parent).expect("session dir should be created");
201        std::fs::write(
202            path,
203            format!(
204                r#"{{"schemaVersion":1,"createdAtMs":1774877738013,"hasConversation":{has_conversation},"title":"{title}","updatedAtMs":1774877739013,"cwd":"{}"}}"#,
205                workspace.display()
206            ),
207        )
208        .expect("meta.json should be written");
209    }
210}