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                    && text.len() > 40
119                    && key_messages.len() < 10
120                {
121                    key_messages.push(first_sentence(&text, 160));
122                }
123            }
124
125            "tool_use" if v.get("tool").is_some() => {
126                // Claude tool_use: {"type":"tool_use","tool":{"name":"...","input":{...}}}
127                if let Some(tool) = v.get("tool") {
128                    let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("?");
129                    *tool_counts.entry(name.to_string()).or_default() += 1;
130                    let input = tool.get("input");
131                    record_file_or_command(
132                        name,
133                        input,
134                        &mut files_changed,
135                        &mut files_read,
136                        &mut git_commands,
137                    );
138                }
139            }
140
141            // ── OpenCode ──────────────────────────────────────────────────
142            "text" => {
143                let text = v
144                    .get("part")
145                    .and_then(|p| p.get("text"))
146                    .and_then(|t| t.as_str())
147                    .or_else(|| v.get("text").and_then(|t| t.as_str()))
148                    .unwrap_or("");
149                if text.len() > 40 && key_messages.len() < 10 {
150                    key_messages.push(first_sentence(text, 160));
151                }
152            }
153
154            "tool_use" if v.get("part").is_some() => {
155                // OpenCode tool_use: {"type":"tool_use","part":{"tool":"...","state":{"input":{...}}}}
156                if let Some(part) = v.get("part") {
157                    let name = part.get("tool").and_then(|t| t.as_str()).unwrap_or("?");
158                    *tool_counts.entry(name.to_string()).or_default() += 1;
159                    let input = part.get("state").and_then(|s| s.get("input"));
160                    record_file_or_command(
161                        name,
162                        input,
163                        &mut files_changed,
164                        &mut files_read,
165                        &mut git_commands,
166                    );
167                }
168            }
169
170            // ── Codex ─────────────────────────────────────────────────────
171            "item.completed" => {
172                if let Some(item) = v.get("item") {
173                    let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
174                    match item_type {
175                        "agent_message" => {
176                            let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
177                            if text.len() > 40 && key_messages.len() < 10 {
178                                key_messages.push(first_sentence(text, 160));
179                            }
180                        }
181                        "command_execution" => {
182                            let cmd = item.get("command").and_then(|c| c.as_str()).unwrap_or("");
183                            *tool_counts.entry("bash".to_string()).or_default() += 1;
184                            let first_line = cmd.lines().next().unwrap_or("").trim();
185                            if (first_line.starts_with("git ") || first_line == "git")
186                                && git_commands.len() < 20
187                            {
188                                git_commands.push(ndjson::truncate(first_line, 120));
189                            }
190                        }
191                        _ => {}
192                    }
193                }
194            }
195
196            _ => {}
197        }
198    }
199
200    ParsedOutput {
201        files_changed: files_changed
202            .into_iter()
203            .chain(ndjson::extract_files_changed(content))
204            .collect::<BTreeSet<_>>()
205            .into_iter()
206            .collect(),
207        files_read: files_read.into_iter().collect(),
208        tool_counts,
209        git_commands,
210        key_messages,
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_empty_content() {
601        let p = parse_output("");
602        assert!(p.is_empty());
603    }
604
605    #[test]
606    fn first_sentence_with_period() {
607        let s = "I will fix the bug. Then run tests.";
608        assert_eq!(first_sentence(s, 200), "I will fix the bug.");
609    }
610
611    #[test]
612    fn first_sentence_truncated() {
613        let long = "abcdefghijklmnopqrstuvwxyz".repeat(10);
614        let result = first_sentence(&long, 20);
615        assert!(result.ends_with('…'));
616    }
617
618    #[test]
619    fn first_sentence_cuts_on_char_count_not_bytes() {
620        assert_eq!(first_sentence("ééééé", 3), "ééé…");
621        assert_eq!(first_sentence("ééé", 3), "ééé");
622    }
623
624    #[test]
625    fn first_sentence_without_terminator_keeps_whole_text() {
626        assert_eq!(first_sentence("  no terminator  ", 50), "no terminator");
627        assert_eq!(first_sentence("", 5), "");
628    }
629
630    #[test]
631    fn format_num_thousands() {
632        assert_eq!(format_num(1_234_567), "1,234,567");
633        assert_eq!(format_num(999), "999");
634    }
635
636    #[test]
637    fn format_duration_variants() {
638        assert_eq!(format_duration(45.0), "45s");
639        assert_eq!(format_duration(90.0), "1.5m");
640        assert_eq!(format_duration(600.0), "10m");
641        assert_eq!(format_duration(7200.0), "2.0h");
642    }
643}