agm_core/agent/session_loader/
cursor.rs1use std::path::Path;
2use std::path::PathBuf;
3use std::process::Command;
4
5use rootcause::prelude::ResultExt as _;
6use rootcause::report;
7use rusqlite::Connection;
8use rusqlite::OptionalExtension as _;
9
10use crate::agent::Agent;
11use crate::agent::session::Session;
12
13pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
14 let chats_root = ytil_sys::dir::build_home_path(Agent::Cursor.sessions_root_path())?;
15 let session_paths = ytil_sys::file::find_matching_recursively_in_dir(
16 &chats_root,
17 |entry| entry.path().file_name().is_some_and(|name| name == "store.db"),
18 |_| false,
19 )?;
20
21 let known_workspaces = load_known_workspaces()?;
22 let ignored_roots = vec![ytil_sys::dir::build_home_path(Agent::Cursor.root_path())?];
23
24 let mut sessions = Vec::new();
25 for store_db in session_paths {
26 let meta_hex = read_meta_hex(&store_db)?;
27 let Some(meta_hex) = meta_hex.filter(|value| !value.trim().is_empty()) else {
28 continue;
29 };
30 let strings_output = read_strings_output(&store_db)?;
31 let Some(workspace) = crate::agent::session_parser::cursor::extract_cursor_workspace_from_strings(
32 &strings_output,
33 &known_workspaces,
34 &ignored_roots,
35 ) else {
36 continue;
37 };
38 if !workspace.is_dir() {
39 continue;
40 }
41 let mut session = crate::agent::session_parser::cursor::parse(&meta_hex, workspace)
42 .attach_with(|| format!("store_db={}", store_db.display()))?;
43 session.updated_at = super::file_updated_at(&store_db)?.unwrap_or(session.created_at);
44 session.path = store_db.parent().map_or_else(|| store_db.clone(), Path::to_path_buf);
45 sessions.push(session);
46 }
47
48 Ok(sessions)
49}
50
51fn load_known_workspaces() -> rootcause::Result<Vec<PathBuf>> {
52 let root = ytil_sys::dir::build_home_path(&[".cursor", "projects"])?;
53
54 let mut workspaces = Vec::new();
55 for path in ytil_sys::file::find_matching_recursively_in_dir(
56 &root,
57 |entry| {
58 entry
59 .path()
60 .file_name()
61 .is_some_and(|name| name == ".workspace-trusted")
62 },
63 |_| false,
64 )? {
65 let content = std::fs::read_to_string(&path)
66 .context("failed to read Cursor workspace marker")
67 .attach_with(|| format!("path={}", path.display()))?;
68 let trimmed = content.trim();
69 if trimmed.is_empty() {
70 continue;
71 }
72 let candidate = PathBuf::from(trimmed);
73 if candidate.is_dir() {
74 workspaces.push(candidate);
75 }
76 }
77
78 workspaces.sort();
79 workspaces.dedup();
80
81 Ok(workspaces)
82}
83
84fn read_meta_hex(store_db: &Path) -> rootcause::Result<Option<String>> {
85 let connection = Connection::open(store_db)
86 .context("failed to open Cursor store db")
87 .attach_with(|| format!("store_db={}", store_db.display()))?;
88 Ok(connection
89 .query_row("select value from meta limit 1", [], |row| row.get::<_, String>(0))
90 .optional()
91 .context("failed to query Cursor session metadata")
92 .attach_with(|| format!("store_db={}", store_db.display()))?)
93}
94
95fn read_strings_output(store_db: &Path) -> rootcause::Result<String> {
96 let output = Command::new("strings")
97 .arg(store_db)
98 .output()
99 .context("failed to run strings for Cursor store db")
100 .attach_with(|| format!("store_db={}", store_db.display()))?;
101
102 if !output.status.success() {
103 return Err(report!("strings exited with non-zero status")
104 .attach(format!("store_db={}", store_db.display()))
105 .attach(format!("status={}", output.status)));
106 }
107
108 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
109}