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    // Note: Common watcher trait tests (info, watch_paths, find_sources) are in
370    // src/capture/watchers/test_common.rs to avoid duplication across all watchers.
371    // Only tool-specific parsing tests remain here.
372
373    #[test]
374    fn test_parse_simple_conversation() {
375        let json = r#"[
376            {"role": "user", "content": "Hello, can you help me?", "ts": 1704067200000},
377            {"role": "assistant", "content": "Of course! What do you need?", "ts": 1704067230000}
378        ]"#;
379
380        let file = create_temp_conversation_file(json);
381        let result = parse_roo_code_task(file.path()).expect("Should parse");
382
383        let (session, messages) = result.expect("Should have session");
384        assert_eq!(session.tool, "roo-code");
385        assert_eq!(messages.len(), 2);
386        assert_eq!(messages[0].role, MessageRole::User);
387        assert_eq!(messages[1].role, MessageRole::Assistant);
388    }
389
390    #[test]
391    fn test_parse_with_content_blocks() {
392        let json = r#"[
393            {
394                "role": "user",
395                "content": [
396                    {"type": "text", "text": "Hello"},
397                    {"type": "text", "text": "World"}
398                ],
399                "ts": 1704067200000
400            }
401        ]"#;
402
403        let file = create_temp_conversation_file(json);
404        let result = parse_roo_code_task(file.path()).expect("Should parse");
405
406        let (_, messages) = result.expect("Should have session");
407        assert_eq!(messages.len(), 1);
408        if let MessageContent::Text(text) = &messages[0].content {
409            assert!(text.contains("Hello"));
410            assert!(text.contains("World"));
411        } else {
412            panic!("Expected text content");
413        }
414    }
415
416    #[test]
417    fn test_parse_empty_conversation() {
418        let json = "[]";
419
420        let file = create_temp_conversation_file(json);
421        let result = parse_roo_code_task(file.path()).expect("Should parse");
422
423        assert!(result.is_none());
424    }
425
426    #[test]
427    fn test_parse_with_tool_blocks() {
428        let json = r#"[
429            {
430                "role": "user",
431                "content": "Create a file",
432                "ts": 1704067200000
433            },
434            {
435                "role": "assistant",
436                "content": [
437                    {"type": "text", "text": "I'll create that file."},
438                    {"type": "tool_use", "id": "tool_1", "name": "write_file", "input": {"path": "test.txt"}}
439                ],
440                "ts": 1704067230000
441            }
442        ]"#;
443
444        let file = create_temp_conversation_file(json);
445        let result = parse_roo_code_task(file.path()).expect("Should parse");
446
447        let (_, messages) = result.expect("Should have session");
448        assert_eq!(messages.len(), 2);
449    }
450
451    #[test]
452    fn test_parse_filters_empty_content() {
453        let json = r#"[
454            {"role": "user", "content": "Hello", "ts": 1704067200000},
455            {"role": "assistant", "content": "", "ts": 1704067230000}
456        ]"#;
457
458        let file = create_temp_conversation_file(json);
459        let result = parse_roo_code_task(file.path()).expect("Should parse");
460
461        let (_, messages) = result.expect("Should have session");
462        // Empty content should be filtered
463        assert_eq!(messages.len(), 1);
464    }
465
466    #[test]
467    fn test_watcher_parse_source() {
468        let watcher = RooCodeWatcher;
469        let json = r#"[{"role": "user", "content": "Test", "ts": 1704067200000}]"#;
470
471        let file = create_temp_conversation_file(json);
472        let result = watcher
473            .parse_source(file.path())
474            .expect("Should parse successfully");
475
476        assert!(!result.is_empty());
477        let (session, _) = &result[0];
478        assert_eq!(session.tool, "roo-code");
479    }
480
481    #[test]
482    fn test_parse_with_task_directory() {
483        let json = r#"[
484            {"role": "user", "content": "Hello", "ts": 1704067200000}
485        ]"#;
486
487        let temp_dir = create_temp_task_dir("550e8400-e29b-41d4-a716-446655440000", json);
488        let history_path = temp_dir
489            .path()
490            .join("550e8400-e29b-41d4-a716-446655440000")
491            .join("api_conversation_history.json");
492
493        let result = parse_roo_code_task(&history_path).expect("Should parse");
494
495        let (session, _) = result.expect("Should have session");
496        assert_eq!(
497            session.id.to_string(),
498            "550e8400-e29b-41d4-a716-446655440000"
499        );
500    }
501
502    #[test]
503    fn test_timestamps_from_messages() {
504        let json = r#"[
505            {"role": "user", "content": "First", "ts": 1704067200000},
506            {"role": "assistant", "content": "Second", "ts": 1704067260000}
507        ]"#;
508
509        let file = create_temp_conversation_file(json);
510        let result = parse_roo_code_task(file.path()).expect("Should parse");
511
512        let (session, messages) = result.expect("Should have session");
513
514        // started_at should be from first message
515        assert!(session.started_at.timestamp_millis() == 1704067200000);
516
517        // ended_at should be from last message
518        assert!(session.ended_at.is_some());
519        assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
520
521        // Message timestamps should match
522        assert!(messages[0].timestamp.timestamp_millis() == 1704067200000);
523        assert!(messages[1].timestamp.timestamp_millis() == 1704067260000);
524    }
525
526    #[test]
527    fn test_handles_unknown_role() {
528        let json = r#"[
529            {"role": "user", "content": "Hello", "ts": 1704067200000},
530            {"role": "unknown", "content": "Should be skipped", "ts": 1704067230000}
531        ]"#;
532
533        let file = create_temp_conversation_file(json);
534        let result = parse_roo_code_task(file.path()).expect("Should parse");
535
536        let (_, messages) = result.expect("Should have session");
537        assert_eq!(messages.len(), 1);
538    }
539}