Skip to main content

ytil_agents/agent/session_parser/
cursor.rs

1use std::path::PathBuf;
2
3use jiff::Timestamp;
4use rootcause::prelude::ResultExt;
5use rootcause::report;
6use serde::Deserialize;
7
8use crate::agent::Agent;
9use crate::agent::session::SearchTextBuilder;
10use crate::agent::session::Session;
11
12/// Parse Cursor session metadata into a session.
13///
14/// # Errors
15/// Returns an error when the encoded metadata is invalid or contains an invalid timestamp.
16pub fn parse(meta_hex: &str, workspace_dir: PathBuf) -> rootcause::Result<CursorSession> {
17    let doc = parse_meta(meta_hex)?;
18
19    let created_at = Timestamp::from_millisecond(doc.created_at)
20        .context("Cursor createdAt is out of range".to_owned())
21        .attach(format!("session_id={}", doc.agent_id))
22        .attach(format!("created_at_ms={}", doc.created_at))?;
23
24    let name = doc.name.unwrap_or_else(|| {
25        workspace_dir
26            .file_name()
27            .and_then(|name| name.to_str())
28            .filter(|name| !name.is_empty())
29            .map_or_else(|| doc.agent_id.clone(), str::to_owned)
30    });
31
32    Ok(CursorSession {
33        id: doc.agent_id,
34        name: name.clone(),
35        search_text: name,
36        workspace: workspace_dir,
37        created_at,
38        updated_at: created_at,
39    })
40}
41
42/// Parse only the session id from Cursor metadata.
43///
44/// # Errors
45/// Returns an error when the encoded metadata is invalid or missing required fields.
46pub fn parse_session_id(meta_hex: &str) -> rootcause::Result<String> {
47    parse_meta(meta_hex).map(|meta| meta.agent_id)
48}
49
50fn parse_meta(meta_hex: &str) -> rootcause::Result<CursorMeta> {
51    let meta_json = decode_hex_string(meta_hex)
52        .context("failed to decode Cursor meta payload".to_owned())
53        .attach(format!("meta_hex={meta_hex}"))?;
54    Ok(serde_json::from_str::<CursorMeta>(&meta_json)
55        .context("failed to parse Cursor session metadata".to_owned())
56        .attach(format!("meta_json={meta_json}"))?)
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct CursorSession {
61    pub id: String,
62    pub name: String,
63    pub search_text: String,
64    pub workspace: PathBuf,
65    pub created_at: Timestamp,
66    pub updated_at: Timestamp,
67}
68
69impl CursorSession {
70    pub fn into_session(self, path: PathBuf) -> Session {
71        let mut session = Session::new(Agent::Cursor, self.id, self.workspace, path, None, self.created_at);
72        session.name = self.name;
73        session.search_text = self.search_text;
74        session.updated_at = self.updated_at;
75        session
76    }
77}
78
79fn decode_hex_string(raw: &str) -> rootcause::Result<String> {
80    let hex = raw.trim();
81
82    if !hex.len().is_multiple_of(2) {
83        return Err(report!("hex string has odd length").attach(format!("len={}", hex.len())));
84    }
85
86    let mut bytes = Vec::with_capacity(hex.len() / 2);
87    for pair in hex.as_bytes().as_chunks::<2>().0 {
88        let pair = std::str::from_utf8(pair).context("hex chunk is not utf8".to_owned())?;
89        let byte = u8::from_str_radix(pair, 16).context("invalid hex byte".to_owned())?;
90        bytes.push(byte);
91    }
92
93    Ok(String::from_utf8(bytes).context("decoded hex string is not utf8".to_owned())?)
94}
95
96pub fn build_search_text_from_strings(session_name: &str, strings_output: &str) -> String {
97    let mut search_text = SearchTextBuilder::default();
98    for line in strings_output.lines().filter_map(searchable_cursor_strings_line) {
99        search_text.push(&line);
100    }
101    search_text.build(session_name)
102}
103
104pub fn extract_cursor_workspace_from_strings(
105    strings_output: &str,
106    known_workspaces: &[PathBuf],
107    ignored_roots: &[PathBuf],
108) -> Option<PathBuf> {
109    let mut known_matches: Vec<PathBuf> = known_workspaces
110        .iter()
111        .filter(|workspace| workspace.to_str().is_some_and(|value| strings_output.contains(value)))
112        .cloned()
113        .collect();
114    known_matches.sort_by_key(|workspace| std::cmp::Reverse(workspace.components().count()));
115    if let Some(workspace) = known_matches.into_iter().next() {
116        return Some(workspace);
117    }
118
119    for line in strings_output.lines() {
120        for candidate in extract_absolute_path_candidates(line) {
121            let Some(existing_path) = longest_existing_path(&candidate) else {
122                continue;
123            };
124            let workspace_dir = if existing_path.is_dir() {
125                existing_path
126            } else if let Some(parent) = existing_path.parent() {
127                parent.to_path_buf()
128            } else {
129                continue;
130            };
131            if ignored_roots.iter().any(|root| workspace_dir.starts_with(root)) {
132                continue;
133            }
134            return Some(workspace_dir);
135        }
136    }
137
138    None
139}
140
141#[derive(Debug, Deserialize)]
142struct CursorMeta {
143    #[serde(rename = "agentId")]
144    agent_id: String,
145    name: Option<String>,
146    #[serde(rename = "createdAt")]
147    created_at: i64,
148}
149
150fn extract_absolute_path_candidates(line: &str) -> Vec<String> {
151    let mut candidates = Vec::new();
152    candidates.extend(extract_prefixed_candidates(line, "file:///"));
153    candidates.extend(extract_prefixed_candidates(line, "/"));
154    candidates
155}
156
157fn searchable_cursor_strings_line(line: &str) -> Option<String> {
158    let normalized = line.split_whitespace().collect::<Vec<_>>().join(" ");
159    let normalized = (!normalized.is_empty()).then_some(normalized)?;
160    if normalized.len() < 8 {
161        return None;
162    }
163    if !normalized.chars().any(char::is_alphabetic) || !normalized.chars().any(char::is_whitespace) {
164        return None;
165    }
166    if normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
167        return None;
168    }
169    if !extract_absolute_path_candidates(&normalized).is_empty() {
170        return None;
171    }
172
173    let lower = normalized.to_ascii_lowercase();
174    if lower.contains("create table")
175        || lower.contains("sqlite_")
176        || lower.contains("indexsqlite_")
177        || lower.starts_with("file:///")
178    {
179        return None;
180    }
181
182    Some(normalized)
183}
184
185fn extract_prefixed_candidates(line: &str, prefix: &str) -> Vec<String> {
186    let mut candidates = Vec::new();
187    let mut start = 0;
188    while let Some(search_area) = line.get(start..) {
189        let Some(offset) = search_area.find(prefix) else {
190            break;
191        };
192        let absolute_start = start.saturating_add(offset);
193        let Some(suffix) = line.get(absolute_start..) else {
194            break;
195        };
196        let candidate: String = suffix.chars().take_while(|ch| is_path_char(*ch)).collect();
197        if !candidate.is_empty() {
198            candidates.push(candidate);
199        }
200        start = absolute_start.saturating_add(prefix.len());
201    }
202    candidates
203}
204
205const fn is_path_char(ch: char) -> bool {
206    ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '~')
207}
208
209fn longest_existing_path(candidate: &str) -> Option<PathBuf> {
210    let normalized = candidate.strip_prefix("file://").unwrap_or(candidate);
211    let mut path = PathBuf::from(normalized);
212
213    while !path.exists() {
214        if !path.pop() {
215            return None;
216        }
217    }
218
219    Some(path)
220}
221
222#[cfg(test)]
223mod tests {
224    use tempfile::tempdir;
225    use test_that::prelude::*;
226
227    use super::*;
228
229    #[test]
230    fn test_decodes_cursor_meta_hex_payload() {
231        assert_that!(
232            decode_hex_string("7b226e616d65223a225361666520526562617365227d"),
233            ok(eq("{\"name\":\"Safe Rebase\"}"))
234        );
235    }
236
237    #[test]
238    fn test_parses_cursor_session_from_meta_json() {
239        let tempdir = tempdir().unwrap();
240        let workspace = tempdir.path().join("workspace");
241        std::fs::create_dir_all(&workspace).unwrap();
242
243        let meta_hex = "7b226167656e744964223a2266626364393632362d623065642d343739632d623838372d376132633264313531376636222c226e616d65223a225361666520526562617365222c22637265617465644174223a313737343837373733383031337d";
244        let cursor_session_result = parse(meta_hex, workspace.clone());
245        assert_that!(cursor_session_result.as_ref().map(|_| ()), ok(eq(())));
246        let cursor_session = cursor_session_result.expect("Cursor metadata should parse");
247        let session = cursor_session.into_session(workspace.join("store.db"));
248        assert_that!(session.agent, eq(Agent::Cursor));
249        assert_that!(session.workspace, eq(workspace));
250        assert_that!(session.name, eq("Safe Rebase"));
251    }
252
253    #[test]
254    fn test_extracts_cursor_workspace_from_known_workspaces_first() {
255        let tempdir = tempdir().unwrap();
256        let workspace = tempdir.path().join("work").join("dotfiles");
257        std::fs::create_dir_all(&workspace).unwrap();
258
259        let strings_output = format!("file://{}/README.md\n{}\n", workspace.display(), workspace.display());
260        let extracted = extract_cursor_workspace_from_strings(&strings_output, std::slice::from_ref(&workspace), &[]);
261        assert_that!(extracted, eq(Some(workspace)));
262    }
263
264    #[test]
265    fn test_extracts_cursor_workspace_from_generic_path_candidates() {
266        let tempdir = tempdir().unwrap();
267        let workspace = tempdir.path().join("work").join("repo");
268        let ignored = tempdir.path().join("home").join(".cursor");
269        std::fs::create_dir_all(workspace.join("src")).unwrap();
270        std::fs::create_dir_all(&ignored).unwrap();
271
272        let strings_output = format!("garbage file://{}/src/main.rs trailing", workspace.display());
273        let extracted = extract_cursor_workspace_from_strings(&strings_output, &[], &[ignored]);
274        assert_that!(extracted, eq(Some(workspace.join("src"))));
275    }
276
277    #[test]
278    fn test_build_search_text_from_strings_keeps_human_lines_and_filters_noise() {
279        let strings_output = concat!(
280            "CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB);\n",
281            "indexsqlite_autoindex_blobs_1blobs\n",
282            "/Users/foo/bar/baz\n",
283            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n",
284            "user asked about stalled sync job\n",
285            "user asked about stalled sync job\n",
286            "assistant suggested retrying the worker\n"
287        );
288
289        let search_text = build_search_text_from_strings("Cursor Session", strings_output);
290
291        assert_that!(
292            search_text,
293            eq("Cursor Session user asked about stalled sync job assistant suggested retrying the worker")
294        );
295    }
296}