Skip to main content

orch/cli/
session.rs

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