Skip to main content

xurl_core/provider/
cursor.rs

1use std::cmp::Reverse;
2use std::collections::HashSet;
3use std::fs;
4use std::hash::{Hash, Hasher};
5use std::io::{BufRead, BufReader, Read};
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::time::SystemTime;
9
10use once_cell::sync::Lazy;
11use regex::Regex;
12use rusqlite::{Connection, OpenFlags};
13use serde::Deserialize;
14use serde_json::{Value, json};
15use walkdir::WalkDir;
16
17use crate::error::{Result, XurlError};
18use crate::model::{ProviderKind, ResolutionMeta, ResolvedThread, WriteRequest, WriteResult};
19use crate::provider::{Provider, WriteEventSink, append_passthrough_args};
20
21static FILE_URI_RE: Lazy<Regex> =
22    Lazy::new(|| Regex::new(r#"file:///[^\x00-\x1f"']+"#).expect("file uri regex must be valid"));
23
24const MAX_PROTO_SCAN_DEPTH: usize = 8;
25
26#[derive(Debug, Clone)]
27pub struct CursorProvider {
28    root: PathBuf,
29}
30
31#[derive(Debug, Clone, Default)]
32pub(crate) struct CursorMaterializedMetadata {
33    pub name: Option<String>,
34    pub mode: Option<String>,
35    pub model: Option<String>,
36    pub workspace_path: Option<String>,
37}
38
39#[derive(Debug, Clone)]
40pub(crate) struct CursorMaterialization {
41    pub path: PathBuf,
42    pub search_text: String,
43    pub metadata: CursorMaterializedMetadata,
44}
45
46#[derive(Debug, Deserialize)]
47struct CursorChatMeta {
48    #[serde(rename = "agentId")]
49    agent_id: String,
50    #[serde(rename = "latestRootBlobId")]
51    latest_root_blob_id: String,
52    #[serde(default)]
53    name: Option<String>,
54    #[serde(default)]
55    mode: Option<String>,
56    #[serde(rename = "lastUsedModel", default)]
57    last_used_model: Option<String>,
58}
59
60#[derive(Debug, Clone)]
61struct CursorMessage {
62    id: String,
63    role: String,
64    parts: Vec<Value>,
65}
66
67#[derive(Debug, Default)]
68struct CursorCollector {
69    known_blob_ids: HashSet<String>,
70    visited_blob_ids: HashSet<String>,
71    seen_message_keys: HashSet<String>,
72    messages: Vec<CursorMessage>,
73    workspace_path: Option<String>,
74}
75
76impl CursorProvider {
77    pub fn new(root: impl Into<PathBuf>) -> Self {
78        Self { root: root.into() }
79    }
80
81    fn chats_root(&self) -> PathBuf {
82        self.root.join("chats")
83    }
84
85    fn materialized_path(&self, session_id: &str) -> PathBuf {
86        let mut hasher = std::collections::hash_map::DefaultHasher::new();
87        self.root.hash(&mut hasher);
88        let root_key = format!("{:016x}", hasher.finish());
89
90        std::env::temp_dir()
91            .join("xurl-cursor")
92            .join(root_key)
93            .join(format!("{session_id}.jsonl"))
94    }
95
96    pub(crate) fn find_store_candidates(&self, session_id: &str) -> Vec<PathBuf> {
97        let chats_root = self.chats_root();
98        if !chats_root.exists() {
99            return Vec::new();
100        }
101
102        WalkDir::new(chats_root)
103            .min_depth(3)
104            .max_depth(3)
105            .into_iter()
106            .filter_map(std::result::Result::ok)
107            .filter(|entry| entry.file_type().is_file())
108            .map(|entry| entry.into_path())
109            .filter(|path| {
110                path.file_name().and_then(|name| name.to_str()) == Some("store.db")
111                    && path
112                        .parent()
113                        .and_then(Path::file_name)
114                        .and_then(|name| name.to_str())
115                        == Some(session_id)
116            })
117            .collect()
118    }
119
120    fn choose_latest(paths: Vec<PathBuf>) -> Option<(PathBuf, usize)> {
121        if paths.is_empty() {
122            return None;
123        }
124
125        let mut scored = paths
126            .into_iter()
127            .map(|path| {
128                let modified = fs::metadata(&path)
129                    .and_then(|meta| meta.modified())
130                    .unwrap_or(SystemTime::UNIX_EPOCH);
131                (path, modified)
132            })
133            .collect::<Vec<_>>();
134
135        scored.sort_by_key(|(_, modified)| Reverse(*modified));
136        let count = scored.len();
137        scored.into_iter().next().map(|(path, _)| (path, count))
138    }
139
140    fn open_store(path: &Path) -> Result<Connection> {
141        Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|source| {
142            XurlError::Sqlite {
143                path: path.to_path_buf(),
144                source,
145            }
146        })
147    }
148
149    fn decode_hex_bytes(raw: &str, path: &Path) -> Result<Vec<u8>> {
150        if raw.len() % 2 != 0 {
151            return Err(XurlError::InvalidMode(format!(
152                "cursor meta payload in {} is not valid hex",
153                path.display()
154            )));
155        }
156
157        let bytes = raw.as_bytes();
158        let mut output = Vec::with_capacity(bytes.len() / 2);
159        let mut index = 0;
160        while index < bytes.len() {
161            let hi = Self::decode_hex_nibble(bytes[index]).ok_or_else(|| {
162                XurlError::InvalidMode(format!(
163                    "cursor meta payload in {} is not valid hex",
164                    path.display()
165                ))
166            })?;
167            let lo = Self::decode_hex_nibble(bytes[index + 1]).ok_or_else(|| {
168                XurlError::InvalidMode(format!(
169                    "cursor meta payload in {} is not valid hex",
170                    path.display()
171                ))
172            })?;
173            output.push((hi << 4) | lo);
174            index += 2;
175        }
176
177        Ok(output)
178    }
179
180    fn decode_hex_nibble(ch: u8) -> Option<u8> {
181        match ch {
182            b'0'..=b'9' => Some(ch - b'0'),
183            b'a'..=b'f' => Some(10 + ch - b'a'),
184            b'A'..=b'F' => Some(10 + ch - b'A'),
185            _ => None,
186        }
187    }
188
189    fn bytes_to_hex(bytes: &[u8]) -> String {
190        let mut output = String::with_capacity(bytes.len() * 2);
191        for byte in bytes {
192            output.push_str(&format!("{byte:02x}"));
193        }
194        output
195    }
196
197    fn parse_chat_meta(conn: &Connection, db_path: &Path) -> Result<CursorChatMeta> {
198        let raw = conn
199            .query_row(
200                "SELECT value FROM meta WHERE key = '0' LIMIT 1",
201                [],
202                |row| row.get::<_, String>(0),
203            )
204            .map_err(|source| XurlError::Sqlite {
205                path: db_path.to_path_buf(),
206                source,
207            })?;
208        let bytes = Self::decode_hex_bytes(&raw, db_path)?;
209        serde_json::from_slice::<CursorChatMeta>(&bytes).map_err(|source| {
210            XurlError::InvalidMode(format!(
211                "failed parsing cursor chat meta {}: {source}",
212                db_path.display()
213            ))
214        })
215    }
216
217    fn fetch_blob_bytes(
218        conn: &Connection,
219        db_path: &Path,
220        blob_id: &str,
221    ) -> Result<Option<Vec<u8>>> {
222        conn.query_row(
223            "SELECT data FROM blobs WHERE id = ?1 LIMIT 1",
224            [blob_id],
225            |row| row.get::<_, Vec<u8>>(0),
226        )
227        .map(Some)
228        .or_else(|source| match source {
229            rusqlite::Error::QueryReturnedNoRows => Ok(None),
230            other => Err(XurlError::Sqlite {
231                path: db_path.to_path_buf(),
232                source: other,
233            }),
234        })
235    }
236
237    fn load_blob_index(conn: &Connection, db_path: &Path) -> Result<HashSet<String>> {
238        let mut stmt =
239            conn.prepare("SELECT id FROM blobs")
240                .map_err(|source| XurlError::Sqlite {
241                    path: db_path.to_path_buf(),
242                    source,
243                })?;
244        let rows = stmt
245            .query_map([], |row| row.get::<_, String>(0))
246            .map_err(|source| XurlError::Sqlite {
247                path: db_path.to_path_buf(),
248                source,
249            })?;
250
251        let mut ids = HashSet::new();
252        for row in rows {
253            ids.insert(row.map_err(|source| XurlError::Sqlite {
254                path: db_path.to_path_buf(),
255                source,
256            })?);
257        }
258
259        Ok(ids)
260    }
261
262    pub(crate) fn materialize_store(
263        &self,
264        store_path: &Path,
265        session_id: &str,
266    ) -> Result<CursorMaterialization> {
267        let conn = Self::open_store(store_path)?;
268        let chat_meta = Self::parse_chat_meta(&conn, store_path)?;
269        if chat_meta.agent_id.to_ascii_lowercase() != session_id {
270            return Err(XurlError::InvalidMode(format!(
271                "cursor store {} belongs to session {} instead of {}",
272                store_path.display(),
273                chat_meta.agent_id,
274                session_id
275            )));
276        }
277
278        let known_blob_ids = Self::load_blob_index(&conn, store_path)?;
279        let mut collector = CursorCollector {
280            known_blob_ids,
281            ..CursorCollector::default()
282        };
283        collector.walk_blob(&conn, store_path, &chat_meta.latest_root_blob_id)?;
284
285        let metadata = CursorMaterializedMetadata {
286            name: chat_meta.name,
287            mode: chat_meta.mode,
288            model: chat_meta.last_used_model,
289            workspace_path: collector.workspace_path.clone(),
290        };
291        let search_text = collector.search_text();
292        let output = Self::render_jsonl(session_id, &metadata, &collector.messages);
293        let materialized_path = self.materialized_path(session_id);
294        if let Some(parent) = materialized_path.parent() {
295            fs::create_dir_all(parent).map_err(|source| XurlError::Io {
296                path: parent.to_path_buf(),
297                source,
298            })?;
299        }
300        fs::write(&materialized_path, output).map_err(|source| XurlError::Io {
301            path: materialized_path.clone(),
302            source,
303        })?;
304
305        Ok(CursorMaterialization {
306            path: materialized_path,
307            search_text,
308            metadata,
309        })
310    }
311
312    fn render_jsonl(
313        session_id: &str,
314        metadata: &CursorMaterializedMetadata,
315        messages: &[CursorMessage],
316    ) -> String {
317        let mut session_metadata = serde_json::Map::new();
318        if let Some(name) = &metadata.name {
319            session_metadata.insert("name".to_string(), Value::String(name.clone()));
320        }
321        if let Some(mode) = &metadata.mode {
322            session_metadata.insert("mode".to_string(), Value::String(mode.clone()));
323        }
324        if let Some(model) = &metadata.model {
325            session_metadata.insert("model".to_string(), Value::String(model.clone()));
326        }
327        if let Some(cwd) = &metadata.workspace_path {
328            session_metadata.insert("cwd".to_string(), Value::String(cwd.clone()));
329        }
330
331        let mut lines = Vec::with_capacity(messages.len() + 1);
332        lines.push(json!({
333            "type": "session",
334            "sessionId": session_id,
335            "metadata": session_metadata,
336        }));
337
338        for message in messages {
339            lines.push(json!({
340                "type": "message",
341                "id": message.id,
342                "sessionId": session_id,
343                "message": {
344                    "role": message.role,
345                },
346                "parts": message.parts,
347            }));
348        }
349
350        let mut output = String::new();
351        for line in lines {
352            let encoded = serde_json::to_string(&line).expect("json serialization should succeed");
353            output.push_str(&encoded);
354            output.push('\n');
355        }
356        output
357    }
358
359    fn cursor_bin() -> String {
360        std::env::var("XURL_CURSOR_BIN").unwrap_or_else(|_| "cursor-agent".to_string())
361    }
362
363    fn spawn_cursor_command(args: &[String]) -> Result<std::process::Child> {
364        let bin = Self::cursor_bin();
365        let mut command = Command::new(&bin);
366        command
367            .args(args)
368            .stdin(Stdio::null())
369            .stdout(Stdio::piped())
370            .stderr(Stdio::piped());
371        command.spawn().map_err(|source| {
372            if source.kind() == std::io::ErrorKind::NotFound {
373                XurlError::CommandNotFound { command: bin }
374            } else {
375                XurlError::Io {
376                    path: PathBuf::from(bin),
377                    source,
378                }
379            }
380        })
381    }
382
383    fn run_create_chat() -> Result<String> {
384        let bin = Self::cursor_bin();
385        let output = Command::new(&bin)
386            .arg("create-chat")
387            .output()
388            .map_err(|source| {
389                if source.kind() == std::io::ErrorKind::NotFound {
390                    XurlError::CommandNotFound {
391                        command: bin.clone(),
392                    }
393                } else {
394                    XurlError::Io {
395                        path: PathBuf::from(&bin),
396                        source,
397                    }
398                }
399            })?;
400
401        if !output.status.success() {
402            return Err(XurlError::CommandFailed {
403                command: format!("{bin} create-chat"),
404                code: output.status.code(),
405                stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
406            });
407        }
408
409        let session_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
410        if session_id.is_empty() {
411            return Err(XurlError::WriteProtocol(
412                "cursor create-chat did not return a session id".to_string(),
413            ));
414        }
415
416        Ok(session_id)
417    }
418
419    fn collect_text_part_texts(parts: &[Value]) -> String {
420        parts
421            .iter()
422            .filter_map(|part| {
423                if part.get("type").and_then(Value::as_str) == Some("text") {
424                    part.get("text").and_then(Value::as_str)
425                } else {
426                    None
427                }
428            })
429            .filter(|text| !text.trim().is_empty())
430            .map(ToString::to_string)
431            .collect::<Vec<_>>()
432            .join("\n\n")
433    }
434
435    fn extract_assistant_text(value: &Value) -> Option<String> {
436        if value.get("type").and_then(Value::as_str) == Some("assistant")
437            && let Some(message) = value.get("message")
438        {
439            let parts = message.get("content")?.as_array()?;
440            let text = Self::collect_text_part_texts(parts);
441            if !text.is_empty() {
442                return Some(text);
443            }
444        }
445
446        if value.get("type").and_then(Value::as_str) == Some("result") {
447            return value
448                .get("result")
449                .and_then(Value::as_str)
450                .filter(|text| !text.is_empty())
451                .map(ToString::to_string);
452        }
453
454        None
455    }
456
457    fn run_write(
458        &self,
459        session_id: String,
460        args: &[String],
461        req: &WriteRequest,
462        sink: &mut dyn WriteEventSink,
463        warnings: Vec<String>,
464    ) -> Result<WriteResult> {
465        let mut child = Self::spawn_cursor_command(args)?;
466        let stdout = child.stdout.take().ok_or_else(|| {
467            XurlError::WriteProtocol("cursor-agent stdout pipe is unavailable".to_string())
468        })?;
469        let stderr = child.stderr.take().ok_or_else(|| {
470            XurlError::WriteProtocol("cursor-agent stderr pipe is unavailable".to_string())
471        })?;
472        let stderr_handle = std::thread::spawn(move || {
473            let mut reader = BufReader::new(stderr);
474            let mut content = String::new();
475            let _ = reader.read_to_string(&mut content);
476            content
477        });
478
479        let stream_path = Path::new("<cursor-agent:stdout>");
480        let mut current_session_id = Some(session_id);
481        let mut final_text = None::<String>;
482        let mut last_emitted_assistant = None::<String>;
483        let reader = BufReader::new(stdout);
484        for line in reader.lines() {
485            let line = line.map_err(|source| XurlError::Io {
486                path: stream_path.to_path_buf(),
487                source,
488            })?;
489            let trimmed = line.trim();
490            if trimmed.is_empty() {
491                continue;
492            }
493
494            let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
495                continue;
496            };
497
498            if value.get("type").and_then(Value::as_str) == Some("system")
499                && value.get("subtype").and_then(Value::as_str) == Some("init")
500                && let Some(found_session_id) = value.get("session_id").and_then(Value::as_str)
501                && current_session_id.as_deref() != Some(found_session_id)
502            {
503                sink.on_session_ready(ProviderKind::Cursor, found_session_id)?;
504                current_session_id = Some(found_session_id.to_string());
505            }
506
507            if let Some(text) = Self::extract_assistant_text(&value) {
508                if last_emitted_assistant.as_deref() != Some(text.as_str()) {
509                    sink.on_text_delta(&text)?;
510                    last_emitted_assistant = Some(text.clone());
511                }
512                final_text = Some(text);
513            }
514        }
515
516        let status = child.wait().map_err(|source| XurlError::Io {
517            path: PathBuf::from(Self::cursor_bin()),
518            source,
519        })?;
520        let stderr_content = stderr_handle.join().unwrap_or_default();
521        if !status.success() {
522            return Err(XurlError::CommandFailed {
523                command: format!("{} {}", Self::cursor_bin(), args.join(" ")),
524                code: status.code(),
525                stderr: stderr_content.trim().to_string(),
526            });
527        }
528
529        let session_id = current_session_id
530            .or(req.session_id.clone())
531            .ok_or_else(|| {
532                XurlError::WriteProtocol("missing session id in cursor-agent output".to_string())
533            })?;
534
535        Ok(WriteResult {
536            provider: ProviderKind::Cursor,
537            session_id,
538            final_text,
539            warnings,
540        })
541    }
542}
543
544impl Provider for CursorProvider {
545    fn kind(&self) -> ProviderKind {
546        ProviderKind::Cursor
547    }
548
549    fn resolve(&self, session_id: &str) -> Result<ResolvedThread> {
550        let candidates = self.find_store_candidates(session_id);
551        if let Some((selected, count)) = Self::choose_latest(candidates) {
552            let materialized = self.materialize_store(&selected, session_id)?;
553            let mut metadata = ResolutionMeta {
554                source: "cursor:store.db".to_string(),
555                candidate_count: count,
556                warnings: Vec::new(),
557            };
558            if count > 1 {
559                metadata.warnings.push(format!(
560                    "multiple cursor stores found ({count}) for session_id={session_id}; selected latest: {}",
561                    selected.display()
562                ));
563            }
564
565            return Ok(ResolvedThread {
566                provider: ProviderKind::Cursor,
567                session_id: session_id.to_string(),
568                path: materialized.path,
569                metadata,
570            });
571        }
572
573        Err(XurlError::ThreadNotFound {
574            provider: ProviderKind::Cursor.to_string(),
575            session_id: session_id.to_string(),
576            searched_roots: vec![self.chats_root()],
577        })
578    }
579
580    fn write(&self, req: &WriteRequest, sink: &mut dyn WriteEventSink) -> Result<WriteResult> {
581        if req.options.role.is_some() {
582            return Err(XurlError::InvalidMode(
583                "cursor does not support role-based write URI".to_string(),
584            ));
585        }
586
587        let session_id = match &req.session_id {
588            Some(session_id) => session_id.clone(),
589            None => {
590                let session_id = Self::run_create_chat()?;
591                sink.on_session_ready(ProviderKind::Cursor, &session_id)?;
592                session_id
593            }
594        };
595
596        let mut args = vec![
597            "--resume".to_string(),
598            session_id.clone(),
599            "--print".to_string(),
600            "--output-format".to_string(),
601            "stream-json".to_string(),
602            "--trust".to_string(),
603        ];
604        append_passthrough_args(&mut args, &req.options.params);
605        args.push(req.prompt.clone());
606
607        self.run_write(session_id, &args, req, sink, Vec::new())
608    }
609}
610
611impl CursorCollector {
612    fn walk_blob(&mut self, conn: &Connection, db_path: &Path, blob_id: &str) -> Result<()> {
613        if !self.visited_blob_ids.insert(blob_id.to_string()) {
614            return Ok(());
615        }
616
617        let Some(bytes) = CursorProvider::fetch_blob_bytes(conn, db_path, blob_id)? else {
618            return Ok(());
619        };
620        self.inspect_bytes(conn, db_path, &bytes, Some(blob_id), 0)
621    }
622
623    fn inspect_bytes(
624        &mut self,
625        conn: &Connection,
626        db_path: &Path,
627        bytes: &[u8],
628        source_id: Option<&str>,
629        depth: usize,
630    ) -> Result<()> {
631        if let Some(value) = parse_json_bytes(bytes) {
632            self.handle_json_value(source_id, &value);
633        }
634
635        if self.workspace_path.is_none() {
636            self.workspace_path = extract_workspace_path_from_bytes(bytes);
637        }
638
639        if depth >= MAX_PROTO_SCAN_DEPTH {
640            return Ok(());
641        }
642
643        let mut index = 0;
644        while index < bytes.len() {
645            let Some(field_key) = read_varint(bytes, &mut index) else {
646                break;
647            };
648
649            match field_key & 0x07 {
650                0 => {
651                    if read_varint(bytes, &mut index).is_none() {
652                        break;
653                    }
654                }
655                1 => {
656                    if index + 8 > bytes.len() {
657                        break;
658                    }
659                    index += 8;
660                }
661                2 => {
662                    let Some(length) = read_varint(bytes, &mut index) else {
663                        break;
664                    };
665                    let Ok(length) = usize::try_from(length) else {
666                        break;
667                    };
668                    if index + length > bytes.len() {
669                        break;
670                    }
671                    let payload = &bytes[index..index + length];
672                    if length == 32 {
673                        let referenced_id = CursorProvider::bytes_to_hex(payload);
674                        if self.known_blob_ids.contains(&referenced_id) {
675                            self.walk_blob(conn, db_path, &referenced_id)?;
676                            index += length;
677                            continue;
678                        }
679                    }
680
681                    self.inspect_bytes(conn, db_path, payload, None, depth + 1)?;
682                    index += length;
683                }
684                5 => {
685                    if index + 4 > bytes.len() {
686                        break;
687                    }
688                    index += 4;
689                }
690                _ => break,
691            }
692        }
693
694        Ok(())
695    }
696
697    fn handle_json_value(&mut self, source_id: Option<&str>, value: &Value) {
698        if self.workspace_path.is_none() {
699            self.workspace_path = extract_workspace_path_from_value(value);
700        }
701
702        let Some(message) = build_cursor_message(source_id, value) else {
703            return;
704        };
705        let message_key = serde_json::to_string(value).unwrap_or_else(|_| message.id.clone());
706        if self.seen_message_keys.insert(message_key) {
707            self.messages.push(message);
708        }
709    }
710
711    fn search_text(&self) -> String {
712        self.messages
713            .iter()
714            .map(|message| {
715                message
716                    .parts
717                    .iter()
718                    .filter_map(|part| {
719                        if part.get("type").and_then(Value::as_str) == Some("text") {
720                            part.get("text").and_then(Value::as_str)
721                        } else {
722                            None
723                        }
724                    })
725                    .collect::<Vec<_>>()
726                    .join("\n")
727            })
728            .filter(|text| !text.trim().is_empty())
729            .collect::<Vec<_>>()
730            .join("\n")
731    }
732}
733
734fn parse_json_bytes(bytes: &[u8]) -> Option<Value> {
735    serde_json::from_slice::<Value>(bytes).ok()
736}
737
738fn read_varint(bytes: &[u8], index: &mut usize) -> Option<u64> {
739    let mut shift = 0_u32;
740    let mut value = 0_u64;
741    while *index < bytes.len() && shift <= 63 {
742        let byte = bytes[*index];
743        *index += 1;
744        value |= u64::from(byte & 0x7f) << shift;
745        if byte & 0x80 == 0 {
746            return Some(value);
747        }
748        shift += 7;
749    }
750
751    None
752}
753
754fn extract_workspace_path_from_value(value: &Value) -> Option<String> {
755    value
756        .get("cwd")
757        .and_then(Value::as_str)
758        .map(ToString::to_string)
759        .or_else(|| {
760            extract_workspace_path_from_bytes(serde_json::to_string(value).ok()?.as_bytes())
761        })
762}
763
764fn extract_workspace_path_from_bytes(bytes: &[u8]) -> Option<String> {
765    let haystack = String::from_utf8_lossy(bytes);
766    let matched = FILE_URI_RE.find(&haystack)?.as_str();
767    decode_file_uri_path(matched)
768}
769
770fn decode_file_uri_path(uri: &str) -> Option<String> {
771    let path = uri.strip_prefix("file://")?;
772    let mut output = Vec::with_capacity(path.len());
773    let bytes = path.as_bytes();
774    let mut index = 0;
775    while index < bytes.len() {
776        match bytes[index] {
777            b'%' if index + 2 < bytes.len() => {
778                let hi = CursorProvider::decode_hex_nibble(bytes[index + 1])?;
779                let lo = CursorProvider::decode_hex_nibble(bytes[index + 2])?;
780                output.push((hi << 4) | lo);
781                index += 3;
782            }
783            byte => {
784                output.push(byte);
785                index += 1;
786            }
787        }
788    }
789
790    String::from_utf8(output).ok()
791}
792
793fn build_cursor_message(source_id: Option<&str>, value: &Value) -> Option<CursorMessage> {
794    let role = value.get("role").and_then(Value::as_str)?;
795    if role != "user" && role != "assistant" {
796        return None;
797    }
798    if value
799        .get("providerOptions")
800        .and_then(|options| options.get("cursor"))
801        .and_then(|cursor| cursor.get("isSummary"))
802        .and_then(Value::as_bool)
803        .unwrap_or(false)
804    {
805        return None;
806    }
807
808    let mut parts = Vec::new();
809    match value.get("content") {
810        Some(Value::String(text)) => {
811            if !text.trim().is_empty() {
812                parts.push(json!({
813                    "type": "text",
814                    "text": text,
815                }));
816            }
817        }
818        Some(Value::Array(items)) => {
819            for item in items {
820                let Some(item_type) = item.get("type").and_then(Value::as_str) else {
821                    continue;
822                };
823                match item_type {
824                    "text" => {
825                        if let Some(text) = item.get("text").and_then(Value::as_str)
826                            && !text.trim().is_empty()
827                        {
828                            parts.push(json!({
829                                "type": "text",
830                                "text": text,
831                            }));
832                        }
833                    }
834                    "file" => {
835                        let label = item
836                            .get("filename")
837                            .and_then(Value::as_str)
838                            .map(|name| format!("[File: {name}]"))
839                            .unwrap_or_else(|| "[File]".to_string());
840                        parts.push(json!({
841                            "type": "text",
842                            "text": label,
843                        }));
844                    }
845                    "image" => {
846                        parts.push(json!({
847                            "type": "text",
848                            "text": "[Image]",
849                        }));
850                    }
851                    "reasoning" | "redacted-reasoning" => {}
852                    _ => {}
853                }
854            }
855        }
856        _ => {}
857    }
858
859    if parts.is_empty() {
860        return None;
861    }
862
863    let id = value
864        .get("id")
865        .and_then(Value::as_str)
866        .map(ToString::to_string)
867        .or_else(|| source_id.map(ToString::to_string))
868        .unwrap_or_else(|| {
869            serde_json::to_string(value)
870                .unwrap_or_else(|_| role.to_string())
871                .chars()
872                .take(64)
873                .collect()
874        });
875
876    Some(CursorMessage {
877        id,
878        role: role.to_string(),
879        parts,
880    })
881}