lore_cli/capture/watchers/
vscode_extension.rs

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