Skip to main content

agm_core/agent/session_loader/
cursor.rs

1use 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}