Skip to main content

normalize_chat_sessions/formats/
normalize_agent.rs

1//! Normalize @agent JSONL format parser.
2
3use super::{LogFormat, ParseError, 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/// Normalize agent session log format (JSONL).
13pub struct NormalizeAgentFormat;
14
15/// Event types in normalize 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 normalize agent session.
72/// Used by Lua bindings and future session listing features.
73#[allow(dead_code)]
74#[derive(Debug, Clone, Default, Serialize)]
75pub struct NormalizeAgentSession {
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 NormalizeAgentSession {
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 normalize agent events
180        for line in peek_lines(path, 3) {
181            if let Ok(entry) = serde_json::from_str::<Value>(&line) {
182                // Normalize agent logs have "event" field
183                if let Some(event) = entry.get("event").and_then(|v| v.as_str())
184                    && matches!(event, "session_start" | "task" | "turn_start")
185                {
186                    // Check for normalize-specific fields
187                    if entry.get("moss_root").is_some()
188                        || entry.get("user_prompt").is_some()
189                        || entry.get("working_memory_count").is_some()
190                    {
191                        return 1.0;
192                    }
193                }
194            }
195        }
196        0.0
197    }
198
199    fn parse(&self, path: &Path) -> Result<Session, ParseError> {
200        let file = File::open(path).map_err(|e| ParseError::Io {
201            path: path.to_path_buf(),
202            source: e,
203        })?;
204        let reader = BufReader::new(file);
205
206        let mut session = Session::new(path.to_path_buf(), self.name());
207        session.subagent_type = Some("interactive".into());
208        let mut current_turn = Turn::default();
209        let mut current_turn_num = 0u32;
210
211        for line in reader.lines() {
212            let line = line.map_err(|e| ParseError::Io {
213                path: path.to_path_buf(),
214                source: e,
215            })?;
216            if line.trim().is_empty() {
217                continue;
218            }
219
220            let Ok(event) = serde_json::from_str::<AgentEvent>(&line) else {
221                continue;
222            };
223
224            match event {
225                AgentEvent::SessionStart {
226                    session_id,
227                    timestamp,
228                    ..
229                } => {
230                    session.metadata.session_id = Some(session_id);
231                    session.metadata.timestamp = Some(timestamp);
232                }
233                AgentEvent::Task {
234                    user_prompt,
235                    provider,
236                    model,
237                    ..
238                } => {
239                    session.metadata.provider = provider;
240                    session.metadata.model = model;
241
242                    // Add user message for the task
243                    current_turn.messages.push(Message {
244                        role: Role::User,
245                        content: vec![ContentBlock::Text { text: user_prompt }],
246                        timestamp: None,
247                    });
248                }
249                AgentEvent::TurnStart { turn, .. } => {
250                    // Flush previous turn when starting a new one
251                    if turn > current_turn_num && !current_turn.messages.is_empty() {
252                        session.turns.push(std::mem::take(&mut current_turn));
253                    }
254                    current_turn_num = turn;
255                }
256                AgentEvent::LlmResponse { response, .. } => {
257                    current_turn.messages.push(Message {
258                        role: Role::Assistant,
259                        content: vec![ContentBlock::Text { text: response }],
260                        timestamp: None,
261                    });
262                }
263                AgentEvent::Command { cmd, success, .. } => {
264                    // Extract command name for tool use
265                    let cmd_name = cmd.split_whitespace().next().unwrap_or("shell").to_string();
266
267                    // Add tool use
268                    let tool_id = format!("cmd-{}", current_turn_num);
269                    current_turn.messages.push(Message {
270                        role: Role::Assistant,
271                        content: vec![ContentBlock::ToolUse {
272                            id: tool_id.clone(),
273                            name: cmd_name,
274                            input: serde_json::json!({ "command": cmd }),
275                        }],
276                        timestamp: None,
277                    });
278
279                    // Add tool result
280                    current_turn.messages.push(Message {
281                        role: Role::Tool,
282                        content: vec![ContentBlock::ToolResult {
283                            tool_use_id: tool_id,
284                            content: if success {
285                                "(success)".to_string()
286                            } else {
287                                "(failed)".to_string()
288                            },
289                            is_error: !success,
290                        }],
291                        timestamp: None,
292                    });
293                }
294                _ => {}
295            }
296        }
297
298        // Flush final turn
299        if !current_turn.messages.is_empty() {
300            session.turns.push(current_turn);
301        }
302
303        Ok(session)
304    }
305}