Skip to main content

omni_dev/cli/voice/
reflect.rs

1//! `omni-dev voice reflect` — feed a `transcript.jsonl` through the
2//! configured [`AiClient`](crate::claude::ai::AiClient) and emit
3//! reflection events (per the #799 schema) to `events.jsonl`.
4//!
5//! Input precedence: `<transcript>` path arg → `--session <id>` →
6//! stdin. A literal `-` as the path also means stdin. When `--session`
7//! is given, events are appended to that session's `events.jsonl` and
8//! `meta.last_reflected_event_id` is advanced; otherwise events stream
9//! to stdout.
10
11use std::io::Write;
12use std::path::PathBuf;
13
14use anyhow::{bail, Result};
15use clap::Parser;
16
17use crate::claude::client::create_default_claude_client;
18use crate::voice::clock::SystemClock;
19use crate::voice::det::SystemUlidRng;
20use crate::voice::reflect::{run_reflect, ReflectOptions, TranscriptSource};
21
22/// Reflects on a transcript and emits reflection events.
23///
24/// The transcript source is resolved in this order: positional
25/// `<transcript>` argument → `--session <id>` (reads
26/// `~/.omni-dev/voice/<id>/transcript.jsonl` incrementally) → stdin.
27/// A literal `-` as the positional argument also means stdin.
28///
29/// When `--session` is given, the resulting events are appended to that
30/// session's `events.jsonl` and `meta.last_reflected_event_id` is
31/// advanced so the next invocation only reflects on newly-arrived
32/// transcript events; otherwise events stream to stdout.
33#[derive(Parser)]
34pub struct ReflectCommand {
35    /// Path to a `transcript.jsonl` file. Pass `-` for stdin. Omit to
36    /// fall back to `--session` or stdin.
37    #[arg(value_name = "TRANSCRIPT")]
38    pub transcript: Option<PathBuf>,
39
40    /// Reflect against a named voice session under
41    /// `~/.omni-dev/voice/<id>/`. Mutually exclusive with a positional
42    /// transcript path.
43    #[arg(long)]
44    pub session: Option<String>,
45}
46
47impl ReflectCommand {
48    /// Executes the reflect command. Async because the AI invocation
49    /// is async; the caller dispatches inside `#[tokio::main]`.
50    pub async fn execute(self) -> Result<()> {
51        let source = resolve_source(self.transcript, self.session)?;
52        // Fail fast on missing input before paying the AI-client
53        // construction cost — otherwise a typo'd path is masked by
54        // unrelated credential errors ("API key not found", etc.) on
55        // hosts without the configured AI backend.
56        if let TranscriptSource::Path(p) = &source {
57            if !p.exists() {
58                bail!("transcript file does not exist: {}", p.display());
59            }
60        }
61        let ai = create_default_claude_client(None, None)
62            .await?
63            .into_ai_client();
64        let opts = ReflectOptions {
65            source,
66            ulid_rng: Box::new(SystemUlidRng),
67            clock: Box::new(SystemClock),
68            ai,
69            session_root_override: None,
70        };
71        // Buffer events in memory rather than holding the StdoutLock
72        // across the AI await — the lock guard is `!Send`, which
73        // poisons this future for use in a multi-thread Tokio runtime.
74        // For session-backed runs the buffer stays empty (events go to
75        // events.jsonl, not stdout).
76        let mut buf: Vec<u8> = Vec::new();
77        run_reflect(opts, &mut buf).await?;
78        let mut stdout = std::io::stdout().lock();
79        stdout.write_all(&buf)?;
80        stdout.flush()?;
81        Ok(())
82    }
83}
84
85fn resolve_source(
86    transcript: Option<PathBuf>,
87    session: Option<String>,
88) -> Result<TranscriptSource> {
89    match (transcript, session) {
90        (Some(_), Some(_)) => {
91            bail!("voice reflect: pass either a transcript path or --session, not both")
92        }
93        (Some(path), None) => {
94            if path.as_os_str() == "-" {
95                Ok(TranscriptSource::Stdin)
96            } else {
97                Ok(TranscriptSource::Path(path))
98            }
99        }
100        (None, Some(id)) => Ok(TranscriptSource::Session(id)),
101        (None, None) => Ok(TranscriptSource::Stdin),
102    }
103}
104
105#[cfg(test)]
106#[allow(clippy::unwrap_used, clippy::expect_used)]
107mod tests {
108    use super::*;
109    use clap::Parser;
110
111    #[derive(Parser)]
112    struct TestCli {
113        #[command(flatten)]
114        reflect: ReflectCommand,
115    }
116
117    #[test]
118    fn parses_no_args_defaults_to_stdin() {
119        let cli = TestCli::try_parse_from(["test"]).unwrap();
120        assert!(cli.reflect.transcript.is_none());
121        assert!(cli.reflect.session.is_none());
122        let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
123        assert!(matches!(source, TranscriptSource::Stdin));
124    }
125
126    #[test]
127    fn parses_positional_path() {
128        let cli = TestCli::try_parse_from(["test", "/tmp/t.jsonl"]).unwrap();
129        let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
130        assert!(
131            matches!(source, TranscriptSource::Path(p) if p == std::path::Path::new("/tmp/t.jsonl"))
132        );
133    }
134
135    #[test]
136    fn parses_dash_as_stdin() {
137        let cli = TestCli::try_parse_from(["test", "-"]).unwrap();
138        let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
139        assert!(matches!(source, TranscriptSource::Stdin));
140    }
141
142    #[test]
143    fn parses_session_flag() {
144        let cli = TestCli::try_parse_from(["test", "--session", "morning"]).unwrap();
145        let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
146        match source {
147            TranscriptSource::Session(s) => assert_eq!(s, "morning"),
148            other => panic!("expected Session, got {other:?}"),
149        }
150    }
151
152    #[test]
153    fn rejects_both_transcript_and_session() {
154        let cli =
155            TestCli::try_parse_from(["test", "/tmp/t.jsonl", "--session", "morning"]).unwrap();
156        let err = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap_err();
157        assert!(
158            err.to_string()
159                .contains("either a transcript path or --session, not both"),
160            "got: {err}"
161        );
162    }
163}