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    };
215
216    // Convert messages
217    let mut messages = Vec::new();
218    let time_per_message = chrono::Duration::seconds(30);
219    let mut current_time = started_at;
220
221    for (idx, item) in raw_session.history.iter().enumerate() {
222        let role = match item.message.role.as_str() {
223            "user" => MessageRole::User,
224            "assistant" => MessageRole::Assistant,
225            "system" => MessageRole::System,
226            "thinking" => continue, // Skip thinking messages
227            "tool" => continue,     // Skip tool result messages
228            _ => continue,
229        };
230
231        let content = item.message.content.to_text();
232        if content.trim().is_empty() {
233            continue;
234        }
235
236        messages.push(Message {
237            id: Uuid::new_v4(),
238            session_id,
239            parent_id: None,
240            index: idx as i32,
241            timestamp: current_time,
242            role,
243            content: MessageContent::Text(content),
244            model: None,
245            git_branch: None,
246            cwd: Some(session.working_directory.clone()),
247        });
248
249        current_time += time_per_message;
250    }
251
252    if messages.is_empty() {
253        return Ok(None);
254    }
255
256    Ok(Some((session, messages)))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use std::io::Write;
263    use tempfile::NamedTempFile;
264
265    /// Creates a temporary Continue session file with given JSON content.
266    fn create_temp_session_file(json: &str) -> NamedTempFile {
267        let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
268        file.write_all(json.as_bytes())
269            .expect("Failed to write content");
270        file.flush().expect("Failed to flush");
271        file
272    }
273
274    #[test]
275    fn test_watcher_info() {
276        let watcher = ContinueDevWatcher;
277        let info = watcher.info();
278
279        assert_eq!(info.name, "continue");
280        assert_eq!(info.description, "Continue.dev VS Code extension sessions");
281    }
282
283    #[test]
284    fn test_watcher_watch_paths() {
285        let watcher = ContinueDevWatcher;
286        let paths = watcher.watch_paths();
287
288        assert!(!paths.is_empty());
289        assert!(paths[0].to_string_lossy().contains(".continue"));
290        assert!(paths[0].to_string_lossy().contains("sessions"));
291    }
292
293    #[test]
294    fn test_parse_simple_session() {
295        let json = r#"{
296            "sessionId": "550e8400-e29b-41d4-a716-446655440000",
297            "title": "Test Session",
298            "workspaceDirectory": "/home/user/project",
299            "history": [
300                {
301                    "message": {
302                        "role": "user",
303                        "content": "Hello, can you help me?"
304                    },
305                    "contextItems": []
306                },
307                {
308                    "message": {
309                        "role": "assistant",
310                        "content": "Of course! What do you need help with?"
311                    },
312                    "contextItems": []
313                }
314            ]
315        }"#;
316
317        let file = create_temp_session_file(json);
318        let result = parse_continue_session(file.path()).expect("Should parse");
319
320        let (session, messages) = result.expect("Should have session");
321        assert_eq!(session.tool, "continue");
322        assert_eq!(session.working_directory, "/home/user/project");
323        assert_eq!(messages.len(), 2);
324        assert_eq!(messages[0].role, MessageRole::User);
325        assert_eq!(messages[1].role, MessageRole::Assistant);
326    }
327
328    #[test]
329    fn test_parse_session_with_model() {
330        let json = r#"{
331            "sessionId": "test-session",
332            "chatModelTitle": "GPT-4",
333            "history": [
334                {
335                    "message": {
336                        "role": "user",
337                        "content": "Test"
338                    },
339                    "contextItems": []
340                }
341            ]
342        }"#;
343
344        let file = create_temp_session_file(json);
345        let result = parse_continue_session(file.path()).expect("Should parse");
346
347        let (session, _) = result.expect("Should have session");
348        assert_eq!(session.model, Some("GPT-4".to_string()));
349    }
350
351    #[test]
352    fn test_parse_empty_history() {
353        let json = r#"{
354            "sessionId": "test-session",
355            "history": []
356        }"#;
357
358        let file = create_temp_session_file(json);
359        let result = parse_continue_session(file.path()).expect("Should parse");
360
361        assert!(result.is_none());
362    }
363
364    #[test]
365    fn test_parse_content_with_parts() {
366        let json = r#"{
367            "sessionId": "test-session",
368            "history": [
369                {
370                    "message": {
371                        "role": "user",
372                        "content": [
373                            {"type": "text", "text": "Hello"},
374                            {"type": "text", "text": "World"}
375                        ]
376                    },
377                    "contextItems": []
378                }
379            ]
380        }"#;
381
382        let file = create_temp_session_file(json);
383        let result = parse_continue_session(file.path()).expect("Should parse");
384
385        let (_, messages) = result.expect("Should have session");
386        assert_eq!(messages.len(), 1);
387        // Content parts should be joined
388        if let MessageContent::Text(text) = &messages[0].content {
389            assert!(text.contains("Hello"));
390            assert!(text.contains("World"));
391        } else {
392            panic!("Expected text content");
393        }
394    }
395
396    #[test]
397    fn test_parse_skips_thinking_messages() {
398        let json = r#"{
399            "sessionId": "test-session",
400            "history": [
401                {
402                    "message": {
403                        "role": "user",
404                        "content": "Question"
405                    },
406                    "contextItems": []
407                },
408                {
409                    "message": {
410                        "role": "thinking",
411                        "content": "Thinking about this..."
412                    },
413                    "contextItems": []
414                },
415                {
416                    "message": {
417                        "role": "assistant",
418                        "content": "Answer"
419                    },
420                    "contextItems": []
421                }
422            ]
423        }"#;
424
425        let file = create_temp_session_file(json);
426        let result = parse_continue_session(file.path()).expect("Should parse");
427
428        let (_, messages) = result.expect("Should have session");
429        // Should only have user and assistant, not thinking
430        assert_eq!(messages.len(), 2);
431    }
432
433    #[test]
434    fn test_find_sessions_returns_ok_when_dir_missing() {
435        let result = find_continue_sessions();
436        assert!(result.is_ok());
437    }
438
439    #[test]
440    fn test_watcher_parse_source() {
441        let watcher = ContinueDevWatcher;
442        let json = r#"{
443            "sessionId": "test",
444            "history": [
445                {
446                    "message": {"role": "user", "content": "Test"},
447                    "contextItems": []
448                }
449            ]
450        }"#;
451
452        let file = create_temp_session_file(json);
453        let result = watcher
454            .parse_source(file.path())
455            .expect("Should parse successfully");
456
457        assert!(!result.is_empty());
458        let (session, _) = &result[0];
459        assert_eq!(session.tool, "continue");
460    }
461
462    #[test]
463    fn test_parse_filters_empty_content() {
464        let json = r#"{
465            "sessionId": "test-session",
466            "history": [
467                {
468                    "message": {
469                        "role": "user",
470                        "content": "Hello"
471                    },
472                    "contextItems": []
473                },
474                {
475                    "message": {
476                        "role": "assistant",
477                        "content": ""
478                    },
479                    "contextItems": []
480                }
481            ]
482        }"#;
483
484        let file = create_temp_session_file(json);
485        let result = parse_continue_session(file.path()).expect("Should parse");
486
487        let (_, messages) = result.expect("Should have session");
488        // Empty content should be filtered out
489        assert_eq!(messages.len(), 1);
490    }
491
492    #[test]
493    fn test_session_id_parsing() {
494        // Valid UUID
495        let json = r#"{
496            "sessionId": "550e8400-e29b-41d4-a716-446655440000",
497            "history": [
498                {
499                    "message": {"role": "user", "content": "Test"},
500                    "contextItems": []
501                }
502            ]
503        }"#;
504
505        let file = create_temp_session_file(json);
506        let result = parse_continue_session(file.path()).expect("Should parse");
507
508        let (session, _) = result.expect("Should have session");
509        assert_eq!(
510            session.id.to_string(),
511            "550e8400-e29b-41d4-a716-446655440000"
512        );
513    }
514
515    #[test]
516    fn test_session_id_fallback_for_invalid_uuid() {
517        // Invalid UUID should generate a new one
518        let json = r#"{
519            "sessionId": "not-a-valid-uuid",
520            "history": [
521                {
522                    "message": {"role": "user", "content": "Test"},
523                    "contextItems": []
524                }
525            ]
526        }"#;
527
528        let file = create_temp_session_file(json);
529        let result = parse_continue_session(file.path()).expect("Should parse");
530
531        let (session, _) = result.expect("Should have session");
532        // Should have a valid UUID (not nil)
533        assert!(!session.id.is_nil());
534    }
535}