normalize_chat_sessions/formats/
normalize_agent.rs1use 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
12pub struct NormalizeAgentFormat;
14
15#[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#[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 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 for line in peek_lines(path, 3) {
181 if let Ok(entry) = serde_json::from_str::<Value>(&line) {
182 if let Some(event) = entry.get("event").and_then(|v| v.as_str())
184 && matches!(event, "session_start" | "task" | "turn_start")
185 {
186 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 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 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 let cmd_name = cmd.split_whitespace().next().unwrap_or("shell").to_string();
266
267 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 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 if !current_turn.messages.is_empty() {
300 session.turns.push(current_turn);
301 }
302
303 Ok(session)
304 }
305}