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 };
215
216 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, "tool" => continue, _ => 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 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 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 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 assert_eq!(messages.len(), 1);
490 }
491
492 #[test]
493 fn test_session_id_parsing() {
494 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 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 assert!(!session.id.is_nil());
534 }
535}