1use std::pin::Pin;
2use std::process::Stdio;
3
4use anyhow::Context as _;
5use async_stream::try_stream;
6use futures::Stream;
7use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
8use tokio::process::Command;
9
10use crate::config::CliBackendConfig;
11use crate::session::Turn;
12
13pub type ReplyStream = Pin<Box<dyn Stream<Item = anyhow::Result<String>> + Send>>;
15
16#[derive(Debug, Clone)]
20pub struct CompletionRequest {
21 pub system_prompt: String,
22 pub turns: Vec<Turn>,
25}
26
27#[async_trait::async_trait]
28pub trait Backend: Send + Sync {
29 async fn complete(&self, req: CompletionRequest) -> anyhow::Result<ReplyStream>;
30}
31
32#[derive(Debug, Clone)]
36pub struct CliBackend {
37 pub cmd: String,
38 pub args: Vec<String>,
39}
40
41impl From<&CliBackendConfig> for CliBackend {
42 fn from(cfg: &CliBackendConfig) -> Self {
43 Self {
44 cmd: cfg.cmd.clone(),
45 args: cfg.args.clone(),
46 }
47 }
48}
49
50#[async_trait::async_trait]
51impl Backend for CliBackend {
52 async fn complete(&self, req: CompletionRequest) -> anyhow::Result<ReplyStream> {
53 let prompt = render_cli_prompt(&req.system_prompt, &req.turns);
54 let (cmd, args) = resolve_cmd(&self.cmd, &self.args);
55
56 let mut child = Command::new(&cmd)
57 .args(&args)
58 .stdin(Stdio::piped())
59 .stdout(Stdio::piped())
60 .stderr(Stdio::piped())
61 .kill_on_drop(true)
62 .spawn()
63 .with_context(|| format!("spawn {cmd}"))?;
64
65 let mut stdin = child.stdin.take().context("take stdin")?;
66 let stdout = child.stdout.take().context("take stdout")?;
67 let stderr = child.stderr.take().context("take stderr")?;
68
69 let writer = tokio::spawn(async move {
72 let _ = stdin.write_all(prompt.as_bytes()).await;
73 let _ = stdin.shutdown().await;
74 });
75
76 let cmd_for_log = cmd.clone();
77 tokio::spawn(async move {
78 let mut buf = String::new();
79 let mut reader = BufReader::new(stderr);
80 if reader.read_to_string(&mut buf).await.is_ok() && !buf.trim().is_empty() {
81 tracing::debug!(target: "kotonoha::backend", cmd = %cmd_for_log, "stderr: {buf}");
82 }
83 });
84
85 let stream = try_stream! {
86 let mut reader = BufReader::new(stdout);
87 let mut buf = [0u8; 4096];
88 loop {
89 let n = reader.read(&mut buf).await?;
90 if n == 0 { break; }
91 let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
92 yield chunk;
93 }
94 let status = child.wait().await?;
95 if !status.success() {
96 Err(anyhow::anyhow!("{cmd} exited with status {status}"))?;
97 }
98 let _ = writer.await;
99 };
100
101 Ok(Box::pin(stream))
102 }
103}
104
105pub fn render_cli_prompt(system_prompt: &str, turns: &[Turn]) -> String {
110 let mut buf = String::with_capacity(1024 + turns.len() * 64);
111 buf.push_str("[SYSTEM]\n");
112 buf.push_str(system_prompt.trim());
113 buf.push_str("\n\n[CONVERSATION]\n");
114 if turns.is_empty() {
115 buf.push_str("(this is the first turn — greet the student warmly in English and ask them a simple opening question.)\n");
116 } else {
117 for turn in turns {
118 match turn {
119 Turn::Student(t) => {
120 buf.push_str("Student: ");
121 buf.push_str(t.trim());
122 buf.push('\n');
123 }
124 Turn::Teacher(t) => {
125 buf.push_str("Teacher: ");
126 buf.push_str(t.trim());
127 buf.push('\n');
128 }
129 }
130 }
131 }
132 buf.push_str("\n[TASK]\nReply as Kotonoha-sensei (Teacher). Output only the teacher's next utterance — no labels, no quotes, no stage directions.\n");
133 buf
134}
135
136fn resolve_cmd(cmd: &str, args: &[String]) -> (String, Vec<String>) {
146 match which::which(cmd) {
147 Ok(resolved) => {
148 tracing::info!(
149 target: "kotonoha::backend",
150 "resolved cmd `{}` -> {}",
151 cmd,
152 resolved.display()
153 );
154 let ext = resolved
155 .extension()
156 .and_then(|s| s.to_str())
157 .map(|s| s.to_ascii_lowercase());
158 let resolved_str = resolved.to_string_lossy().into_owned();
159 match ext.as_deref() {
160 Some("ps1") => {
161 let mut full = vec![
162 "-NoProfile".into(),
163 "-ExecutionPolicy".into(),
164 "Bypass".into(),
165 "-File".into(),
166 resolved_str,
167 ];
168 full.extend(args.iter().cloned());
169 tracing::info!(target: "kotonoha::backend", "→ powershell -File wrap");
170 ("powershell".into(), full)
171 }
172 Some("cmd") | Some("bat") => {
173 let mut full = vec!["/C".into(), resolved_str];
174 full.extend(args.iter().cloned());
175 tracing::info!(target: "kotonoha::backend", "→ cmd.exe /C wrap");
176 ("cmd.exe".into(), full)
177 }
178 _ => (resolved_str, args.to_vec()),
179 }
180 }
181 Err(e) => {
182 tracing::warn!(
183 target: "kotonoha::backend",
184 "`which({cmd})` failed: {e}. Looked in PATH = {:?}",
185 std::env::var("PATH").unwrap_or_default()
186 );
187 (cmd.to_string(), args.to_vec())
188 }
189 }
190}