Skip to main content

orch/cli/
session.rs

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