1use std::fs::{File, OpenOptions};
26use std::io::{BufRead, BufReader, BufWriter, Write};
27use std::path::{Path, PathBuf};
28
29use anyhow::{Context, Result};
30use chrono::Local;
31
32use crate::domain::{KeyCode, Msg, MsgKind, Paste, SlashCmd, ToolOutcome, TurnId};
33use crate::providers::{ProgressEvent, SubagentPhase};
34
35pub struct Recorder {
38 writer: BufWriter<File>,
39 path: PathBuf,
40}
41
42impl Recorder {
43 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
45 let path = path.into();
46 let file = OpenOptions::new()
47 .create(true)
48 .append(true)
49 .open(&path)
50 .with_context(|| format!("open {} for recording", path.display()))?;
51 Ok(Self {
52 writer: BufWriter::new(file),
53 path,
54 })
55 }
56
57 pub fn path(&self) -> &Path {
58 &self.path
59 }
60
61 pub fn record_kind(
65 &mut self,
66 kind: MsgKind,
67 turn: Option<TurnId>,
68 mut body: serde_json::Value,
69 ) -> Result<()> {
70 crate::utils::redact_json(&mut body);
75 let entry = serde_json::json!({
76 "ts": Local::now().to_rfc3339(),
77 "kind": format!("{:?}", kind),
78 "turn": turn.map(|t| t.0),
79 "body": body,
80 });
81 writeln!(self.writer, "{}", entry).context("write jsonl line")?;
82 Ok(())
83 }
84
85 pub fn flush(&mut self) -> Result<()> {
86 self.writer.flush().context("flush recorder")
87 }
88}
89
90impl Drop for Recorder {
91 fn drop(&mut self) {
92 let _ = self.writer.flush();
93 }
94}
95
96pub fn record_msg_body(msg: &Msg) -> serde_json::Value {
103 match msg {
104 Msg::Key(key) => serde_json::json!({
105 "code": key_code_body(key.code),
106 "modifiers": {
107 "ctrl": key.modifiers.ctrl,
108 "alt": key.modifiers.alt,
109 "shift": key.modifiers.shift,
110 },
111 }),
112 Msg::Paste(Paste::Text(text)) => serde_json::json!({
113 "type": "text",
114 "text": text,
115 }),
116 Msg::Paste(Paste::Image { bytes, format }) => serde_json::json!({
117 "recordable": false,
118 "reason": "binary paste image omitted",
119 "type": "image",
120 "format": format,
121 "size_bytes": bytes.len(),
122 }),
123 Msg::SubmitPrompt {
124 text,
125 attachment_ids,
126 } => serde_json::json!({
127 "text": text,
128 "attachment_ids": attachment_ids,
129 }),
130 Msg::Slash(cmd) => slash_body(cmd),
131 Msg::CancelTurn => serde_json::json!({}),
132 Msg::ConfirmAccepted => serde_json::json!({"accepted": true}),
133 Msg::ConfirmDeclined => serde_json::json!({"accepted": false}),
134 Msg::Quit => serde_json::json!({}),
135 Msg::RuntimeSignal(signal) => serde_json::json!({
136 "signal": signal.as_str(),
137 }),
138 Msg::StreamText { chunk, .. } => serde_json::json!({"chunk": chunk}),
139 Msg::StreamReasoning { chunk, .. } => serde_json::json!({
140 "text": chunk.text,
141 "signature": chunk.signature,
142 }),
143 Msg::StreamToolCall { call, .. } => serde_json::to_value(call)
144 .unwrap_or_else(|_| unsupported("tool call was not serializable")),
145 Msg::BuiltinToolSchemaTokens(tokens) => serde_json::json!({"tokens": tokens}),
146 Msg::ContextUsageEstimated { snapshot, .. } => serde_json::json!({
147 "used_tokens": snapshot.used_tokens,
148 "max_tokens": snapshot.max_tokens,
149 "remaining_tokens": snapshot.remaining_tokens,
150 "used_percent": snapshot.used_percent,
151 "source": format!("{:?}", snapshot.source),
152 "breakdown": snapshot.breakdown.as_ref().map(|b| serde_json::json!({
153 "system_tokens": b.system_tokens,
154 "instructions_tokens": b.instructions_tokens,
155 "message_tokens": b.message_tokens,
156 "tool_schema_tokens": b.tool_schema_tokens,
157 "image_count": b.image_count,
158 "message_count": b.message_count,
159 "tool_count": b.tool_count,
160 })),
161 }),
162 Msg::CompactionFinished { result, .. } => serde_json::json!({
163 "id": result.record.id,
164 "trigger": result.record.trigger.as_str(),
165 "before_tokens": result.record.before_tokens,
166 "after_tokens": result.record.after_tokens,
167 "archived_message_count": result.record.archived_message_count,
168 "preserved_message_count": result.record.preserved_message_count,
169 "duration_secs": result.record.duration_secs,
170 }),
171 Msg::CompactionFailed {
172 trigger,
173 message,
174 kind,
175 ..
176 } => serde_json::json!({
177 "trigger": trigger.as_str(),
178 "message": message,
179 "kind": format!("{:?}", kind),
180 }),
181 Msg::StreamDone {
182 usage,
183 thinking_signature,
184 ..
185 } => serde_json::json!({
186 "usage": usage.as_ref().map(|u| serde_json::json!({
187 "prompt_tokens": u.prompt_tokens,
188 "completion_tokens": u.completion_tokens,
189 "total_tokens": u.total_tokens,
190 "cached_input_tokens": u.cached_input_tokens,
191 "cache_creation_input_tokens": u.cache_creation_input_tokens,
192 "reasoning_output_tokens": u.reasoning_output_tokens,
193 "source": format!("{:?}", u.source),
194 })),
195 "thinking_signature": thinking_signature,
196 }),
197 Msg::UpstreamError { error, .. } => serde_json::to_value(error)
198 .unwrap_or_else(|_| unsupported("upstream error was not serializable")),
199 Msg::TurnCancelled(_) => serde_json::json!({}),
200 Msg::ToolStarted { call_id, .. } => serde_json::json!({"call_id": call_id.0}),
201 Msg::ToolProgress { call_id, event, .. } => serde_json::json!({
202 "call_id": call_id.0,
203 "event": progress_body(event),
204 }),
205 Msg::ToolFinished {
206 call_id, outcome, ..
207 } => serde_json::json!({
208 "call_id": call_id.0,
209 "outcome": outcome_body(outcome),
210 }),
211 Msg::ApprovalRequested {
212 call_id,
213 tool,
214 risk,
215 ..
216 } => serde_json::json!({
217 "call_id": call_id.0,
218 "tool": tool,
219 "risk": risk,
220 }),
221 Msg::McpServerReady { name, tools } => serde_json::json!({
222 "name": name,
223 "tools": tools.iter().map(|tool| serde_json::json!({
224 "name": tool.name,
225 "description": tool.description,
226 "input_schema": tool.input_schema,
227 })).collect::<Vec<_>>(),
228 }),
229 Msg::McpServerErrored { name, reason } => serde_json::json!({
230 "name": name,
231 "reason": reason,
232 }),
233 Msg::McpServerStopped { name } => serde_json::json!({"name": name}),
234 Msg::InstructionsChanged(loaded) => match loaded {
235 Some(loaded) => serde_json::json!({
236 "path": loaded.path,
237 "byte_len": loaded.byte_len,
238 "truncated": loaded.truncated,
239 }),
240 None => serde_json::json!({"path": null}),
241 },
242 Msg::MemoryChanged(loaded) => match loaded {
243 Some(loaded) => serde_json::json!({
244 "entries": loaded.entries.len(),
245 "truncated": loaded.truncated,
246 }),
247 None => serde_json::json!({"entries": 0}),
248 },
249 Msg::SessionSaved => serde_json::json!({}),
250 Msg::ConversationLoaded(history) => serde_json::json!({
251 "id": history.id,
252 "message_count": history.messages.len(),
253 "title": history.title,
254 }),
255 Msg::ConversationsListed(summaries) => serde_json::json!({
256 "count": summaries.len(),
257 "ids": summaries.iter().map(|summary| summary.id.as_str()).collect::<Vec<_>>(),
258 }),
259 Msg::RuntimeTasksListed(tasks) => serde_json::json!({
260 "count": tasks.len(),
261 "ids": tasks.iter().map(|task| task.id.as_str()).collect::<Vec<_>>(),
262 }),
263 Msg::RuntimeTaskLoaded { task, events } => serde_json::json!({
264 "id": task.as_ref().map(|task| task.id.as_str()),
265 "found": task.is_some(),
266 "event_count": events.len(),
267 }),
268 Msg::RuntimeProcessesListed(processes) => serde_json::json!({
269 "count": processes.len(),
270 "ids": processes.iter().map(|process| process.id.as_str()).collect::<Vec<_>>(),
271 }),
272 Msg::RuntimeText(text) => serde_json::json!({"chars": text.len()}),
273 Msg::RuntimeApprovalsListed(approvals) => serde_json::json!({
274 "count": approvals.len(),
275 "ids": approvals.iter().map(|approval| approval.id.as_str()).collect::<Vec<_>>(),
276 }),
277 Msg::RuntimeCheckpointsListed(checkpoints) => serde_json::json!({
278 "count": checkpoints.len(),
279 "ids": checkpoints.iter().map(|checkpoint| checkpoint.id.as_str()).collect::<Vec<_>>(),
280 }),
281 Msg::RuntimePluginsListed(plugins) => serde_json::json!({
282 "count": plugins.len(),
283 "ids": plugins.iter().map(|plugin| plugin.id.as_str()).collect::<Vec<_>>(),
284 }),
285 Msg::ModelPullFinished { model } => serde_json::json!({"model": model}),
286 Msg::ModelPullProgress(line) => serde_json::json!({"line": line}),
287 Msg::Tick => serde_json::json!({}),
288 Msg::StatusDismiss => serde_json::json!({}),
289 Msg::Resize { width, height } => serde_json::json!({
290 "width": width,
291 "height": height,
292 }),
293 Msg::TransientStatus {
294 text,
295 kind,
296 dismiss_ms,
297 } => serde_json::json!({
298 "text": text,
299 "kind": format!("{:?}", kind),
300 "dismiss_ms": dismiss_ms,
301 }),
302 Msg::MouseScroll { delta } => serde_json::json!({"delta": delta}),
303 Msg::OpenImageAt {
304 message_index,
305 image_index,
306 } => serde_json::json!({
307 "message_index": message_index,
308 "image_index": image_index,
309 }),
310 Msg::CopySelection(text) => serde_json::json!({ "len": text.chars().count() }),
313 }
314}
315
316fn key_code_body(code: KeyCode) -> serde_json::Value {
317 match code {
318 KeyCode::Char(c) => serde_json::json!({"char": c.to_string()}),
319 KeyCode::F(n) => serde_json::json!({"f": n}),
320 other => serde_json::json!(format!("{:?}", other)),
321 }
322}
323
324fn slash_body(cmd: &SlashCmd) -> serde_json::Value {
325 match cmd {
326 SlashCmd::Model(model) => serde_json::json!({"command": "model", "arg": model}),
327 SlashCmd::Reasoning(level) => serde_json::json!({
328 "command": "reasoning",
329 "arg": level.map(|level| level.as_str()),
330 }),
331 SlashCmd::VisibleReasoning(arg) => {
332 serde_json::json!({"command": "visible-reasoning", "arg": arg})
333 },
334 SlashCmd::Safety(mode) => serde_json::json!({
335 "command": "safety",
336 "arg": mode.map(|m| m.as_str()),
337 }),
338 SlashCmd::Clear => serde_json::json!({"command": "clear"}),
339 SlashCmd::Save(name) => serde_json::json!({"command": "save", "arg": name}),
340 SlashCmd::Load(name) => serde_json::json!({"command": "load", "arg": name}),
341 SlashCmd::List => serde_json::json!({"command": "list"}),
342 SlashCmd::Usage => serde_json::json!({"command": "usage"}),
343 SlashCmd::Context => serde_json::json!({"command": "context"}),
344 SlashCmd::Compact(instructions) => {
345 serde_json::json!({"command": "compact", "arg": instructions})
346 },
347 SlashCmd::Memory => serde_json::json!({"command": "memory"}),
348 SlashCmd::Remember(text) => serde_json::json!({"command": "remember", "arg": text}),
349 SlashCmd::Forget(id) => serde_json::json!({"command": "forget", "arg": id}),
350 SlashCmd::ConsolidateMemory => serde_json::json!({"command": "consolidate-memory"}),
351 SlashCmd::Doctor => serde_json::json!({"command": "doctor"}),
352 SlashCmd::Tasks => serde_json::json!({"command": "tasks"}),
353 SlashCmd::Task(id) => serde_json::json!({"command": "task", "arg": id}),
354 SlashCmd::Pause(id) => serde_json::json!({"command": "pause", "arg": id}),
355 SlashCmd::Resume(id) => serde_json::json!({"command": "resume", "arg": id}),
356 SlashCmd::Cancel(id) => serde_json::json!({"command": "cancel", "arg": id}),
357 SlashCmd::Handoff(id) => serde_json::json!({"command": "handoff", "arg": id}),
358 SlashCmd::Report(id) => serde_json::json!({"command": "report", "arg": id}),
359 SlashCmd::Processes => serde_json::json!({"command": "processes"}),
360 SlashCmd::Logs(id) => serde_json::json!({"command": "logs", "arg": id}),
361 SlashCmd::Stop(id) => serde_json::json!({"command": "stop", "arg": id}),
362 SlashCmd::Restart(id) => serde_json::json!({"command": "restart", "arg": id}),
363 SlashCmd::Open(target) => serde_json::json!({"command": "open", "arg": target}),
364 SlashCmd::Ports => serde_json::json!({"command": "ports"}),
365 SlashCmd::Approvals => serde_json::json!({"command": "approvals"}),
366 SlashCmd::Approve(id) => serde_json::json!({"command": "approve", "arg": id}),
367 SlashCmd::Deny(id) => serde_json::json!({"command": "deny", "arg": id}),
368 SlashCmd::Checkpoint(paths) => serde_json::json!({"command": "checkpoint", "arg": paths}),
369 SlashCmd::Checkpoints => serde_json::json!({"command": "checkpoints"}),
370 SlashCmd::Restore(id) => serde_json::json!({"command": "restore", "arg": id}),
371 SlashCmd::ModelInfo(model) => serde_json::json!({"command": "model-info", "arg": model}),
372 SlashCmd::Plugins => serde_json::json!({"command": "plugins"}),
373 SlashCmd::CloudSetup => serde_json::json!({"command": "cloud-setup"}),
374 SlashCmd::Help => serde_json::json!({"command": "help"}),
375 SlashCmd::Quit => serde_json::json!({"command": "quit"}),
376 SlashCmd::Unknown(name) => serde_json::json!({"command": "unknown", "name": name}),
377 }
378}
379
380fn progress_body(event: &ProgressEvent) -> serde_json::Value {
381 match event {
382 ProgressEvent::Output(text) => serde_json::json!({"type": "output", "text": text}),
383 ProgressEvent::Status(text) => serde_json::json!({"type": "status", "text": text}),
384 ProgressEvent::Bytes { done, total } => serde_json::json!({
385 "type": "bytes",
386 "done": done,
387 "total": total,
388 }),
389 ProgressEvent::Artifact {
390 mime,
391 data,
392 caption,
393 } => serde_json::json!({
394 "recordable": false,
395 "type": "artifact",
396 "mime": mime,
397 "caption": caption,
398 "size_bytes": data.len(),
399 }),
400 ProgressEvent::SubagentToolCall {
401 child_call_id,
402 tool_name,
403 phase,
404 } => serde_json::json!({
405 "type": "subagent_tool_call",
406 "child_call_id": child_call_id.0,
407 "tool_name": tool_name,
408 "phase": subagent_phase(*phase),
409 }),
410 ProgressEvent::SubagentText(text) => {
411 serde_json::json!({"type": "subagent_text", "text": text})
412 },
413 }
414}
415
416fn subagent_phase(phase: SubagentPhase) -> &'static str {
417 match phase {
418 SubagentPhase::Started => "started",
419 SubagentPhase::Finished => "finished",
420 SubagentPhase::Errored => "errored",
421 }
422}
423
424fn outcome_body(outcome: &ToolOutcome) -> serde_json::Value {
425 serde_json::json!({
426 "status": match outcome.status {
427 crate::domain::ToolStatus::Success => "success",
428 crate::domain::ToolStatus::Error => "error",
429 crate::domain::ToolStatus::Cancelled => "cancelled",
430 },
431 "summary": outcome.summary,
432 "model_content": outcome.model_content,
433 "error": outcome.error,
434 "image_count": outcome.images().map(|images| images.len()).unwrap_or(0),
435 "duration_secs": outcome.duration_secs,
436 "metadata": outcome.metadata,
437 "artifacts": outcome.artifacts,
438 })
439}
440
441fn unsupported(reason: &str) -> serde_json::Value {
442 serde_json::json!({
443 "recordable": false,
444 "reason": reason,
445 })
446}
447
448pub struct Replay {
451 lines: std::io::Lines<BufReader<File>>,
452 path: PathBuf,
453}
454
455impl Replay {
456 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
457 let path = path.into();
458 let file =
459 File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
460 Ok(Self {
461 lines: BufReader::new(file).lines(),
462 path,
463 })
464 }
465
466 pub fn path(&self) -> &Path {
467 &self.path
468 }
469}
470
471impl Iterator for Replay {
472 type Item = Result<ReplayEntry>;
473
474 fn next(&mut self) -> Option<Self::Item> {
475 let line = self.lines.next()?;
476 Some(match line {
477 Ok(raw) => serde_json::from_str::<ReplayEntry>(&raw)
478 .with_context(|| format!("parse replay line: {}", raw)),
479 Err(e) => Err(anyhow::Error::from(e)),
480 })
481 }
482}
483
484#[derive(Debug, serde::Serialize, serde::Deserialize)]
487pub struct ReplayEntry {
488 pub ts: String,
489 pub kind: String,
490 pub turn: Option<u64>,
491 pub body: serde_json::Value,
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 fn tmpfile(name: &str) -> PathBuf {
499 let dir = std::env::temp_dir().join("mermaid_recorder_tests");
500 let _ = std::fs::create_dir_all(&dir);
501 dir.join(name)
502 }
503
504 #[test]
505 fn record_and_replay_roundtrip() {
506 let path = tmpfile("roundtrip.jsonl");
507 let _ = std::fs::remove_file(&path);
508
509 {
510 let mut r = Recorder::open(&path).expect("open");
511 r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
512 .expect("record");
513 r.record_kind(
514 MsgKind::SubmitPrompt,
515 None,
516 serde_json::json!({"text": "hello"}),
517 )
518 .expect("record");
519 r.record_kind(
520 MsgKind::StreamText,
521 Some(TurnId(7)),
522 serde_json::json!({"chunk": "partial"}),
523 )
524 .expect("record");
525 r.flush().expect("flush");
526 }
527
528 let replay = Replay::open(&path).expect("open replay");
529 let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
530 assert_eq!(entries.len(), 3);
531 assert_eq!(entries[0].kind, "Tick");
532 assert_eq!(entries[1].body["text"], "hello");
533 assert_eq!(entries[2].turn, Some(7));
534
535 let _ = std::fs::remove_file(&path);
536 }
537
538 #[test]
539 fn record_kind_redacts_secrets_in_body() {
540 let path = tmpfile("redact.jsonl");
543 let _ = std::fs::remove_file(&path);
544 {
545 let mut r = Recorder::open(&path).expect("open");
546 r.record_kind(
547 MsgKind::StreamText,
548 None,
549 serde_json::json!({
550 "chunk": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
551 "nested": ["Authorization: Bearer abcdef123456ghijkl"],
552 }),
553 )
554 .expect("record");
555 r.flush().expect("flush");
556 }
557
558 let raw = std::fs::read_to_string(&path).expect("read back");
559 assert!(
560 !raw.contains("sk-abcdefghijklmnop1234"),
561 "raw secret leaked: {raw}"
562 );
563 assert!(
564 !raw.contains("abcdef123456ghijkl"),
565 "bearer token leaked: {raw}"
566 );
567 assert!(
568 raw.contains("[REDACTED]"),
569 "expected redaction marker: {raw}"
570 );
571
572 let replay = Replay::open(&path).expect("replay");
573 let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
574 assert_eq!(entries[0].body["chunk"], "OPENAI_API_KEY=[REDACTED]");
575 assert_eq!(
576 entries[0].body["nested"][0],
577 "Authorization: Bearer [REDACTED]"
578 );
579
580 let _ = std::fs::remove_file(&path);
581 }
582
583 #[test]
584 fn replay_parses_malformed_line_as_err() {
585 let path = tmpfile("bad.jsonl");
586 std::fs::write(&path, "not-json\n").expect("write");
587 let mut replay = Replay::open(&path).expect("open");
588 let first = replay.next().expect("first entry");
589 assert!(first.is_err());
590 let _ = std::fs::remove_file(&path);
591 }
592
593 #[test]
594 fn record_creates_file_on_open() {
595 let path = tmpfile("creates.jsonl");
596 let _ = std::fs::remove_file(&path);
597 assert!(!path.exists());
598 let _ = Recorder::open(&path).expect("open");
599 assert!(path.exists());
600 let _ = std::fs::remove_file(&path);
601 }
602
603 #[test]
604 fn record_append_preserves_existing_lines() {
605 let path = tmpfile("append.jsonl");
606 let _ = std::fs::remove_file(&path);
607 {
608 let mut r = Recorder::open(&path).expect("open");
609 r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
610 .expect("record");
611 }
612 {
613 let mut r = Recorder::open(&path).expect("reopen");
614 r.record_kind(MsgKind::Quit, None, serde_json::json!({}))
615 .expect("record");
616 }
617 let replay = Replay::open(&path).expect("replay");
618 let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
619 assert_eq!(entries.len(), 2);
620 assert_eq!(entries[0].kind, "Tick");
621 assert_eq!(entries[1].kind, "Quit");
622 let _ = std::fs::remove_file(&path);
623 }
624
625 #[test]
626 fn record_msg_body_submit_prompt_keeps_text_and_attachments() {
627 let body = record_msg_body(&crate::domain::Msg::SubmitPrompt {
628 text: "explain main.rs".to_string(),
629 attachment_ids: vec![3, 9],
630 });
631 assert_eq!(body["text"], "explain main.rs");
632 assert_eq!(body["attachment_ids"][0], 3);
633 assert_eq!(body["attachment_ids"][1], 9);
634 }
635
636 #[test]
637 fn record_msg_body_slash_model_keeps_command_and_arg() {
638 let body = record_msg_body(&crate::domain::Msg::Slash(crate::domain::SlashCmd::Model(
639 Some("anthropic/opus".to_string()),
640 )));
641 assert_eq!(body["command"], "model");
642 assert_eq!(body["arg"], "anthropic/opus");
643 }
644
645 #[test]
646 fn record_msg_body_runtime_signal_keeps_signal_name() {
647 let body = record_msg_body(&crate::domain::Msg::RuntimeSignal(
648 crate::domain::RuntimeSignal::Terminate,
649 ));
650 assert_eq!(body["signal"], "terminate");
651 }
652
653 #[test]
654 fn record_msg_body_marks_binary_paste_image_unrecordable() {
655 let body = record_msg_body(&crate::domain::Msg::Paste(crate::domain::Paste::Image {
656 bytes: vec![1, 2, 3],
657 format: "png".to_string(),
658 }));
659 assert_eq!(body["recordable"], false);
660 assert_eq!(body["type"], "image");
661 assert_eq!(body["size_bytes"], 3);
662 }
663}