Skip to main content

plexus_substrate/activations/claudecode/
sessions.rs

1/// Session File Management Module
2///
3/// Provides CRUD operations for Claude Code session files stored as JSONL
4/// in ~/.claude/projects/<project>/<session-id>.jsonl
5///
6/// Also provides integration with arbor for importing/exporting sessions.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::path::PathBuf;
11use tokio::fs;
12use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
13
14use crate::activations::arbor::{ArborStorage, TreeId};
15use crate::activations::claudecode::types::NodeEvent;
16
17// ═══════════════════════════════════════════════════════════════════════════
18// SESSION FILE TYPES
19// ═══════════════════════════════════════════════════════════════════════════
20
21/// Event in a session JSONL file
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "kebab-case")]
24pub enum SessionEvent {
25    /// User message
26    #[serde(rename = "user")]
27    User {
28        #[serde(flatten)]
29        data: UserEvent,
30    },
31    /// Assistant message
32    #[serde(rename = "assistant")]
33    Assistant {
34        #[serde(flatten)]
35        data: AssistantEvent,
36    },
37    /// System message
38    #[serde(rename = "system")]
39    System {
40        #[serde(flatten)]
41        data: SystemEvent,
42    },
43    /// File history snapshot
44    #[serde(rename = "file-history-snapshot")]
45    FileHistorySnapshot {
46        timestamp: Option<String>,
47    },
48    /// Queue operation
49    #[serde(rename = "queue-operation")]
50    QueueOperation {
51        operation: String,
52        timestamp: String,
53        #[serde(rename = "sessionId")]
54        session_id: String,
55    },
56    /// Unknown event type
57    #[serde(other)]
58    Unknown,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct UserEvent {
63    pub uuid: String,
64    #[serde(rename = "parentUuid")]
65    pub parent_uuid: Option<String>,
66    #[serde(rename = "sessionId")]
67    pub session_id: String,
68    pub timestamp: String,
69    pub cwd: String,
70    pub message: UserMessage,
71    #[serde(rename = "isSidechain")]
72    pub is_sidechain: Option<bool>,
73    #[serde(rename = "gitBranch")]
74    pub git_branch: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct UserMessage {
79    pub role: String,
80    pub content: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct AssistantEvent {
85    pub uuid: String,
86    #[serde(rename = "parentUuid")]
87    pub parent_uuid: Option<String>,
88    #[serde(rename = "sessionId")]
89    pub session_id: String,
90    pub timestamp: String,
91    pub cwd: Option<String>,
92    pub message: AssistantMessage,
93    #[serde(rename = "requestId")]
94    pub request_id: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(untagged)]
99pub enum AssistantMessage {
100    /// Full message with content array
101    Full {
102        role: String,
103        content: Vec<ContentBlock>,
104        model: Option<String>,
105        id: Option<String>,
106        #[serde(rename = "stop_reason")]
107        stop_reason: Option<String>,
108        usage: Option<Value>,
109    },
110    /// Simple string (for streaming events)
111    Simple(String),
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(tag = "type", rename_all = "snake_case")]
116pub enum ContentBlock {
117    Text { text: String },
118    ToolUse {
119        id: String,
120        name: String,
121        input: Value,
122    },
123    ToolResult {
124        #[serde(rename = "tool_use_id")]
125        tool_use_id: String,
126        content: String,
127        #[serde(rename = "is_error")]
128        is_error: Option<bool>,
129    },
130    Thinking {
131        thinking: String,
132        signature: Option<String>,
133    },
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SystemEvent {
138    pub uuid: String,
139    #[serde(rename = "sessionId")]
140    pub session_id: String,
141    pub timestamp: String,
142    pub message: String,
143}
144
145// ═══════════════════════════════════════════════════════════════════════════
146// SESSION FILE OPERATIONS
147// ═══════════════════════════════════════════════════════════════════════════
148
149/// Get the base directory for Claude sessions
150pub fn get_sessions_base_dir() -> PathBuf {
151    dirs::home_dir()
152        .expect("Could not determine home directory")
153        .join(".claude")
154        .join("projects")
155}
156
157/// Get the path to a session file
158pub fn get_session_path(project_path: &str, session_id: &str) -> PathBuf {
159    get_sessions_base_dir()
160        .join(project_path)
161        .join(format!("{}.jsonl", session_id))
162}
163
164/// List all sessions for a project
165pub async fn list_sessions(project_path: &str) -> Result<Vec<String>, String> {
166    let dir = get_sessions_base_dir().join(project_path);
167
168    if !dir.exists() {
169        return Ok(vec![]);
170    }
171
172    let mut sessions = vec![];
173    let mut entries = fs::read_dir(&dir)
174        .await
175        .map_err(|e| format!("Failed to read directory: {}", e))?;
176
177    while let Some(entry) = entries
178        .next_entry()
179        .await
180        .map_err(|e| format!("Failed to read entry: {}", e))?
181    {
182        let path = entry.path();
183        if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
184            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
185                sessions.push(stem.to_string());
186            }
187        }
188    }
189
190    Ok(sessions)
191}
192
193/// Read all events from a session file
194pub async fn read_session(
195    project_path: &str,
196    session_id: &str,
197) -> Result<Vec<SessionEvent>, String> {
198    let path = get_session_path(project_path, session_id);
199
200    if !path.exists() {
201        return Err(format!("Session not found: {}", session_id));
202    }
203
204    let file = fs::File::open(&path)
205        .await
206        .map_err(|e| format!("Failed to open session file: {}", e))?;
207
208    let reader = BufReader::new(file);
209    let mut lines = reader.lines();
210    let mut events = vec![];
211
212    while let Some(line) = lines
213        .next_line()
214        .await
215        .map_err(|e| format!("Failed to read line: {}", e))?
216    {
217        if line.trim().is_empty() {
218            continue;
219        }
220
221        match serde_json::from_str::<SessionEvent>(&line) {
222            Ok(event) => events.push(event),
223            Err(e) => {
224                eprintln!("Warning: Failed to parse event: {} - {}", e, &line[..line.len().min(100)]);
225                // Continue reading despite parse errors
226            }
227        }
228    }
229
230    Ok(events)
231}
232
233/// Append an event to a session file
234pub async fn append_to_session(
235    project_path: &str,
236    session_id: &str,
237    event: &SessionEvent,
238) -> Result<(), String> {
239    let path = get_session_path(project_path, session_id);
240
241    // Ensure directory exists
242    if let Some(parent) = path.parent() {
243        fs::create_dir_all(parent)
244            .await
245            .map_err(|e| format!("Failed to create directory: {}", e))?;
246    }
247
248    let json = serde_json::to_string(event).map_err(|e| format!("Failed to serialize event: {}", e))?;
249
250    let mut file = fs::OpenOptions::new()
251        .create(true)
252        .append(true)
253        .open(&path)
254        .await
255        .map_err(|e| format!("Failed to open session file: {}", e))?;
256
257    file.write_all(json.as_bytes())
258        .await
259        .map_err(|e| format!("Failed to write to session: {}", e))?;
260    file.write_all(b"\n")
261        .await
262        .map_err(|e| format!("Failed to write newline: {}", e))?;
263
264    Ok(())
265}
266
267/// Delete a session file
268pub async fn delete_session(project_path: &str, session_id: &str) -> Result<(), String> {
269    let path = get_session_path(project_path, session_id);
270
271    if !path.exists() {
272        return Err(format!("Session not found: {}", session_id));
273    }
274
275    fs::remove_file(&path)
276        .await
277        .map_err(|e| format!("Failed to delete session: {}", e))?;
278
279    Ok(())
280}
281
282// ═══════════════════════════════════════════════════════════════════════════
283// ARBOR INTEGRATION
284// ═══════════════════════════════════════════════════════════════════════════
285
286/// Import a session file into an arbor tree
287///
288/// Creates a tree structure matching the session conversation flow
289pub async fn import_to_arbor(
290    arbor: &ArborStorage,
291    project_path: &str,
292    session_id: &str,
293    owner_id: &str,
294) -> Result<TreeId, String> {
295    let events = read_session(project_path, session_id).await?;
296
297    // Create new tree
298    let metadata = serde_json::json!({
299        "source": "claude_session_import",
300        "session_id": session_id,
301        "project_path": project_path,
302    });
303
304    let tree_id = arbor
305        .tree_create(Some(metadata), owner_id)
306        .await
307        .map_err(|e| e.to_string())?;
308
309    let tree = arbor.tree_get(&tree_id).await.map_err(|e| e.to_string())?;
310    let mut current_parent = tree.root;
311
312    // Process each event
313    for event in events {
314        match event {
315            SessionEvent::User { data } => {
316                // Create user message node
317                let node_event = NodeEvent::UserMessage {
318                    content: data.message.content.clone(),
319                };
320                let json =
321                    serde_json::to_string(&node_event).map_err(|e| format!("Serialize error: {}", e))?;
322
323                let node_id = arbor
324                    .node_create_text(&tree_id, Some(current_parent), json, None)
325                    .await
326                    .map_err(|e| e.to_string())?;
327
328                current_parent = node_id;
329            }
330            SessionEvent::Assistant { data } => {
331                // Create assistant start node
332                let start_event = NodeEvent::AssistantStart;
333                let json = serde_json::to_string(&start_event)
334                    .map_err(|e| format!("Serialize error: {}", e))?;
335
336                let start_node = arbor
337                    .node_create_text(&tree_id, Some(current_parent), json, None)
338                    .await
339                    .map_err(|e| e.to_string())?;
340
341                current_parent = start_node;
342
343                // Process assistant message content
344                if let AssistantMessage::Full { content, .. } = data.message {
345                    for block in content {
346                        let node_event = match block {
347                            ContentBlock::Text { text } => NodeEvent::ContentText { text },
348                            ContentBlock::ToolUse { id, name, input } => {
349                                NodeEvent::ContentToolUse { id, name, input }
350                            }
351                            ContentBlock::ToolResult {
352                                tool_use_id,
353                                content,
354                                is_error,
355                            } => NodeEvent::UserToolResult {
356                                tool_use_id,
357                                content,
358                                is_error: is_error.unwrap_or(false),
359                            },
360                            ContentBlock::Thinking { thinking, .. } => {
361                                NodeEvent::ContentThinking { thinking }
362                            }
363                        };
364
365                        let json = serde_json::to_string(&node_event)
366                            .map_err(|e| format!("Serialize error: {}", e))?;
367
368                        let node_id = arbor
369                            .node_create_text(&tree_id, Some(current_parent), json, None)
370                            .await
371                            .map_err(|e| e.to_string())?;
372
373                        current_parent = node_id;
374                    }
375                }
376
377                // Create assistant complete node
378                let complete_event = NodeEvent::AssistantComplete { usage: None };
379                let json = serde_json::to_string(&complete_event)
380                    .map_err(|e| format!("Serialize error: {}", e))?;
381
382                let complete_node = arbor
383                    .node_create_text(&tree_id, Some(current_parent), json, None)
384                    .await
385                    .map_err(|e| e.to_string())?;
386
387                current_parent = complete_node;
388            }
389            _ => {
390                // Skip other event types for now
391            }
392        }
393    }
394
395    Ok(tree_id)
396}
397
398/// Export an arbor tree to a session JSONL file
399///
400/// Converts arbor node structure back to claude session format
401pub async fn export_from_arbor(
402    arbor: &ArborStorage,
403    tree_id: &TreeId,
404    project_path: &str,
405    session_id: &str,
406) -> Result<(), String> {
407    use crate::activations::arbor::NodeType;
408    use crate::activations::claudecode::types::NodeEvent;
409
410    let tree = arbor.tree_get(tree_id).await.map_err(|e| e.to_string())?;
411
412    // Helper to traverse tree in DFS order
413    let traverse_dfs = |tree: &crate::activations::arbor::Tree| -> Vec<TreeId> {
414        use std::collections::HashMap;
415
416        // Build child map
417        let mut children: HashMap<TreeId, Vec<TreeId>> = HashMap::new();
418        for (node_id, node) in &tree.nodes {
419            if let Some(parent_id) = &node.parent {
420                children.entry(*parent_id)
421                    .or_insert_with(Vec::new)
422                    .push(*node_id);
423            }
424        }
425
426        // DFS traversal
427        let mut visited = Vec::new();
428        let mut stack = vec![tree.root];
429
430        while let Some(current) = stack.pop() {
431            visited.push(current);
432            if let Some(child_ids) = children.get(&current) {
433                // Reverse to maintain left-to-right order
434                for child_id in child_ids.iter().rev() {
435                    stack.push(*child_id);
436                }
437            }
438        }
439
440        visited
441    };
442
443    let node_ids = traverse_dfs(&tree);
444
445    // Parse NodeEvents and aggregate into SessionEvents
446    let mut session_events = Vec::new();
447    let mut current_assistant_blocks: Vec<ContentBlock> = Vec::new();
448    let mut in_assistant = false;
449
450    for node_id in node_ids {
451        let node = tree.nodes.get(&node_id).unwrap();
452
453        if let NodeType::Text { content } = &node.data {
454            // Skip empty content (like root node)
455            if content.is_empty() {
456                continue;
457            }
458
459            // Try to parse as NodeEvent
460            let node_event: NodeEvent = match serde_json::from_str(content) {
461                Ok(e) => e,
462                Err(_) => continue, // Skip nodes that aren't NodeEvents
463            };
464
465            match node_event {
466                NodeEvent::UserMessage { content } => {
467                    // Complete any pending assistant message
468                    if in_assistant && !current_assistant_blocks.is_empty() {
469                        session_events.push(build_assistant_event(
470                            std::mem::take(&mut current_assistant_blocks),
471                            session_id,
472                        ));
473                        in_assistant = false;
474                    }
475
476                    // Create user event
477                    session_events.push(SessionEvent::User {
478                        data: UserEvent {
479                            uuid: uuid::Uuid::new_v4().to_string(),
480                            parent_uuid: None,
481                            session_id: session_id.to_string(),
482                            timestamp: chrono::Utc::now().to_rfc3339(),
483                            cwd: std::env::current_dir()
484                                .ok()
485                                .and_then(|p| p.to_str().map(String::from))
486                                .unwrap_or_default(),
487                            message: UserMessage {
488                                role: "user".to_string(),
489                                content,
490                            },
491                            is_sidechain: None,
492                            git_branch: None,
493                        },
494                    });
495                }
496
497                NodeEvent::AssistantStart => {
498                    in_assistant = true;
499                    current_assistant_blocks.clear();
500                }
501
502                NodeEvent::ContentText { text } => {
503                    if in_assistant {
504                        current_assistant_blocks.push(ContentBlock::Text { text });
505                    }
506                }
507
508                NodeEvent::ContentToolUse { id, name, input } => {
509                    if in_assistant {
510                        current_assistant_blocks.push(ContentBlock::ToolUse { id, name, input });
511                    }
512                }
513
514                NodeEvent::ContentThinking { thinking } => {
515                    if in_assistant {
516                        current_assistant_blocks.push(ContentBlock::Thinking {
517                            thinking,
518                            signature: None,
519                        });
520                    }
521                }
522
523                NodeEvent::UserToolResult {
524                    tool_use_id,
525                    content,
526                    is_error,
527                } => {
528                    // Complete any pending assistant message
529                    if in_assistant && !current_assistant_blocks.is_empty() {
530                        session_events.push(build_assistant_event(
531                            std::mem::take(&mut current_assistant_blocks),
532                            session_id,
533                        ));
534                        in_assistant = false;
535                    }
536
537                    // Tool results become user messages in Claude API
538                    let content_str = serde_json::to_string(&vec![ContentBlock::ToolResult {
539                        tool_use_id,
540                        content,
541                        is_error: Some(is_error),
542                    }])
543                    .unwrap_or_default();
544
545                    session_events.push(SessionEvent::User {
546                        data: UserEvent {
547                            uuid: uuid::Uuid::new_v4().to_string(),
548                            parent_uuid: None,
549                            session_id: session_id.to_string(),
550                            timestamp: chrono::Utc::now().to_rfc3339(),
551                            cwd: std::env::current_dir()
552                                .ok()
553                                .and_then(|p| p.to_str().map(String::from))
554                                .unwrap_or_default(),
555                            message: UserMessage {
556                                role: "user".to_string(),
557                                content: content_str,
558                            },
559                            is_sidechain: None,
560                            git_branch: None,
561                        },
562                    });
563                }
564
565                NodeEvent::AssistantComplete { .. } => {
566                    if in_assistant && !current_assistant_blocks.is_empty() {
567                        session_events.push(build_assistant_event(
568                            std::mem::take(&mut current_assistant_blocks),
569                            session_id,
570                        ));
571                        in_assistant = false;
572                    }
573                }
574            }
575        }
576    }
577
578    // Complete any pending assistant message
579    if in_assistant && !current_assistant_blocks.is_empty() {
580        session_events.push(build_assistant_event(current_assistant_blocks, session_id));
581    }
582
583    // Write to session file
584    for event in session_events {
585        append_to_session(project_path, session_id, &event).await?;
586    }
587
588    Ok(())
589}
590
591/// Helper to build AssistantEvent from content blocks
592fn build_assistant_event(blocks: Vec<ContentBlock>, session_id: &str) -> SessionEvent {
593    SessionEvent::Assistant {
594        data: AssistantEvent {
595            uuid: uuid::Uuid::new_v4().to_string(),
596            parent_uuid: None,
597            session_id: session_id.to_string(),
598            timestamp: chrono::Utc::now().to_rfc3339(),
599            cwd: std::env::current_dir()
600                .ok()
601                .and_then(|p| p.to_str().map(String::from)),
602            message: AssistantMessage::Full {
603                role: "assistant".to_string(),
604                content: blocks,
605                model: None,
606                id: None,
607                stop_reason: None,
608                usage: None,
609            },
610            request_id: None,
611        },
612    }
613}