ytil_agents/agent/session_parser/
cursor.rs1use 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
12pub 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
42pub 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}