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