Skip to main content

orch/cli/
session.rs

1use crate::cli::cost::format_cost;
2use crate::cli::init_store;
3use crate::cli::ndjson;
4use crate::cli::task_ref;
5use crate::store::Task;
6use crate::store::{TaskStatus, TaskStore};
7use anyhow::Context;
8use serde::Serialize;
9use std::collections::BTreeMap;
10use std::collections::BTreeSet;
11use std::collections::HashSet;
12use std::fs;
13use std::str::FromStr;
14use std::sync::Arc;
15
16pub async fn export(
17    task_id: &str,
18    attempt: Option<u32>,
19    format: ExportFormat,
20) -> anyhow::Result<()> {
21    let store = Arc::new(init_store().await?);
22
23    let resolved = task_ref::resolve(&store, task_id).await?;
24    let internal_id = resolved
25        .store_id
26        .ok_or_else(|| anyhow::anyhow!("task not found in store: {task_id}"))?;
27    let repo = resolved.repo.clone();
28    let task = store.get(internal_id).await.context("task not found")?;
29
30    let attempt = if let Some(n) = attempt {
31        n
32    } else {
33        let runs = store.get_runs(internal_id).await?;
34        if runs.is_empty() {
35            anyhow::bail!("no attempts found for task");
36        }
37        let max_attempt = runs.iter().map(|r| r.attempt).max().unwrap_or(1);
38        u32::try_from(max_attempt).unwrap_or(1)
39    };
40
41    let output_path = task_ref::state_dir(&resolved)?
42        .join("attempts")
43        .join(attempt.to_string())
44        .join("output.json");
45
46    if !output_path.exists() {
47        anyhow::bail!(
48            "attempt {attempt} not found (no output file at {})",
49            output_path.display()
50        );
51    }
52
53    let content = fs::read_to_string(&output_path)?;
54
55    match format {
56        ExportFormat::Markdown => export_markdown(&task, attempt, &content, &store, &repo).await,
57        ExportFormat::Json => export_json(&task, attempt, &content),
58        ExportFormat::Raw => {
59            println!("{content}");
60            Ok(())
61        }
62    }
63}
64
65/// Structured summary extracted from raw NDJSON output.
66struct ParsedOutput {
67    /// Files written or edited (deduplicated, sorted).
68    files_changed: Vec<String>,
69    /// Files read during the session (deduplicated, sorted).
70    files_read: Vec<String>,
71    /// Tool call counts keyed by tool name.
72    tool_counts: BTreeMap<String, usize>,
73    /// Git-related bash commands executed.
74    git_commands: Vec<String>,
75    /// Representative assistant text lines (for "key decisions").
76    key_messages: Vec<String>,
77}
78
79impl ParsedOutput {
80    fn is_empty(&self) -> bool {
81        self.files_changed.is_empty()
82            && self.files_read.is_empty()
83            && self.tool_counts.is_empty()
84            && self.git_commands.is_empty()
85            && self.key_messages.is_empty()
86    }
87}
88
89/// Parse NDJSON content into a structured [`ParsedOutput`].
90///
91/// Handles all three agent formats (Claude, Codex, OpenCode).  Only write/edit tool calls contribute to
92/// `files_changed`; read calls go to `files_read`.  Bash calls that start
93/// with `git` are captured as `git_commands`.  Assistant text messages
94/// longer than 40 chars are kept as `key_messages` (capped at 10).
95fn parse_output(content: &str) -> ParsedOutput {
96    let mut files_changed: BTreeSet<String> = BTreeSet::new();
97    let mut files_read: BTreeSet<String> = BTreeSet::new();
98    let mut tool_counts: BTreeMap<String, usize> = BTreeMap::default();
99    let mut git_commands: Vec<String> = Vec::new();
100    let mut key_messages: Vec<String> = Vec::new();
101
102    for line in content.lines() {
103        let trimmed = line.trim();
104        if trimmed.is_empty() {
105            continue;
106        }
107        let v: serde_json::Value = match serde_json::from_str(trimmed) {
108            Ok(v) => v,
109            Err(_) => continue,
110        };
111
112        let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
113
114        match event_type {
115            // ── Claude ────────────────────────────────────────────────────
116            "assistant" => {
117                if let Some(text) = extract_claude_text(&v) {
118                    push_key_message(&mut key_messages, &text);
119                }
120            }
121
122            "tool_use" if v.get("tool").is_some() => {
123                // Claude tool_use: {"type":"tool_use","tool":{"name":"...","input":{...}}}
124                if let Some(tool) = v.get("tool") {
125                    let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("?");
126                    *tool_counts.entry(name.to_string()).or_default() += 1;
127                    let input = tool.get("input");
128                    record_file_or_command(
129                        name,
130                        input,
131                        &mut files_changed,
132                        &mut files_read,
133                        &mut git_commands,
134                    );
135                }
136            }
137
138            // ── OpenCode ──────────────────────────────────────────────────
139            "text" => {
140                let text = v
141                    .get("part")
142                    .and_then(|p| p.get("text"))
143                    .and_then(|t| t.as_str())
144                    .or_else(|| v.get("text").and_then(|t| t.as_str()))
145                    .unwrap_or("");
146                push_key_message(&mut key_messages, text);
147            }
148
149            "tool_use" if v.get("part").is_some() => {
150                // OpenCode tool_use: {"type":"tool_use","part":{"tool":"...","state":{"input":{...}}}}
151                if let Some(part) = v.get("part") {
152                    let name = part.get("tool").and_then(|t| t.as_str()).unwrap_or("?");
153                    *tool_counts.entry(name.to_string()).or_default() += 1;
154                    let input = part.get("state").and_then(|s| s.get("input"));
155                    record_file_or_command(
156                        name,
157                        input,
158                        &mut files_changed,
159                        &mut files_read,
160                        &mut git_commands,
161                    );
162                }
163            }
164
165            // ── Codex ─────────────────────────────────────────────────────
166            "item.completed" => {
167                if let Some(item) = v.get("item") {
168                    let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
169                    match item_type {
170                        "agent_message" => {
171                            let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
172                            push_key_message(&mut key_messages, text);
173                        }
174                        "command_execution" => {
175                            let cmd = item.get("command").and_then(|c| c.as_str()).unwrap_or("");
176                            *tool_counts.entry("bash".to_string()).or_default() += 1;
177                            let first_line = cmd.lines().next().unwrap_or("").trim();
178                            if (first_line.starts_with("git ") || first_line == "git")
179                                && git_commands.len() < 20
180                            {
181                                git_commands.push(ndjson::truncate(first_line, 120));
182                            }
183                        }
184                        _ => {}
185                    }
186                }
187            }
188
189            _ => {}
190        }
191    }
192
193    ParsedOutput {
194        files_changed: files_changed
195            .into_iter()
196            .chain(ndjson::extract_files_changed(content))
197            .collect::<BTreeSet<_>>()
198            .into_iter()
199            .collect(),
200        files_read: files_read.into_iter().collect(),
201        tool_counts,
202        git_commands,
203        key_messages,
204    }
205}
206
207/// Keep an assistant message longer than 40 chars as a key message, up to 10.
208fn push_key_message(key_messages: &mut Vec<String>, text: &str) {
209    if text.len() > 40 && key_messages.len() < 10 {
210        key_messages.push(first_sentence(text, 160));
211    }
212}
213
214/// Record a file path or git command from a tool call.
215fn record_file_or_command(
216    name: &str,
217    input: Option<&serde_json::Value>,
218    files_changed: &mut BTreeSet<String>,
219    files_read: &mut BTreeSet<String>,
220    git_commands: &mut Vec<String>,
221) {
222    let Some(input) = input else { return };
223    match name {
224        "Write" | "write" | "NotebookEdit" | "Edit" | "edit" | "MultiEdit" => {
225            if let Some(p) = str_field(input, "file_path") {
226                files_changed.insert(p);
227            }
228        }
229        "Read" | "read" => {
230            if let Some(p) = str_field(input, "file_path") {
231                files_read.insert(p);
232            }
233        }
234        "Bash" | "bash" => {
235            if let Some(cmd) = str_field(input, "command") {
236                let first_line = cmd.lines().next().unwrap_or("").trim();
237                if (first_line.starts_with("git ") || first_line == "git")
238                    && git_commands.len() < 20
239                {
240                    git_commands.push(ndjson::truncate(first_line, 120));
241                }
242            }
243        }
244        _ => {}
245    }
246}
247
248async fn export_markdown(
249    task: &Task,
250    attempt: u32,
251    content: &str,
252    store: &Arc<TaskStore>,
253    _repo: &str,
254) -> anyhow::Result<()> {
255    let runs = store.get_runs(task.id).await?;
256    let attempt_i32 = i32::try_from(attempt).ok();
257    let attempt_run = runs.iter().find(|r| Some(r.attempt) == attempt_i32);
258
259    let agent = task.agent.as_deref().unwrap_or("unknown");
260    let model = task.model.as_deref().unwrap_or("-");
261
262    let duration = attempt_run.map_or(0.0, |r| r.duration_secs);
263    let duration_str = format_duration(duration);
264
265    let input_tokens = task.input_tokens;
266    let output_tokens = task.output_tokens;
267
268    let cost = task.total_cost_usd;
269
270    println!(
271        "# Session: {} (Attempt {})",
272        task_id_display(task.id, task.external_id.as_ref()),
273        attempt
274    );
275    println!();
276    println!("**Agent**: {agent} ({model})");
277    println!("**Duration**: {duration_str}");
278    println!(
279        "**Tokens**: {} in / {} out",
280        format_num(input_tokens),
281        format_num(output_tokens)
282    );
283    if input_tokens > 0 || output_tokens > 0 || cost > 0.0 {
284        println!("**Cost**: {}", format_cost(cost));
285    }
286    println!("---");
287
288    if content.trim().is_empty() {
289        println!("(no output captured)");
290        return Ok(());
291    }
292
293    // ── Structured summary ────────────────────────────────────────────────
294    let parsed = parse_output(content);
295    if !parsed.is_empty() {
296        println!();
297        println!("## Summary");
298
299        if !parsed.files_changed.is_empty() {
300            println!();
301            println!("### Files Changed");
302            for f in &parsed.files_changed {
303                println!("- `{f}`");
304            }
305        }
306
307        if !parsed.tool_counts.is_empty() {
308            println!();
309            println!("### Tool Usage");
310            // Sort by count descending, then name ascending
311            let mut counts: Vec<(&String, &usize)> = parsed.tool_counts.iter().collect();
312            counts.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
313            for (tool, count) in &counts {
314                println!("- **{tool}**: {count}");
315            }
316        }
317
318        if !parsed.git_commands.is_empty() {
319            println!();
320            println!("### Git Context");
321            // Deduplicate while preserving order
322            let mut seen = HashSet::new();
323            for cmd in &parsed.git_commands {
324                if seen.insert(cmd.as_str()) {
325                    println!("- `{cmd}`");
326                }
327            }
328        }
329
330        if !parsed.key_messages.is_empty() {
331            println!();
332            println!("### Key Decisions");
333            for msg in &parsed.key_messages {
334                println!("- {msg}");
335            }
336        }
337    }
338
339    // ── Full output ───────────────────────────────────────────────────────
340    println!();
341    println!("## Output");
342    println!();
343
344    let mut formatter = ndjson::Formatter::new(false);
345    for line in content.lines() {
346        ndjson::print(&formatter.format(line));
347    }
348
349    // ── Result ────────────────────────────────────────────────────────────
350    let summary = &task.summary;
351    if !summary.is_empty() {
352        println!();
353        println!("---");
354        println!();
355        println!("## Result");
356        println!();
357
358        let status = if task.status == TaskStatus::Done {
359            "success"
360        } else if task.status == TaskStatus::Blocked {
361            "blocked"
362        } else {
363            "incomplete"
364        };
365
366        println!("**Status**: {status}");
367        println!("**Summary**: {summary}");
368    }
369
370    let error = &task.last_error;
371    if !error.is_empty() {
372        println!();
373        println!("**Error**: {error}");
374    }
375
376    Ok(())
377}
378
379fn export_json(task: &Task, attempt: u32, content: &str) -> anyhow::Result<()> {
380    #[derive(Serialize)]
381    struct SessionExport {
382        task_id: i64,
383        external_id: Option<String>,
384        attempt: u32,
385        agent: Option<String>,
386        model: Option<String>,
387        input_tokens: i64,
388        output_tokens: i64,
389        total_cost_usd: f64,
390        summary: String,
391        last_error: String,
392        status: String,
393        files_changed: Vec<String>,
394        files_read: Vec<String>,
395        tool_counts: BTreeMap<String, usize>,
396        git_commands: Vec<String>,
397        key_messages: Vec<String>,
398        output: Vec<serde_json::Value>,
399    }
400
401    let output_lines: Vec<serde_json::Value> = content
402        .lines()
403        .filter_map(|line| {
404            let trimmed = line.trim();
405            if trimmed.is_empty() {
406                return None;
407            }
408            serde_json::from_str(trimmed).ok()
409        })
410        .collect();
411
412    let parsed = parse_output(content);
413
414    let export = SessionExport {
415        task_id: task.id,
416        external_id: task.external_id.clone(),
417        attempt,
418        agent: task.agent.clone(),
419        model: task.model.clone(),
420        input_tokens: task.input_tokens,
421        output_tokens: task.output_tokens,
422        total_cost_usd: task.total_cost_usd,
423        summary: task.summary.clone(),
424        last_error: task.last_error.clone(),
425        status: task.status.as_str().to_string(),
426        files_changed: parsed.files_changed,
427        files_read: parsed.files_read,
428        tool_counts: parsed.tool_counts,
429        git_commands: parsed.git_commands,
430        key_messages: parsed.key_messages,
431        output: output_lines,
432    };
433
434    println!("{}", serde_json::to_string_pretty(&export)?);
435
436    Ok(())
437}
438
439fn task_id_display(internal_id: i64, external_id: Option<&String>) -> String {
440    match external_id {
441        Some(ext) => format!("{ext}:{internal_id}"),
442        None => format!("internal:{internal_id}"),
443    }
444}
445
446fn format_num(n: i64) -> String {
447    let s = n.to_string();
448    let mut result = String::new();
449    for (i, c) in s.chars().rev().enumerate() {
450        if i > 0 && i % 3 == 0 {
451            result.push(',');
452        }
453        result.push(c);
454    }
455    result.chars().rev().collect()
456}
457
458fn format_duration(secs: f64) -> String {
459    if secs < 60.0 {
460        format!("{secs:.0}s")
461    } else if secs < 3600.0 {
462        let mins = secs / 60.0;
463        if mins < 10.0 {
464            format!("{mins:.1}m")
465        } else {
466            format!("{mins:.0}m")
467        }
468    } else {
469        let hours = secs / 3600.0;
470        format!("{hours:.1}h")
471    }
472}
473
474/// Extract text content from a Claude `assistant` event.
475fn extract_claude_text(v: &serde_json::Value) -> Option<String> {
476    let content = v.get("message")?.get("content")?.as_array()?;
477    let text: String = content
478        .iter()
479        .filter_map(|item| {
480            if item.get("type").and_then(|t| t.as_str()) == Some("text") {
481                item.get("text")
482                    .and_then(|t| t.as_str())
483                    .map(str::to_string)
484            } else {
485                None
486            }
487        })
488        .collect::<String>();
489    if text.is_empty() { None } else { Some(text) }
490}
491
492/// Get a string field from a JSON object.
493fn str_field(v: &serde_json::Value, key: &str) -> Option<String> {
494    v.get(key).and_then(|s| s.as_str()).map(str::to_string)
495}
496
497/// Return the first sentence (or up to `max` chars) of `s`.
498fn first_sentence(s: &str, max: usize) -> String {
499    // Find the end of the first sentence.
500    let sentence_end = s
501        .char_indices()
502        .find(|(_, c)| matches!(c, '.' | '!' | '?'))
503        .map_or(s.len(), |(i, _)| i + 1);
504    let candidate = &s[..sentence_end.min(s.len())];
505    ndjson::truncate(candidate.trim(), max)
506}
507
508#[derive(Debug, Clone, Copy, Default)]
509pub enum ExportFormat {
510    #[default]
511    Markdown,
512    Json,
513    Raw,
514}
515
516impl FromStr for ExportFormat {
517    type Err = String;
518
519    fn from_str(s: &str) -> Result<Self, Self::Err> {
520        match s.to_lowercase().as_str() {
521            "markdown" | "md" => Ok(Self::Markdown),
522            "json" | "js" => Ok(Self::Json),
523            "raw" | "ndjson" => Ok(Self::Raw),
524            _ => Err(format!("unknown format: {s}")),
525        }
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn parse_claude_write_edit() {
535        let content = r#"
536{"type":"tool_use","tool":{"name":"Write","input":{"file_path":"src/lib.rs","content":"..."}}}
537{"type":"tool_use","tool":{"name":"Edit","input":{"file_path":"src/main.rs","old_string":"foo","new_string":"bar"}}}
538{"type":"tool_use","tool":{"name":"Read","input":{"file_path":"Cargo.toml"}}}
539"#;
540        let p = parse_output(content);
541        assert_eq!(p.files_changed, vec!["src/lib.rs", "src/main.rs"]);
542        assert_eq!(p.files_read, vec!["Cargo.toml"]);
543        assert_eq!(p.tool_counts["Write"], 1);
544        assert_eq!(p.tool_counts["Edit"], 1);
545        assert_eq!(p.tool_counts["Read"], 1);
546    }
547
548    #[test]
549    fn parse_claude_git_bash() {
550        let content = r#"
551{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git status"}}}
552{"type":"tool_use","tool":{"name":"Bash","input":{"command":"cargo build"}}}
553{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git commit -m \"fix\""}}}
554"#;
555        let p = parse_output(content);
556        assert_eq!(p.git_commands.len(), 2);
557        assert!(p.git_commands.contains(&"git status".to_string()));
558        assert!(
559            p.git_commands
560                .contains(&"git commit -m \"fix\"".to_string())
561        );
562        assert_eq!(p.tool_counts["Bash"], 3);
563    }
564
565    #[test]
566    fn parse_opencode_tool_use() {
567        let content = r#"
568{"type":"tool_use","part":{"tool":"write","state":{"input":{"file_path":"README.md"}}}}
569{"type":"tool_use","part":{"tool":"bash","state":{"input":{"command":"git diff"}}}}
570"#;
571        let p = parse_output(content);
572        assert_eq!(p.files_changed, vec!["README.md"]);
573        assert_eq!(p.git_commands, vec!["git diff"]);
574    }
575
576    #[test]
577    fn parse_codex_command_execution() {
578        let content = r#"
579{"type":"item.completed","item":{"type":"command_execution","command":"git log --oneline -5"}}
580{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}
581"#;
582        let p = parse_output(content);
583        assert_eq!(p.git_commands, vec!["git log --oneline -5"]);
584        assert_eq!(p.tool_counts["bash"], 2);
585    }
586
587    #[test]
588    fn parse_key_messages_from_assistant() {
589        let content = r#"
590{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"This is a short msg."}]}}
591{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I have analyzed the codebase and found that the main issue is in the authentication module."}]}}
592"#;
593        let p = parse_output(content);
594        // Short message (<= 40 chars) should be filtered out
595        assert_eq!(p.key_messages.len(), 1);
596        assert!(p.key_messages[0].contains("authentication"));
597    }
598
599    #[test]
600    fn parse_key_messages_from_opencode_and_codex() {
601        let long = "x".repeat(41);
602        let content = format!(
603            r#"{{"type":"text","part":{{"text":"{long}"}}}}
604{{"type":"text","text":"{long}"}}
605{{"type":"text","part":{{"text":"short"}}}}
606{{"type":"item.completed","item":{{"type":"agent_message","text":"{long}"}}}}
607{{"type":"item.completed","item":{{"type":"agent_message","text":"short"}}}}
608"#
609        );
610        let p = parse_output(&content);
611        assert_eq!(p.key_messages.len(), 3);
612    }
613
614    #[test]
615    fn parse_key_messages_capped_at_ten() {
616        let line = format!(r#"{{"type":"text","text":"{}"}}"#, "y".repeat(50));
617        let content = vec![line; 15].join("\n");
618        let p = parse_output(&content);
619        assert_eq!(p.key_messages.len(), 10);
620    }
621
622    #[test]
623    fn parse_codex_git_commands_capped_at_twenty() {
624        let line = r#"{"type":"item.completed","item":{"type":"command_execution","command":"git status"}}"#;
625        let content = vec![line; 25].join("\n");
626        let p = parse_output(&content);
627        assert_eq!(p.git_commands.len(), 20);
628        assert_eq!(p.tool_counts["bash"], 25);
629    }
630
631    #[test]
632    fn parse_empty_content() {
633        let p = parse_output("");
634        assert!(p.is_empty());
635    }
636
637    #[test]
638    fn first_sentence_with_period() {
639        let s = "I will fix the bug. Then run tests.";
640        assert_eq!(first_sentence(s, 200), "I will fix the bug.");
641    }
642
643    #[test]
644    fn first_sentence_truncated() {
645        let long = "abcdefghijklmnopqrstuvwxyz".repeat(10);
646        let result = first_sentence(&long, 20);
647        assert!(result.ends_with('…'));
648    }
649
650    #[test]
651    fn first_sentence_cuts_on_char_count_not_bytes() {
652        assert_eq!(first_sentence("ééééé", 3), "ééé…");
653        assert_eq!(first_sentence("ééé", 3), "ééé");
654    }
655
656    #[test]
657    fn first_sentence_without_terminator_keeps_whole_text() {
658        assert_eq!(first_sentence("  no terminator  ", 50), "no terminator");
659        assert_eq!(first_sentence("", 5), "");
660    }
661
662    #[test]
663    fn format_num_thousands() {
664        assert_eq!(format_num(1_234_567), "1,234,567");
665        assert_eq!(format_num(999), "999");
666    }
667
668    #[test]
669    fn format_duration_variants() {
670        assert_eq!(format_duration(45.0), "45s");
671        assert_eq!(format_duration(90.0), "1.5m");
672        assert_eq!(format_duration(600.0), "10m");
673        assert_eq!(format_duration(7200.0), "2.0h");
674    }
675}