terraphim_session_analyzer/connectors/
mod.rs

1//! Session Connectors for multi-agent support
2//!
3//! This module provides connectors for various AI coding assistants:
4//! - Claude Code (JSONL) - via existing parser
5//! - Cursor (SQLite) - via cursor module
6//! - Codex (JSONL) - OpenAI Codex CLI
7//! - Aider (Markdown) - Aider chat history
8//! - OpenCode (JSONL) - OpenCode AI assistant
9//!
10//! Enable with `--features connectors`
11
12use anyhow::Result;
13use std::path::PathBuf;
14
15#[cfg(feature = "connectors")]
16pub mod aider;
17#[cfg(feature = "connectors")]
18pub mod codex;
19#[cfg(feature = "connectors")]
20pub mod cursor;
21#[cfg(feature = "connectors")]
22pub mod opencode;
23
24/// Status of a connector's detection
25#[derive(Debug, Clone)]
26pub enum ConnectorStatus {
27    /// Connector found with estimated session count
28    Available {
29        path: PathBuf,
30        sessions_estimate: Option<usize>,
31    },
32    /// Connector's data directory not found
33    NotFound,
34    /// Connector found but has errors
35    Error(String),
36}
37
38/// Trait for session connectors
39pub trait SessionConnector: Send + Sync {
40    /// Unique identifier for this connector
41    fn source_id(&self) -> &str;
42
43    /// Human-readable name
44    fn display_name(&self) -> &str;
45
46    /// Check if this connector's data source is available
47    fn detect(&self) -> ConnectorStatus;
48
49    /// Get the default data path for this connector
50    fn default_path(&self) -> Option<PathBuf>;
51
52    /// Import sessions from this source
53    fn import(&self, options: &ImportOptions) -> Result<Vec<NormalizedSession>>;
54}
55
56/// Options for importing sessions
57#[derive(Debug, Clone, Default)]
58pub struct ImportOptions {
59    /// Override the default path
60    pub path: Option<PathBuf>,
61    /// Only import sessions after this timestamp
62    pub since: Option<jiff::Timestamp>,
63    /// Only import sessions before this timestamp
64    pub until: Option<jiff::Timestamp>,
65    /// Maximum sessions to import
66    pub limit: Option<usize>,
67    /// Skip sessions already imported (for incremental updates)
68    pub incremental: bool,
69}
70
71/// Normalized session from any connector
72#[derive(Debug, Clone)]
73pub struct NormalizedSession {
74    /// Connector source ID
75    pub source: String,
76    /// Original session ID from the source
77    pub external_id: String,
78    /// Session title or description
79    pub title: Option<String>,
80    /// Path to source file/database
81    pub source_path: PathBuf,
82    /// Session start time
83    pub started_at: Option<jiff::Timestamp>,
84    /// Session end time
85    pub ended_at: Option<jiff::Timestamp>,
86    /// Normalized messages
87    pub messages: Vec<NormalizedMessage>,
88    /// Additional metadata
89    pub metadata: serde_json::Value,
90}
91
92/// Normalized message from any connector
93#[derive(Debug, Clone)]
94pub struct NormalizedMessage {
95    /// Message index in session
96    pub idx: usize,
97    /// Role: user, assistant, or system
98    pub role: String,
99    /// Author identifier (model name, user, etc.)
100    pub author: Option<String>,
101    /// Message content
102    pub content: String,
103    /// Message timestamp
104    pub created_at: Option<jiff::Timestamp>,
105    /// Additional fields
106    pub extra: serde_json::Value,
107}
108
109/// Registry of available connectors
110pub struct ConnectorRegistry {
111    connectors: Vec<Box<dyn SessionConnector>>,
112}
113
114impl ConnectorRegistry {
115    /// Create a new registry with all available connectors
116    #[must_use]
117    pub fn new() -> Self {
118        // Add Claude Code connector (always available via parser)
119        #[allow(unused_mut)] // mut needed when connectors feature is enabled
120        let mut connectors: Vec<Box<dyn SessionConnector>> = vec![Box::new(ClaudeCodeConnector)];
121
122        // Add additional connectors if feature enabled
123        #[cfg(feature = "connectors")]
124        {
125            connectors.push(Box::new(cursor::CursorConnector));
126            connectors.push(Box::new(codex::CodexConnector));
127            connectors.push(Box::new(aider::AiderConnector));
128            connectors.push(Box::new(opencode::OpenCodeConnector));
129        }
130
131        Self { connectors }
132    }
133
134    /// Get all available connectors
135    #[must_use]
136    pub fn connectors(&self) -> &[Box<dyn SessionConnector>] {
137        &self.connectors
138    }
139
140    /// Find a connector by source ID
141    #[must_use]
142    pub fn get(&self, source_id: &str) -> Option<&dyn SessionConnector> {
143        self.connectors
144            .iter()
145            .find(|c| c.source_id() == source_id)
146            .map(|c| c.as_ref())
147    }
148
149    /// Detect all available connectors
150    pub fn detect_all(&self) -> Vec<(&str, ConnectorStatus)> {
151        self.connectors
152            .iter()
153            .map(|c| (c.source_id(), c.detect()))
154            .collect()
155    }
156}
157
158impl Default for ConnectorRegistry {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164/// Claude Code connector (wraps existing parser)
165#[derive(Debug, Default)]
166pub struct ClaudeCodeConnector;
167
168impl SessionConnector for ClaudeCodeConnector {
169    fn source_id(&self) -> &str {
170        "claude-code"
171    }
172
173    fn display_name(&self) -> &str {
174        "Claude Code"
175    }
176
177    fn detect(&self) -> ConnectorStatus {
178        if let Some(path) = self.default_path() {
179            if path.exists() {
180                // Count JSONL files
181                let count = walkdir::WalkDir::new(&path)
182                    .max_depth(3)
183                    .into_iter()
184                    .filter_map(|e| e.ok())
185                    .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
186                    .count();
187                ConnectorStatus::Available {
188                    path,
189                    sessions_estimate: Some(count),
190                }
191            } else {
192                ConnectorStatus::NotFound
193            }
194        } else {
195            ConnectorStatus::NotFound
196        }
197    }
198
199    fn default_path(&self) -> Option<PathBuf> {
200        home::home_dir().map(|h| h.join(".claude").join("projects"))
201    }
202
203    fn import(&self, options: &ImportOptions) -> Result<Vec<NormalizedSession>> {
204        use crate::parser::SessionParser;
205
206        let path = options
207            .path
208            .clone()
209            .or_else(|| self.default_path())
210            .ok_or_else(|| anyhow::anyhow!("No path specified and default not found"))?;
211
212        let parsers = SessionParser::from_directory(&path)?;
213        let mut sessions = Vec::new();
214
215        for parser in parsers {
216            // Convert SessionParser to NormalizedSession
217            let entries = parser.entries();
218            if entries.is_empty() {
219                continue;
220            }
221
222            let first = entries.first().unwrap();
223            let last = entries.last().unwrap();
224
225            let messages: Vec<NormalizedMessage> = entries
226                .iter()
227                .enumerate()
228                .map(|(idx, entry)| {
229                    let (role, content) = match &entry.message {
230                        crate::models::Message::User { content, .. } => {
231                            ("user".to_string(), content.clone())
232                        }
233                        crate::models::Message::Assistant { content, .. } => {
234                            let text = content
235                                .iter()
236                                .filter_map(|block| match block {
237                                    crate::models::ContentBlock::Text { text } => {
238                                        Some(text.clone())
239                                    }
240                                    _ => None,
241                                })
242                                .collect::<Vec<_>>()
243                                .join("\n");
244                            ("assistant".to_string(), text)
245                        }
246                        crate::models::Message::ToolResult { content, .. } => {
247                            let text = content
248                                .iter()
249                                .map(|c| c.content.clone())
250                                .collect::<Vec<_>>()
251                                .join("\n");
252                            ("tool".to_string(), text)
253                        }
254                    };
255
256                    NormalizedMessage {
257                        idx,
258                        role,
259                        author: None,
260                        content,
261                        created_at: crate::models::parse_timestamp(&entry.timestamp).ok(),
262                        extra: serde_json::Value::Null,
263                    }
264                })
265                .collect();
266
267            sessions.push(NormalizedSession {
268                source: "claude-code".to_string(),
269                external_id: first.session_id.clone(),
270                title: first.cwd.clone(),
271                source_path: path.clone(),
272                started_at: crate::models::parse_timestamp(&first.timestamp).ok(),
273                ended_at: crate::models::parse_timestamp(&last.timestamp).ok(),
274                messages,
275                metadata: serde_json::json!({
276                    "project_path": first.cwd,
277                }),
278            });
279        }
280
281        Ok(sessions)
282    }
283}