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_chat_meta(json: &str, session_id: String) -> rootcause::Result<CursorSession> {
17 let doc = serde_json::from_str::<CursorChatMeta>(json)
18 .context("failed to parse Cursor chat metadata".to_owned())
19 .attach(format!("meta_json={json}"))?;
20
21 let cwd = doc.cwd.trim();
22 if cwd.is_empty() {
23 return Err(report!("Cursor chat meta cwd is empty").attach(format!("session_id={session_id}")));
24 }
25 let workspace_dir = PathBuf::from(cwd);
26
27 let created_at = Timestamp::from_millisecond(doc.created_at_ms)
28 .context("Cursor createdAtMs is out of range".to_owned())
29 .attach(format!("session_id={session_id}"))
30 .attach(format!("created_at_ms={}", doc.created_at_ms))?;
31 let updated_at = match doc.updated_at_ms {
32 Some(updated_at_ms) => Timestamp::from_millisecond(updated_at_ms)
33 .context("Cursor updatedAtMs is out of range".to_owned())
34 .attach(format!("session_id={session_id}"))
35 .attach(format!("updated_at_ms={updated_at_ms}"))?,
36 None => created_at,
37 };
38
39 let name = doc.title.filter(|title| !title.trim().is_empty()).unwrap_or_else(|| {
40 workspace_dir
41 .file_name()
42 .and_then(|name| name.to_str())
43 .filter(|name| !name.is_empty())
44 .map_or_else(|| session_id.clone(), str::to_owned)
45 });
46
47 Ok(CursorSession {
48 id: session_id,
49 name: name.clone(),
50 search_text: name,
51 workspace: workspace_dir,
52 created_at,
53 updated_at,
54 has_conversation: doc.has_conversation.unwrap_or(true),
55 })
56}
57
58pub fn build_search_text_from_prompts(session_name: &str, prompts: &[String]) -> String {
59 let mut search_text = SearchTextBuilder::default();
60 for prompt in prompts {
61 search_text.push(prompt);
62 }
63 search_text.build(session_name)
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct CursorSession {
68 pub id: String,
69 pub name: String,
70 pub search_text: String,
71 pub workspace: PathBuf,
72 pub created_at: Timestamp,
73 pub updated_at: Timestamp,
74 pub has_conversation: bool,
75}
76
77impl CursorSession {
78 pub fn into_session(self, path: PathBuf) -> Session {
79 let mut session = Session::new(Agent::Cursor, self.id, self.workspace, path, None, self.created_at);
80 session.name = self.name;
81 session.search_text = self.search_text;
82 session.updated_at = self.updated_at;
83 session
84 }
85}
86
87#[derive(Debug, Deserialize)]
88struct CursorChatMeta {
89 #[serde(rename = "createdAtMs")]
90 created_at_ms: i64,
91 #[serde(rename = "updatedAtMs")]
92 updated_at_ms: Option<i64>,
93 title: Option<String>,
94 cwd: String,
95 #[serde(rename = "hasConversation")]
96 has_conversation: Option<bool>,
97}
98
99#[cfg(test)]
100mod tests {
101 use test_that::prelude::*;
102
103 use super::*;
104
105 #[test]
106 fn test_parse_chat_meta_when_json_has_cwd_returns_workspace_and_title() {
107 let json = r#"{"schemaVersion":1,"createdAtMs":1774877738013,"hasConversation":true,"title":"Status Line","updatedAtMs":1774877739013,"cwd":"/Users/gianlu/data/dev/work/pws-api/pws-api"}"#;
108
109 let cursor_session_result = parse_chat_meta(json, "session-id".to_owned());
110 assert_that!(cursor_session_result.as_ref().map(|_| ()), ok(eq(())));
111 let cursor_session = cursor_session_result.expect("Cursor chat metadata should parse");
112 let session = cursor_session.into_session(PathBuf::from("session-id"));
113
114 assert_that!(session.agent, eq(Agent::Cursor));
115 assert_that!(session.id, eq("session-id"));
116 assert_that!(session.name, eq("Status Line"));
117 assert_that!(
118 session.workspace,
119 eq(PathBuf::from("/Users/gianlu/data/dev/work/pws-api/pws-api"))
120 );
121 }
122
123 #[test]
124 fn test_parse_chat_meta_when_cwd_is_empty_returns_error() {
125 let json = r#"{"schemaVersion":1,"createdAtMs":1774877738013,"hasConversation":true,"title":"Status Line","cwd":" "}"#;
126
127 assert_that!(
128 (parse_chat_meta(json, "session-id".to_owned())).map(|_| ()),
129 err(displays_as(contains_substring("Cursor chat meta cwd is empty")))
130 );
131 }
132
133 #[test]
134 fn test_build_search_text_from_prompts_when_prompts_repeat_keeps_unique_snippets() {
135 let search_text = build_search_text_from_prompts(
136 "Status Line",
137 &[
138 "first prompt".to_owned(),
139 "first prompt".to_owned(),
140 "second prompt".to_owned(),
141 ],
142 );
143
144 assert_that!(search_text, eq("Status Line first prompt second prompt"));
145 }
146}