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]
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 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 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 assert_eq!(messages.len(), 1);
491 }
492
493 #[test]
494 fn test_session_id_parsing() {
495 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 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 assert!(!session.id.is_nil());
535 }
536}