lore_cli/capture/watchers/
continue_dev.rs1use 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
23pub 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
59fn continue_sessions_path() -> PathBuf {
63 dirs::home_dir()
64 .unwrap_or_else(|| PathBuf::from("."))
65 .join(".continue")
66 .join("sessions")
67}
68
69fn 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#[derive(Debug, Deserialize)]
99#[serde(rename_all = "camelCase")]
100struct ContinueSession {
101 session_id: String,
103
104 #[serde(default)]
106 workspace_directory: Option<String>,
107
108 #[serde(default)]
110 history: Vec<ContinueChatHistoryItem>,
111
112 #[serde(default)]
114 chat_model_title: Option<String>,
115}
116
117#[derive(Debug, Deserialize)]
119#[serde(rename_all = "camelCase")]
120struct ContinueChatHistoryItem {
121 message: ContinueChatMessage,
123}
124
125#[derive(Debug, Deserialize)]
127#[serde(rename_all = "camelCase")]
128struct ContinueChatMessage {
129 role: String,
131
132 content: ContinueMessageContent,
134}
135
136#[derive(Debug, Deserialize)]
138#[serde(untagged)]
139enum ContinueMessageContent {
140 Text(String),
142 Parts(Vec<ContinueMessagePart>),
144}
145
146impl ContinueMessageContent {
147 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#[derive(Debug, Deserialize)]
165#[serde(tag = "type", rename_all = "camelCase")]
166enum ContinueMessagePart {
167 Text { text: String },
169 #[serde(rename = "imageUrl")]
171 #[allow(dead_code)]
172 ImageUrl {},
173}
174
175fn 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 let session_id = Uuid::parse_str(&raw_session.session_id).unwrap_or_else(|_| Uuid::new_v4());
188
189 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 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, "tool" => continue, _ => 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 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]
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 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 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 assert_eq!(messages.len(), 1);
470 }
471
472 #[test]
473 fn test_session_id_parsing() {
474 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 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 assert!(!session.id.is_nil());
514 }
515}