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    #[test]
276    fn test_watcher_info() {
277        let watcher = ContinueDevWatcher;
278        let info = watcher.info();
279
280        assert_eq!(info.name, "continue");
281        assert_eq!(info.description, "Continue.dev VS Code extension sessions");
282    }
283
284    #[test]
285    fn test_watcher_watch_paths() {
286        let watcher = ContinueDevWatcher;
287        let paths = watcher.watch_paths();
288
289        assert!(!paths.is_empty());
290        assert!(paths[0].to_string_lossy().contains(".continue"));
291        assert!(paths[0].to_string_lossy().contains("sessions"));
292    }
293
294    #[test]
295    fn test_parse_simple_session() {
296        let json = r#"{
297            "sessionId": "550e8400-e29b-41d4-a716-446655440000",
298            "title": "Test Session",
299            "workspaceDirectory": "/home/user/project",
300            "history": [
301                {
302                    "message": {
303                        "role": "user",
304                        "content": "Hello, can you help me?"
305                    },
306                    "contextItems": []
307                },
308                {
309                    "message": {
310                        "role": "assistant",
311                        "content": "Of course! What do you need help with?"
312                    },
313                    "contextItems": []
314                }
315            ]
316        }"#;
317
318        let file = create_temp_session_file(json);
319        let result = parse_continue_session(file.path()).expect("Should parse");
320
321        let (session, messages) = result.expect("Should have session");
322        assert_eq!(session.tool, "continue");
323        assert_eq!(session.working_directory, "/home/user/project");
324        assert_eq!(messages.len(), 2);
325        assert_eq!(messages[0].role, MessageRole::User);
326        assert_eq!(messages[1].role, MessageRole::Assistant);
327    }
328
329    #[test]
330    fn test_parse_session_with_model() {
331        let json = r#"{
332            "sessionId": "test-session",
333            "chatModelTitle": "GPT-4",
334            "history": [
335                {
336                    "message": {
337                        "role": "user",
338                        "content": "Test"
339                    },
340                    "contextItems": []
341                }
342            ]
343        }"#;
344
345        let file = create_temp_session_file(json);
346        let result = parse_continue_session(file.path()).expect("Should parse");
347
348        let (session, _) = result.expect("Should have session");
349        assert_eq!(session.model, Some("GPT-4".to_string()));
350    }
351
352    #[test]
353    fn test_parse_empty_history() {
354        let json = r#"{
355            "sessionId": "test-session",
356            "history": []
357        }"#;
358
359        let file = create_temp_session_file(json);
360        let result = parse_continue_session(file.path()).expect("Should parse");
361
362        assert!(result.is_none());
363    }
364
365    #[test]
366    fn test_parse_content_with_parts() {
367        let json = r#"{
368            "sessionId": "test-session",
369            "history": [
370                {
371                    "message": {
372                        "role": "user",
373                        "content": [
374                            {"type": "text", "text": "Hello"},
375                            {"type": "text", "text": "World"}
376                        ]
377                    },
378                    "contextItems": []
379                }
380            ]
381        }"#;
382
383        let file = create_temp_session_file(json);
384        let result = parse_continue_session(file.path()).expect("Should parse");
385
386        let (_, messages) = result.expect("Should have session");
387        assert_eq!(messages.len(), 1);
388        // Content parts should be joined
389        if let MessageContent::Text(text) = &messages[0].content {
390            assert!(text.contains("Hello"));
391            assert!(text.contains("World"));
392        } else {
393            panic!("Expected text content");
394        }
395    }
396
397    #[test]
398    fn test_parse_skips_thinking_messages() {
399        let json = r#"{
400            "sessionId": "test-session",
401            "history": [
402                {
403                    "message": {
404                        "role": "user",
405                        "content": "Question"
406                    },
407                    "contextItems": []
408                },
409                {
410                    "message": {
411                        "role": "thinking",
412                        "content": "Thinking about this..."
413                    },
414                    "contextItems": []
415                },
416                {
417                    "message": {
418                        "role": "assistant",
419                        "content": "Answer"
420                    },
421                    "contextItems": []
422                }
423            ]
424        }"#;
425
426        let file = create_temp_session_file(json);
427        let result = parse_continue_session(file.path()).expect("Should parse");
428
429        let (_, messages) = result.expect("Should have session");
430        // Should only have user and assistant, not thinking
431        assert_eq!(messages.len(), 2);
432    }
433
434    #[test]
435    fn test_find_sessions_returns_ok_when_dir_missing() {
436        let result = find_continue_sessions();
437        assert!(result.is_ok());
438    }
439
440    #[test]
441    fn test_watcher_parse_source() {
442        let watcher = ContinueDevWatcher;
443        let json = r#"{
444            "sessionId": "test",
445            "history": [
446                {
447                    "message": {"role": "user", "content": "Test"},
448                    "contextItems": []
449                }
450            ]
451        }"#;
452
453        let file = create_temp_session_file(json);
454        let result = watcher
455            .parse_source(file.path())
456            .expect("Should parse successfully");
457
458        assert!(!result.is_empty());
459        let (session, _) = &result[0];
460        assert_eq!(session.tool, "continue");
461    }
462
463    #[test]
464    fn test_parse_filters_empty_content() {
465        let json = r#"{
466            "sessionId": "test-session",
467            "history": [
468                {
469                    "message": {
470                        "role": "user",
471                        "content": "Hello"
472                    },
473                    "contextItems": []
474                },
475                {
476                    "message": {
477                        "role": "assistant",
478                        "content": ""
479                    },
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 (_, messages) = result.expect("Should have session");
489        // Empty content should be filtered out
490        assert_eq!(messages.len(), 1);
491    }
492
493    #[test]
494    fn test_session_id_parsing() {
495        // Valid UUID
496        let json = r#"{
497            "sessionId": "550e8400-e29b-41d4-a716-446655440000",
498            "history": [
499                {
500                    "message": {"role": "user", "content": "Test"},
501                    "contextItems": []
502                }
503            ]
504        }"#;
505
506        let file = create_temp_session_file(json);
507        let result = parse_continue_session(file.path()).expect("Should parse");
508
509        let (session, _) = result.expect("Should have session");
510        assert_eq!(
511            session.id.to_string(),
512            "550e8400-e29b-41d4-a716-446655440000"
513        );
514    }
515
516    #[test]
517    fn test_session_id_fallback_for_invalid_uuid() {
518        // Invalid UUID should generate a new one
519        let json = r#"{
520            "sessionId": "not-a-valid-uuid",
521            "history": [
522                {
523                    "message": {"role": "user", "content": "Test"},
524                    "contextItems": []
525                }
526            ]
527        }"#;
528
529        let file = create_temp_session_file(json);
530        let result = parse_continue_session(file.path()).expect("Should parse");
531
532        let (session, _) = result.expect("Should have session");
533        // Should have a valid UUID (not nil)
534        assert!(!session.id.is_nil());
535    }
536}