recall_cli/providers/
copilot.rs1use 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}