Skip to main content

normalize_chat_sessions/formats/
normalize_agent.rs

1//! Moss @agent JSONL format parser.
2
3use super::{LogFormat, SessionFile, list_jsonl_sessions, peek_lines};
4use crate::{ContentBlock, Message, Role, Session, Turn};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8use std::fs::File;
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11
12/// Moss agent session log format (JSONL).
13pub struct NormalizeAgentFormat;
14
15/// Event types in moss agent logs.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "event")]
18pub enum AgentEvent {
19    #[serde(rename = "session_start")]
20    SessionStart {
21        session_id: String,
22        timestamp: String,
23        moss_root: Option<String>,
24    },
25    #[serde(rename = "task")]
26    Task {
27        user_prompt: String,
28        provider: Option<String>,
29        model: Option<String>,
30        role: Option<String>,
31        max_turns: Option<u32>,
32        #[serde(flatten)]
33        extra: HashMap<String, Value>,
34    },
35    #[serde(rename = "turn_start")]
36    TurnStart {
37        turn: u32,
38        state: Option<String>,
39        working_memory_count: Option<u32>,
40        notes_count: Option<u32>,
41        #[serde(flatten)]
42        extra: HashMap<String, Value>,
43    },
44    #[serde(rename = "llm_response")]
45    LlmResponse {
46        turn: u32,
47        response: String,
48        state: Option<String>,
49        retries: Option<u32>,
50    },
51    #[serde(rename = "command")]
52    Command {
53        turn: u32,
54        cmd: String,
55        success: bool,
56        output_length: Option<usize>,
57        #[serde(flatten)]
58        extra: HashMap<String, Value>,
59    },
60    #[serde(rename = "session_end")]
61    SessionEnd {
62        duration_seconds: Option<u64>,
63        total_turns: Option<u32>,
64    },
65    #[serde(rename = "max_turns_reached")]
66    MaxTurnsReached { turn: u32 },
67    #[serde(other)]
68    Unknown,
69}
70
71/// Parsed moss agent session.
72/// Used by Lua bindings and future session listing features.
73#[allow(dead_code)]
74#[derive(Debug, Clone, Default, Serialize)]
75pub struct MossAgentSession {
76    pub session_id: String,
77    pub timestamp: Option<String>,
78    pub prompt: Option<String>,
79    pub provider: Option<String>,
80    pub model: Option<String>,
81    pub role: Option<String>,
82    pub turns: u32,
83    pub commands: Vec<CommandInfo>,
84    pub completed: bool,
85    pub max_turns_hit: bool,
86}
87
88#[allow(dead_code)]
89#[derive(Debug, Clone, Serialize)]
90pub struct CommandInfo {
91    pub cmd: String,
92    pub success: bool,
93    pub turn: u32,
94}
95
96#[allow(dead_code)]
97impl MossAgentSession {
98    /// Parse a session from a log file path.
99    pub fn parse(path: &Path) -> Option<Self> {
100        let file = File::open(path).ok()?;
101        let reader = BufReader::new(file);
102        let mut session = Self::default();
103
104        for line in reader.lines().map_while(Result::ok) {
105            if line.trim().is_empty() {
106                continue;
107            }
108            if let Ok(event) = serde_json::from_str::<AgentEvent>(&line) {
109                match event {
110                    AgentEvent::SessionStart {
111                        session_id,
112                        timestamp,
113                        ..
114                    } => {
115                        session.session_id = session_id;
116                        session.timestamp = Some(timestamp);
117                    }
118                    AgentEvent::Task {
119                        user_prompt,
120                        provider,
121                        model,
122                        role,
123                        ..
124                    } => {
125                        session.prompt = Some(user_prompt);
126                        session.provider = provider;
127                        session.model = model;
128                        session.role = role;
129                    }
130                    AgentEvent::TurnStart { turn, .. } => {
131                        session.turns = session.turns.max(turn);
132                    }
133                    AgentEvent::Command {
134                        cmd, success, turn, ..
135                    } => {
136                        session.commands.push(CommandInfo { cmd, success, turn });
137                    }
138                    AgentEvent::SessionEnd { .. } => {
139                        session.completed = true;
140                    }
141                    AgentEvent::MaxTurnsReached { .. } => {
142                        session.max_turns_hit = true;
143                    }
144                    _ => {}
145                }
146            }
147        }
148
149        if session.session_id.is_empty() {
150            return None;
151        }
152        Some(session)
153    }
154}
155
156impl LogFormat for NormalizeAgentFormat {
157    fn name(&self) -> &'static str {
158        "normalize"
159    }
160
161    fn sessions_dir(&self, project: Option<&Path>) -> PathBuf {
162        let project_root = project
163            .map(|p| p.to_path_buf())
164            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
165        project_root.join(".normalize/agent/logs")
166    }
167
168    fn list_sessions(&self, project: Option<&Path>) -> Vec<SessionFile> {
169        let dir = self.sessions_dir(project);
170        list_jsonl_sessions(&dir)
171    }
172
173    fn detect(&self, path: &Path) -> f64 {
174        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
175        if ext != "jsonl" {
176            return 0.0;
177        }
178
179        // Peek at first few lines for moss agent events
180        for line in peek_lines(path, 3) {
181            if let Ok(entry) = serde_json::from_str::<Value>(&line) {
182                // Moss agent logs have "event" field
183                if let Some(event) = entry.get("event").and_then(|v| v.as_str()) {
184                    if matches!(event, "session_start" | "task" | "turn_start") {
185                        // Check for moss-specific fields
186                        if entry.get("moss_root").is_some()
187                            || entry.get("user_prompt").is_some()
188                            || entry.get("working_memory_count").is_some()
189                        {
190                            return 1.0;
191                        }
192                    }
193                }
194            }
195        }
196        0.0
197    }
198
199    fn parse(&self, path: &Path) -> Result<Session, String> {
200        let file = File::open(path).map_err(|e| e.to_string())?;
201        let reader = BufReader::new(file);
202
203        let mut session = Session::new(path.to_path_buf(), self.name());
204        let mut current_turn = Turn::default();
205        let mut current_turn_num = 0u32;
206
207        for line in reader.lines() {
208            let line = line.map_err(|e| e.to_string())?;
209            if line.trim().is_empty() {
210                continue;
211            }
212
213            let Ok(event) = serde_json::from_str::<AgentEvent>(&line) else {
214                continue;
215            };
216
217            match event {
218                AgentEvent::SessionStart {
219                    session_id,
220                    timestamp,
221                    ..
222                } => {
223                    session.metadata.session_id = Some(session_id);
224                    session.metadata.timestamp = Some(timestamp);
225                }
226                AgentEvent::Task {
227                    user_prompt,
228                    provider,
229                    model,
230                    ..
231                } => {
232                    session.metadata.provider = provider;
233                    session.metadata.model = model;
234
235                    // Add user message for the task
236                    current_turn.messages.push(Message {
237                        role: Role::User,
238                        content: vec![ContentBlock::Text { text: user_prompt }],
239                        timestamp: None,
240                    });
241                }
242                AgentEvent::TurnStart { turn, .. } => {
243                    // Flush previous turn when starting a new one
244                    if turn > current_turn_num && !current_turn.messages.is_empty() {
245                        session.turns.push(std::mem::take(&mut current_turn));
246                    }
247                    current_turn_num = turn;
248                }
249                AgentEvent::LlmResponse { response, .. } => {
250                    current_turn.messages.push(Message {
251                        role: Role::Assistant,
252                        content: vec![ContentBlock::Text { text: response }],
253                        timestamp: None,
254                    });
255                }
256                AgentEvent::Command { cmd, success, .. } => {
257                    // Extract command name for tool use
258                    let cmd_name = cmd.split_whitespace().next().unwrap_or("shell").to_string();
259
260                    // Add tool use
261                    let tool_id = format!("cmd-{}", current_turn_num);
262                    current_turn.messages.push(Message {
263                        role: Role::Assistant,
264                        content: vec![ContentBlock::ToolUse {
265                            id: tool_id.clone(),
266                            name: cmd_name,
267                            input: serde_json::json!({ "command": cmd }),
268                        }],
269                        timestamp: None,
270                    });
271
272                    // Add tool result
273                    current_turn.messages.push(Message {
274                        role: Role::User,
275                        content: vec![ContentBlock::ToolResult {
276                            tool_use_id: tool_id,
277                            content: if success {
278                                "(success)".to_string()
279                            } else {
280                                "(failed)".to_string()
281                            },
282                            is_error: !success,
283                        }],
284                        timestamp: None,
285                    });
286                }
287                _ => {}
288            }
289        }
290
291        // Flush final turn
292        if !current_turn.messages.is_empty() {
293            session.turns.push(current_turn);
294        }
295
296        Ok(session)
297    }
298}