normalize_chat_sessions/formats/
normalize_agent.rs1use 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
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 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 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 if matches!(event, "session_start" | "task" | "turn_start") {
185 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 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 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 let cmd_name = cmd.split_whitespace().next().unwrap_or("shell").to_string();
259
260 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 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 if !current_turn.messages.is_empty() {
293 session.turns.push(current_turn);
294 }
295
296 Ok(session)
297 }
298}