lore_cli/capture/watchers/
roo_code.rs

1//! Roo Code session parser.
2//!
3//! Parses conversation history from Roo Code, an AI coding assistant VS Code
4//! extension that is a fork of Cline.
5//!
6//! Roo Code stores task conversations in the VS Code global storage directory:
7//! - macOS: `~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`
8//! - Linux: `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`
9//! - Windows: `%APPDATA%/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`
10//!
11//! Each task has its own directory containing:
12//! - `api_conversation_history.json` - Raw API message exchanges
13//! - `ui_messages.json` - User-facing message format
14//! - `task_metadata.json` - Task metadata (optional)
15
16use anyhow::{Context, Result};
17use chrono::{DateTime, TimeZone, Utc};
18use serde::Deserialize;
19use std::fs;
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23use crate::storage::models::{Message, MessageContent, MessageRole, Session};
24
25use super::{Watcher, WatcherInfo};
26
27/// Watcher for Roo Code sessions.
28///
29/// Discovers and parses task conversation files from Roo Code's VS Code
30/// extension storage.
31pub struct RooCodeWatcher;
32
33impl Watcher for RooCodeWatcher {
34    fn info(&self) -> WatcherInfo {
35        WatcherInfo {
36            name: "roo-code",
37            description: "Roo Code VS Code extension sessions",
38            default_paths: vec![roo_code_tasks_path()],
39        }
40    }
41
42    fn is_available(&self) -> bool {
43        roo_code_tasks_path().exists()
44    }
45
46    fn find_sources(&self) -> Result<Vec<PathBuf>> {
47        find_roo_code_tasks()
48    }
49
50    fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
51        let parsed = parse_roo_code_task(path)?;
52        match parsed {
53            Some((session, messages)) if !messages.is_empty() => Ok(vec![(session, messages)]),
54            _ => Ok(vec![]),
55        }
56    }
57
58    fn watch_paths(&self) -> Vec<PathBuf> {
59        vec![roo_code_tasks_path()]
60    }
61}
62
63/// Returns the path to Roo Code's tasks storage directory.
64///
65/// This is platform-specific:
66/// - macOS: `~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks`
67/// - Linux: `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks`
68/// - Windows: `%APPDATA%/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks`
69fn roo_code_tasks_path() -> PathBuf {
70    let base = get_vscode_global_storage();
71    base.join("rooveterinaryinc.roo-cline").join("tasks")
72}
73
74/// Returns the VS Code global storage path.
75fn get_vscode_global_storage() -> PathBuf {
76    #[cfg(target_os = "macos")]
77    {
78        dirs::home_dir()
79            .unwrap_or_else(|| PathBuf::from("."))
80            .join("Library/Application Support/Code/User/globalStorage")
81    }
82    #[cfg(target_os = "linux")]
83    {
84        dirs::config_dir()
85            .unwrap_or_else(|| PathBuf::from("."))
86            .join("Code/User/globalStorage")
87    }
88    #[cfg(target_os = "windows")]
89    {
90        dirs::config_dir()
91            .unwrap_or_else(|| PathBuf::from("."))
92            .join("Code/User/globalStorage")
93    }
94    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
95    {
96        dirs::config_dir()
97            .unwrap_or_else(|| PathBuf::from("."))
98            .join("Code/User/globalStorage")
99    }
100}
101
102/// Finds all Roo Code task directories.
103///
104/// Each task has its own subdirectory containing conversation files.
105fn find_roo_code_tasks() -> Result<Vec<PathBuf>> {
106    let tasks_path = roo_code_tasks_path();
107
108    if !tasks_path.exists() {
109        return Ok(Vec::new());
110    }
111
112    let mut tasks = Vec::new();
113
114    for entry in fs::read_dir(&tasks_path)? {
115        let entry = entry?;
116        let path = entry.path();
117
118        if path.is_dir() {
119            // Check for conversation history file
120            let history_file = path.join("api_conversation_history.json");
121            if history_file.exists() {
122                tasks.push(history_file);
123            }
124        }
125    }
126
127    Ok(tasks)
128}
129
130/// Raw Roo Code API conversation message.
131/// Uses the same format as Cline.
132#[derive(Debug, Deserialize)]
133struct RooCodeApiMessage {
134    /// Role: "user" or "assistant"
135    role: String,
136
137    /// Message content (can be string or array of content blocks)
138    content: RooCodeContent,
139
140    /// Timestamp (milliseconds since epoch)
141    #[serde(default)]
142    ts: Option<i64>,
143}
144
145/// Content in Roo Code API format.
146/// Uses the same format as Cline.
147#[derive(Debug, Deserialize)]
148#[serde(untagged)]
149enum RooCodeContent {
150    /// Simple text content
151    Text(String),
152    /// Array of content blocks
153    Blocks(Vec<RooCodeContentBlock>),
154}
155
156impl RooCodeContent {
157    /// Extracts text content from the message.
158    fn to_text(&self) -> String {
159        match self {
160            Self::Text(s) => s.clone(),
161            Self::Blocks(blocks) => blocks
162                .iter()
163                .filter_map(|b| match b {
164                    RooCodeContentBlock::Text { text } => Some(text.clone()),
165                    _ => None,
166                })
167                .collect::<Vec<_>>()
168                .join("\n"),
169        }
170    }
171}
172
173/// A content block in Roo Code messages.
174/// Uses the same format as Cline.
175#[derive(Debug, Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177enum RooCodeContentBlock {
178    /// Text content
179    Text { text: String },
180    /// Image content
181    Image {
182        #[allow(dead_code)]
183        source: serde_json::Value,
184    },
185    /// Tool use
186    ToolUse {
187        #[allow(dead_code)]
188        id: Option<String>,
189        #[allow(dead_code)]
190        name: Option<String>,
191        #[allow(dead_code)]
192        input: Option<serde_json::Value>,
193    },
194    /// Tool result
195    ToolResult {
196        #[allow(dead_code)]
197        tool_use_id: Option<String>,
198        #[allow(dead_code)]
199        content: Option<serde_json::Value>,
200    },
201}
202
203/// Task metadata from Roo Code.
204/// Uses the same format as Cline.
205#[derive(Debug, Deserialize, Default)]
206struct RooCodeTaskMetadata {
207    /// Timestamp (ISO 8601 or milliseconds)
208    #[serde(default)]
209    ts: Option<serde_json::Value>,
210
211    /// Working directory
212    #[serde(default)]
213    dir: Option<String>,
214}
215
216/// Parses a Roo Code task from its conversation history file.
217fn parse_roo_code_task(history_path: &Path) -> Result<Option<(Session, Vec<Message>)>> {
218    let content =
219        fs::read_to_string(history_path).context("Failed to read Roo Code conversation history")?;
220
221    let raw_messages: Vec<RooCodeApiMessage> =
222        serde_json::from_str(&content).context("Failed to parse Roo Code conversation JSON")?;
223
224    if raw_messages.is_empty() {
225        return Ok(None);
226    }
227
228    // Try to get task directory for ID and metadata
229    let task_dir = history_path.parent();
230    let task_id = task_dir
231        .and_then(|p| p.file_name())
232        .and_then(|n| n.to_str())
233        .map(|s| s.to_string());
234
235    // Try to read metadata
236    let metadata = task_dir
237        .map(|d| d.join("task_metadata.json"))
238        .filter(|p| p.exists())
239        .and_then(|p| fs::read_to_string(p).ok())
240        .and_then(|c| serde_json::from_str::<RooCodeTaskMetadata>(&c).ok())
241        .unwrap_or_default();
242
243    // Generate session ID from task ID or create new one
244    let session_id = task_id
245        .as_ref()
246        .and_then(|id| Uuid::parse_str(id).ok())
247        .unwrap_or_else(Uuid::new_v4);
248
249    // Determine timestamps
250    let first_ts = raw_messages.first().and_then(|m| m.ts);
251    let last_ts = raw_messages.last().and_then(|m| m.ts);
252
253    let started_at = first_ts
254        .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
255        .or_else(|| {
256            metadata.ts.as_ref().and_then(|v| match v {
257                serde_json::Value::Number(n) => n
258                    .as_i64()
259                    .and_then(|ts| Utc.timestamp_millis_opt(ts).single()),
260                serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
261                    .ok()
262                    .map(|dt| dt.with_timezone(&Utc)),
263                _ => None,
264            })
265        })
266        .unwrap_or_else(Utc::now);
267
268    let ended_at = last_ts.and_then(|ts| Utc.timestamp_millis_opt(ts).single());
269
270    // Get working directory
271    let working_directory = metadata
272        .dir
273        .or_else(|| {
274            task_dir
275                .and_then(|d| d.parent())
276                .and_then(|d| d.parent())
277                .and_then(|d| d.parent())
278                .map(|d| d.to_string_lossy().to_string())
279        })
280        .unwrap_or_else(|| ".".to_string());
281
282    let session = Session {
283        id: session_id,
284        tool: "roo-code".to_string(),
285        tool_version: None,
286        started_at,
287        ended_at,
288        model: None,
289        working_directory,
290        git_branch: None,
291        source_path: Some(history_path.to_string_lossy().to_string()),
292        message_count: raw_messages.len() as i32,
293        machine_id: crate::storage::get_machine_id(),
294    };
295
296    // Convert messages
297    let mut messages = Vec::new();
298    let time_per_message = chrono::Duration::seconds(30);
299    let mut current_time = started_at;
300
301    for (idx, msg) in raw_messages.iter().enumerate() {
302        let role = match msg.role.as_str() {
303            "user" => MessageRole::User,
304            "assistant" => MessageRole::Assistant,
305            "system" => MessageRole::System,
306            _ => continue,
307        };
308
309        let content_text = msg.content.to_text();
310        if content_text.trim().is_empty() {
311            continue;
312        }
313
314        let timestamp = msg
315            .ts
316            .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
317            .unwrap_or(current_time);
318
319        messages.push(Message {
320            id: Uuid::new_v4(),
321            session_id,
322            parent_id: None,
323            index: idx as i32,
324            timestamp,
325            role,
326            content: MessageContent::Text(content_text),
327            model: None,
328            git_branch: None,
329            cwd: Some(session.working_directory.clone()),
330        });
331
332        current_time += time_per_message;
333    }
334
335    if messages.is_empty() {
336        return Ok(None);
337    }
338
339    Ok(Some((session, messages)))
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::io::Write;
346    use tempfile::{NamedTempFile, TempDir};
347
348    /// Creates a temporary Roo Code conversation file with given JSON content.
349    fn create_temp_conversation_file(json: &str) -> NamedTempFile {
350        let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
351        file.write_all(json.as_bytes())
352            .expect("Failed to write content");
353        file.flush().expect("Failed to flush");
354        file
355    }
356
357    /// Creates a temporary task directory structure.
358    fn create_temp_task_dir(task_id: &str, history_json: &str) -> TempDir {
359        let temp_dir = TempDir::new().expect("Failed to create temp dir");
360        let task_dir = temp_dir.path().join(task_id);
361        fs::create_dir_all(&task_dir).expect("Failed to create task dir");
362
363        let history_file = task_dir.join("api_conversation_history.json");
364        fs::write(&history_file, history_json).expect("Failed to write history file");
365
366        temp_dir
367    }
368
369    #[test]
370    fn test_watcher_info() {
371        let watcher = RooCodeWatcher;
372        let info = watcher.info();
373
374        assert_eq!(info.name, "roo-code");
375        assert_eq!(info.description, "Roo Code VS Code extension sessions");
376    }
377
378    #[test]
379    fn test_watcher_watch_paths() {
380        let watcher = RooCodeWatcher;
381        let paths = watcher.watch_paths();
382
383        assert!(!paths.is_empty());
384        assert!(paths[0]
385            .to_string_lossy()
386            .contains("rooveterinaryinc.roo-cline"));
387        assert!(paths[0].to_string_lossy().contains("tasks"));
388    }
389
390    #[test]
391    fn test_parse_simple_conversation() {
392        let json = r#"[
393            {"role": "user", "content": "Hello, can you help me?", "ts": 1704067200000},
394            {"role": "assistant", "content": "Of course! What do you need?", "ts": 1704067230000}
395        ]"#;
396
397        let file = create_temp_conversation_file(json);
398        let result = parse_roo_code_task(file.path()).expect("Should parse");
399
400        let (session, messages) = result.expect("Should have session");
401        assert_eq!(session.tool, "roo-code");
402        assert_eq!(messages.len(), 2);
403        assert_eq!(messages[0].role, MessageRole::User);
404        assert_eq!(messages[1].role, MessageRole::Assistant);
405    }
406
407    #[test]
408    fn test_parse_with_content_blocks() {
409        let json = r#"[
410            {
411                "role": "user",
412                "content": [
413                    {"type": "text", "text": "Hello"},
414                    {"type": "text", "text": "World"}
415                ],
416                "ts": 1704067200000
417            }
418        ]"#;
419
420        let file = create_temp_conversation_file(json);
421        let result = parse_roo_code_task(file.path()).expect("Should parse");
422
423        let (_, messages) = result.expect("Should have session");
424        assert_eq!(messages.len(), 1);
425        if let MessageContent::Text(text) = &messages[0].content {
426            assert!(text.contains("Hello"));
427            assert!(text.contains("World"));
428        } else {
429            panic!("Expected text content");
430        }
431    }
432
433    #[test]
434    fn test_parse_empty_conversation() {
435        let json = "[]";
436
437        let file = create_temp_conversation_file(json);
438        let result = parse_roo_code_task(file.path()).expect("Should parse");
439
440        assert!(result.is_none());
441    }
442
443    #[test]
444    fn test_parse_with_tool_blocks() {
445        let json = r#"[
446            {
447                "role": "user",
448                "content": "Create a file",
449                "ts": 1704067200000
450            },
451            {
452                "role": "assistant",
453                "content": [
454                    {"type": "text", "text": "I'll create that file."},
455                    {"type": "tool_use", "id": "tool_1", "name": "write_file", "input": {"path": "test.txt"}}
456                ],
457                "ts": 1704067230000
458            }
459        ]"#;
460
461        let file = create_temp_conversation_file(json);
462        let result = parse_roo_code_task(file.path()).expect("Should parse");
463
464        let (_, messages) = result.expect("Should have session");
465        assert_eq!(messages.len(), 2);
466    }
467
468    #[test]
469    fn test_parse_filters_empty_content() {
470        let json = r#"[
471            {"role": "user", "content": "Hello", "ts": 1704067200000},
472            {"role": "assistant", "content": "", "ts": 1704067230000}
473        ]"#;
474
475        let file = create_temp_conversation_file(json);
476        let result = parse_roo_code_task(file.path()).expect("Should parse");
477
478        let (_, messages) = result.expect("Should have session");
479        // Empty content should be filtered
480        assert_eq!(messages.len(), 1);
481    }
482
483    #[test]
484    fn test_find_tasks_returns_ok_when_dir_missing() {
485        let result = find_roo_code_tasks();
486        assert!(result.is_ok());
487    }
488
489    #[test]
490    fn test_watcher_parse_source() {
491        let watcher = RooCodeWatcher;
492        let json = r#"[{"role": "user", "content": "Test", "ts": 1704067200000}]"#;
493
494        let file = create_temp_conversation_file(json);
495        let result = watcher
496            .parse_source(file.path())
497            .expect("Should parse successfully");
498
499        assert!(!result.is_empty());
500        let (session, _) = &result[0];
501        assert_eq!(session.tool, "roo-code");
502    }
503
504    #[test]
505    fn test_parse_with_task_directory() {
506        let json = r#"[
507            {"role": "user", "content": "Hello", "ts": 1704067200000}
508        ]"#;
509
510        let temp_dir = create_temp_task_dir("550e8400-e29b-41d4-a716-446655440000", json);
511        let history_path = temp_dir
512            .path()
513            .join("550e8400-e29b-41d4-a716-446655440000")
514            .join("api_conversation_history.json");
515
516        let result = parse_roo_code_task(&history_path).expect("Should parse");
517
518        let (session, _) = result.expect("Should have session");
519        assert_eq!(
520            session.id.to_string(),
521            "550e8400-e29b-41d4-a716-446655440000"
522        );
523    }
524
525    #[test]
526    fn test_timestamps_from_messages() {
527        let json = r#"[
528            {"role": "user", "content": "First", "ts": 1704067200000},
529            {"role": "assistant", "content": "Second", "ts": 1704067260000}
530        ]"#;
531
532        let file = create_temp_conversation_file(json);
533        let result = parse_roo_code_task(file.path()).expect("Should parse");
534
535        let (session, messages) = result.expect("Should have session");
536
537        // started_at should be from first message
538        assert!(session.started_at.timestamp_millis() == 1704067200000);
539
540        // ended_at should be from last message
541        assert!(session.ended_at.is_some());
542        assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
543
544        // Message timestamps should match
545        assert!(messages[0].timestamp.timestamp_millis() == 1704067200000);
546        assert!(messages[1].timestamp.timestamp_millis() == 1704067260000);
547    }
548
549    #[test]
550    fn test_handles_unknown_role() {
551        let json = r#"[
552            {"role": "user", "content": "Hello", "ts": 1704067200000},
553            {"role": "unknown", "content": "Should be skipped", "ts": 1704067230000}
554        ]"#;
555
556        let file = create_temp_conversation_file(json);
557        let result = parse_roo_code_task(file.path()).expect("Should parse");
558
559        let (_, messages) = result.expect("Should have session");
560        assert_eq!(messages.len(), 1);
561    }
562}