Skip to main content

orch/cli/
session.rs

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