lore_cli/capture/watchers/
roo_code.rs1use 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
27pub 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
63fn roo_code_tasks_path() -> PathBuf {
70 let base = get_vscode_global_storage();
71 base.join("rooveterinaryinc.roo-cline").join("tasks")
72}
73
74fn 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
102fn 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 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#[derive(Debug, Deserialize)]
133struct RooCodeApiMessage {
134 role: String,
136
137 content: RooCodeContent,
139
140 #[serde(default)]
142 ts: Option<i64>,
143}
144
145#[derive(Debug, Deserialize)]
148#[serde(untagged)]
149enum RooCodeContent {
150 Text(String),
152 Blocks(Vec<RooCodeContentBlock>),
154}
155
156impl RooCodeContent {
157 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#[derive(Debug, Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177enum RooCodeContentBlock {
178 Text { text: String },
180 Image {
182 #[allow(dead_code)]
183 source: serde_json::Value,
184 },
185 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 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#[derive(Debug, Deserialize, Default)]
206struct RooCodeTaskMetadata {
207 #[serde(default)]
209 ts: Option<serde_json::Value>,
210
211 #[serde(default)]
213 dir: Option<String>,
214}
215
216fn 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 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 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 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 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 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 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 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 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]
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 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 assert!(session.started_at.timestamp_millis() == 1704067200000);
516
517 assert!(session.ended_at.is_some());
519 assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
520
521 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}