Skip to main content

recall_cli/providers/
copilot.rs

1use std::path::PathBuf;
2
3use chrono::DateTime;
4use serde::Deserialize;
5
6use crate::session::{Checkpoint, Session, truncate};
7
8use super::Provider;
9
10#[derive(Debug, Deserialize)]
11struct WorkspaceYaml {
12    id: String,
13    cwd: Option<String>,
14    summary: Option<String>,
15    created_at: Option<DateTime<chrono::Utc>>,
16    updated_at: Option<DateTime<chrono::Utc>>,
17}
18
19#[derive(Debug, Deserialize)]
20struct Event {
21    #[serde(rename = "type")]
22    event_type: String,
23    data: Option<serde_json::Value>,
24}
25
26pub struct CopilotProvider;
27
28impl CopilotProvider {
29    fn session_state_dir() -> Option<PathBuf> {
30        dirs::home_dir().map(|h| h.join(".copilot").join("session-state"))
31    }
32
33    pub fn load_session(path: &std::path::Path) -> Option<Session> {
34        let workspace_path = path.join("workspace.yaml");
35        let content = std::fs::read_to_string(&workspace_path).ok()?;
36        let ws: WorkspaceYaml = serde_yaml::from_str(&content).ok()?;
37
38        let checkpoints = Self::load_checkpoints(path);
39        let (user_messages, task_summaries) = Self::load_events(path);
40
41        let summary = ws.summary.unwrap_or_default();
42        if summary.is_empty() && user_messages.is_empty() {
43            return None;
44        }
45
46        Some(Session {
47            id: ws.id,
48            provider: String::new(),
49            summary,
50            cwd: ws.cwd.unwrap_or_default(),
51            created_at: ws.created_at.unwrap_or_default(),
52            updated_at: ws.updated_at.unwrap_or_default(),
53            checkpoints,
54            user_messages,
55            task_summaries,
56            path: path.to_path_buf(),
57        })
58    }
59
60    fn load_checkpoints(session_path: &std::path::Path) -> Vec<Checkpoint> {
61        let index_path = session_path.join("checkpoints").join("index.md");
62        let Ok(content) = std::fs::read_to_string(&index_path) else {
63            return Vec::new();
64        };
65
66        content
67            .lines()
68            .filter(|line| {
69                line.starts_with("| ") && !line.contains("---") && !line.contains("Title")
70            })
71            .filter_map(|line| {
72                let parts: Vec<&str> = line.split('|').collect();
73                if parts.len() >= 3 {
74                    let title = parts[2].trim().to_string();
75                    if !title.is_empty() {
76                        return Some(Checkpoint { title });
77                    }
78                }
79                None
80            })
81            .collect()
82    }
83
84    fn load_events(session_path: &std::path::Path) -> (Vec<String>, Vec<String>) {
85        let events_path = session_path.join("events.jsonl");
86        let Ok(content) = std::fs::read_to_string(&events_path) else {
87            return (Vec::new(), Vec::new());
88        };
89
90        let mut user_messages = Vec::new();
91        let mut task_summaries = Vec::new();
92
93        for line in content.lines() {
94            let Ok(event) = serde_json::from_str::<Event>(line) else {
95                continue;
96            };
97
98            match event.event_type.as_str() {
99                "user.message" => {
100                    if let Some(data) = &event.data {
101                        if let Some(content) = data.get("content").and_then(|v| v.as_str()) {
102                            let trimmed = content.trim();
103                            if !trimmed.is_empty() && !trimmed.starts_with('<') {
104                                user_messages.push(truncate(trimmed, 200));
105                            }
106                        }
107                    }
108                }
109                "session.task_complete" => {
110                    if let Some(data) = &event.data {
111                        if let Some(summary) = data.get("summary").and_then(|v| v.as_str()) {
112                            let first_line = summary.lines().next().unwrap_or("").trim();
113                            if !first_line.is_empty() {
114                                task_summaries.push(truncate(first_line, 200));
115                            }
116                        }
117                    }
118                }
119                _ => {}
120            }
121        }
122
123        (user_messages, task_summaries)
124    }
125}
126
127impl Provider for CopilotProvider {
128    fn name(&self) -> &str {
129        "Copilot"
130    }
131
132    fn discover_sessions(&self) -> Vec<Session> {
133        let Some(state_dir) = Self::session_state_dir() else {
134            return Vec::new();
135        };
136
137        let Ok(entries) = std::fs::read_dir(&state_dir) else {
138            return Vec::new();
139        };
140
141        entries
142            .filter_map(|e| e.ok())
143            .filter(|e| e.path().is_dir())
144            .filter_map(|e| Self::load_session(&e.path()))
145            .collect()
146    }
147}