lore_cli/capture/watchers/
continue_dev.rs

1//! Continue.dev session parser.
2//!
3//! Parses session data from Continue.dev, an open source AI coding assistant.
4//! Sessions are stored as JSON files in `~/.continue/sessions/`.
5//!
6//! Each session file contains:
7//! - Session ID and title
8//! - Working directory
9//! - Chat history with messages and context
10//! - Optional model and usage information
11
12use anyhow::{Context, Result};
13use chrono::{DateTime, Utc};
14use serde::Deserialize;
15use std::fs;
16use std::path::{Path, PathBuf};
17use uuid::Uuid;
18
19use crate::storage::models::{Message, MessageContent, MessageRole, Session};
20
21use super::{Watcher, WatcherInfo};
22
23/// Watcher for Continue.dev sessions.
24///
25/// Discovers and parses JSON session files from Continue.dev's storage.
26/// Continue.dev is an open source VS Code extension for AI-assisted coding.
27pub struct ContinueDevWatcher;
28
29impl Watcher for ContinueDevWatcher {
30    fn info(&self) -> WatcherInfo {
31        WatcherInfo {
32            name: "continue",
33            description: "Continue.dev VS Code extension sessions",
34            default_paths: vec![continue_sessions_path()],
35        }
36    }
37
38    fn is_available(&self) -> bool {
39        continue_sessions_path().exists()
40    }
41
42    fn find_sources(&self) -> Result<Vec<PathBuf>> {
43        find_continue_sessions()
44    }
45
46    fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
47        let parsed = parse_continue_session(path)?;
48        match parsed {
49            Some((session, messages)) if !messages.is_empty() => Ok(vec![(session, messages)]),
50            _ => Ok(vec![]),
51        }
52    }
53
54    fn watch_paths(&self) -> Vec<PathBuf> {
55        vec![continue_sessions_path()]
56    }
57}
58
59/// Returns the path to Continue.dev's sessions directory.
60///
61/// This is typically `~/.continue/sessions/` on all platforms.
62fn continue_sessions_path() -> PathBuf {
63    dirs::home_dir()
64        .unwrap_or_else(|| PathBuf::from("."))
65        .join(".continue")
66        .join("sessions")
67}
68
69/// Finds all Continue.dev session files.
70///
71/// Scans the sessions directory for JSON files.
72fn find_continue_sessions() -> Result<Vec<PathBuf>> {
73    let sessions_path = continue_sessions_path();
74
75    if !sessions_path.exists() {
76        return Ok(Vec::new());
77    }
78
79    let mut files = Vec::new();
80
81    for entry in fs::read_dir(&sessions_path)? {
82        let entry = entry?;
83        let path = entry.path();
84
85        if path.is_file() {
86            if let Some(ext) = path.extension() {
87                if ext == "json" {
88                    files.push(path);
89                }
90            }
91        }
92    }
93
94    Ok(files)
95}
96
97/// Raw Continue.dev session structure.
98#[derive(Debug, Deserialize)]
99#[serde(rename_all = "camelCase")]
100struct ContinueSession {
101    /// Session ID
102    session_id: String,
103
104    /// Working directory
105    #[serde(default)]
106    workspace_directory: Option<String>,
107
108    /// Chat history
109    #[serde(default)]
110    history: Vec<ContinueChatHistoryItem>,
111
112    /// Model used
113    #[serde(default)]
114    chat_model_title: Option<String>,
115}
116
117/// A chat history item in a Continue.dev session.
118#[derive(Debug, Deserialize)]
119#[serde(rename_all = "camelCase")]
120struct ContinueChatHistoryItem {
121    /// The message
122    message: ContinueChatMessage,
123}
124
125/// A message in Continue.dev chat history.
126#[derive(Debug, Deserialize)]
127#[serde(rename_all = "camelCase")]
128struct ContinueChatMessage {
129    /// Role: "user", "assistant", "system", "thinking", "tool"
130    role: String,
131
132    /// Message content (can be string or array of parts)
133    content: ContinueMessageContent,
134}
135
136/// Message content in Continue.dev format.
137#[derive(Debug, Deserialize)]
138#[serde(untagged)]
139enum ContinueMessageContent {
140    /// Simple text content
141    Text(String),
142    /// Complex content with multiple parts
143    Parts(Vec<ContinueMessagePart>),
144}
145
146impl ContinueMessageContent {
147    /// Extracts text content from the message.
148    fn to_text(&self) -> String {
149        match self {
150            Self::Text(s) => s.clone(),
151            Self::Parts(parts) => parts
152                .iter()
153                .filter_map(|p| match p {
154                    ContinueMessagePart::Text { text } => Some(text.clone()),
155                    _ => None,
156                })
157                .collect::<Vec<_>>()
158                .join("\n"),
159        }
160    }
161}
162
163/// A part of a Continue.dev message.
164#[derive(Debug, Deserialize)]
165#[serde(tag = "type", rename_all = "camelCase")]
166enum ContinueMessagePart {
167    /// Text content
168    Text { text: String },
169    /// Image URL (we only match the variant, not use the inner fields)
170    #[serde(rename = "imageUrl")]
171    #[allow(dead_code)]
172    ImageUrl {},
173}
174
175/// Parses a Continue.dev session file.
176fn parse_continue_session(path: &Path) -> Result<Option<(Session, Vec<Message>)>> {
177    let content = fs::read_to_string(path).context("Failed to read Continue session file")?;
178
179    let raw_session: ContinueSession =
180        serde_json::from_str(&content).context("Failed to parse Continue session JSON")?;
181
182    if raw_session.history.is_empty() {
183        return Ok(None);
184    }
185
186    // Parse session ID as UUID or generate new one
187    let session_id = Uuid::parse_str(&raw_session.session_id).unwrap_or_else(|_| Uuid::new_v4());
188
189    // Use file modification time for timestamps
190    let file_mtime = fs::metadata(path)
191        .ok()
192        .and_then(|m| m.modified().ok())
193        .map(DateTime::<Utc>::from);
194
195    let ended_at = file_mtime;
196    let message_count = raw_session.history.len();
197    let started_at = ended_at
198        .map(|t| t - chrono::Duration::minutes(message_count as i64 * 2))
199        .unwrap_or_else(Utc::now);
200
201    let session = Session {
202        id: session_id,
203        tool: "continue".to_string(),
204        tool_version: None,
205        started_at,
206        ended_at,
207        model: raw_session.chat_model_title,
208        working_directory: raw_session
209            .workspace_directory
210            .unwrap_or_else(|| ".".to_string()),
211        git_branch: None,
212        source_path: Some(path.to_string_lossy().to_string()),
213        message_count: message_count as i32,
214        machine_id: crate::storage::get_machine_id(),
215    };
216
217    // Convert messages
218    let mut messages = Vec::new();
219    let time_per_message = chrono::Duration::seconds(30);
220    let mut current_time = started_at;
221
222    for (idx, item) in raw_session.history.iter().enumerate() {
223        let role = match item.message.role.as_str() {
224            "user" => MessageRole::User,
225            "assistant" => MessageRole::Assistant,
226            "system" => MessageRole::System,
227            "thinking" => continue, // Skip thinking messages
228            "tool" => continue,     // Skip tool result messages
229            _ => continue,
230        };
231
232        let content = item.message.content.to_text();
233        if content.trim().is_empty() {
234            continue;
235        }
236
237        messages.push(Message {
238            id: Uuid::new_v4(),
239            session_id,
240            parent_id: None,
241            index: idx as i32,
242            timestamp: current_time,
243            role,
244            content: MessageContent::Text(content),
245            model: None,
246            git_branch: None,
247            cwd: Some(session.working_directory.clone()),
248        });
249
250        current_time += time_per_message;
251    }
252
253    if messages.is_empty() {
254        return Ok(None);
255    }
256
257    Ok(Some((session, messages)))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use std::io::Write;
264    use tempfile::NamedTempFile;
265
266    /// Creates a temporary Continue session file with given JSON content.
267    fn create_temp_session_file(json: &str) -> NamedTempFile {
268        let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
269        file.write_all(json.as_bytes())
270            .expect("Failed to write content");
271        file.flush().expect("Failed to flush");
272        file
273    }
274
275    // Note: Common watcher trait tests (info, watch_paths, find_sources) are in
276    // src/capture/watchers/test_common.rs to avoid duplication across all watchers.
277    // Only tool-specific parsing tests remain here.
278
279    #[test]
280    fn test_parse_simple_session() {
281        let json = r#"{
282            "sessionId": "550e8400-e29b-41d4-a716-446655440000",
283            "title": "Test Session",
284            "workspaceDirectory": "/home/user/project",
285            "history": [
286                {
287                    "message": {
288                        "role": "user",
289                        "content": "Hello, can you help me?"
290                    },
291                    "contextItems": []
292                },
293                {
294                    "message": {
295                        "role": "assistant",
296                        "content": "Of course! What do you need help with?"
297                    },
298                    "contextItems": []
299                }
300            ]
301        }"#;
302
303        let file = create_temp_session_file(json);
304        let result = parse_continue_session(file.path()).expect("Should parse");
305
306        let (session, messages) = result.expect("Should have session");
307        assert_eq!(session.tool, "continue");
308        assert_eq!(session.working_directory, "/home/user/project");
309        assert_eq!(messages.len(), 2);
310        assert_eq!(messages[0].role, MessageRole::User);
311        assert_eq!(messages[1].role, MessageRole::Assistant);
312    }
313
314    #[test]
315    fn test_parse_session_with_model() {
316        let json = r#"{
317            "sessionId": "test-session",
318            "chatModelTitle": "GPT-4",
319            "history": [
320                {
321                    "message": {
322                        "role": "user",
323                        "content": "Test"
324                    },
325                    "contextItems": []
326                }
327            ]
328        }"#;
329
330        let file = create_temp_session_file(json);
331        let result = parse_continue_session(file.path()).expect("Should parse");
332
333        let (session, _) = result.expect("Should have session");
334        assert_eq!(session.model, Some("GPT-4".to_string()));
335    }
336
337    #[test]
338    fn test_parse_empty_history() {
339        let json = r#"{
340            "sessionId": "test-session",
341            "history": []
342        }"#;
343
344        let file = create_temp_session_file(json);
345        let result = parse_continue_session(file.path()).expect("Should parse");
346
347        assert!(result.is_none());
348    }
349
350    #[test]
351    fn test_parse_content_with_parts() {
352        let json = r#"{
353            "sessionId": "test-session",
354            "history": [
355                {
356                    "message": {
357                        "role": "user",
358                        "content": [
359                            {"type": "text", "text": "Hello"},
360                            {"type": "text", "text": "World"}
361                        ]
362                    },
363                    "contextItems": []
364                }
365            ]
366        }"#;
367
368        let file = create_temp_session_file(json);
369        let result = parse_continue_session(file.path()).expect("Should parse");
370
371        let (_, messages) = result.expect("Should have session");
372        assert_eq!(messages.len(), 1);
373        // Content parts should be joined
374        if let MessageContent::Text(text) = &messages[0].content {
375            assert!(text.contains("Hello"));
376            assert!(text.contains("World"));
377        } else {
378            panic!("Expected text content");
379        }
380    }
381
382    #[test]
383    fn test_parse_skips_thinking_messages() {
384        let json = r#"{
385            "sessionId": "test-session",
386            "history": [
387                {
388                    "message": {
389                        "role": "user",
390                        "content": "Question"
391                    },
392                    "contextItems": []
393                },
394                {
395                    "message": {
396                        "role": "thinking",
397                        "content": "Thinking about this..."
398                    },
399                    "contextItems": []
400                },
401                {
402                    "message": {
403                        "role": "assistant",
404                        "content": "Answer"
405                    },
406                    "contextItems": []
407                }
408            ]
409        }"#;
410
411        let file = create_temp_session_file(json);
412        let result = parse_continue_session(file.path()).expect("Should parse");
413
414        let (_, messages) = result.expect("Should have session");
415        // Should only have user and assistant, not thinking
416        assert_eq!(messages.len(), 2);
417    }
418
419    #[test]
420    fn test_watcher_parse_source() {
421        let watcher = ContinueDevWatcher;
422        let json = r#"{
423            "sessionId": "test",
424            "history": [
425                {
426                    "message": {"role": "user", "content": "Test"},
427                    "contextItems": []
428                }
429            ]
430        }"#;
431
432        let file = create_temp_session_file(json);
433        let result = watcher
434            .parse_source(file.path())
435            .expect("Should parse successfully");
436
437        assert!(!result.is_empty());
438        let (session, _) = &result[0];
439        assert_eq!(session.tool, "continue");
440    }
441
442    #[test]
443    fn test_parse_filters_empty_content() {
444        let json = r#"{
445            "sessionId": "test-session",
446            "history": [
447                {
448                    "message": {
449                        "role": "user",
450                        "content": "Hello"
451                    },
452                    "contextItems": []
453                },
454                {
455                    "message": {
456                        "role": "assistant",
457                        "content": ""
458                    },
459                    "contextItems": []
460                }
461            ]
462        }"#;
463
464        let file = create_temp_session_file(json);
465        let result = parse_continue_session(file.path()).expect("Should parse");
466
467        let (_, messages) = result.expect("Should have session");
468        // Empty content should be filtered out
469        assert_eq!(messages.len(), 1);
470    }
471
472    #[test]
473    fn test_session_id_parsing() {
474        // Valid UUID
475        let json = r#"{
476            "sessionId": "550e8400-e29b-41d4-a716-446655440000",
477            "history": [
478                {
479                    "message": {"role": "user", "content": "Test"},
480                    "contextItems": []
481                }
482            ]
483        }"#;
484
485        let file = create_temp_session_file(json);
486        let result = parse_continue_session(file.path()).expect("Should parse");
487
488        let (session, _) = result.expect("Should have session");
489        assert_eq!(
490            session.id.to_string(),
491            "550e8400-e29b-41d4-a716-446655440000"
492        );
493    }
494
495    #[test]
496    fn test_session_id_fallback_for_invalid_uuid() {
497        // Invalid UUID should generate a new one
498        let json = r#"{
499            "sessionId": "not-a-valid-uuid",
500            "history": [
501                {
502                    "message": {"role": "user", "content": "Test"},
503                    "contextItems": []
504                }
505            ]
506        }"#;
507
508        let file = create_temp_session_file(json);
509        let result = parse_continue_session(file.path()).expect("Should parse");
510
511        let (session, _) = result.expect("Should have session");
512        // Should have a valid UUID (not nil)
513        assert!(!session.id.is_nil());
514    }
515}