Something went wrong. Try again.
Coding agent with a memory of all things
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375//! The S9 local-model commands: `translate` (role `translate`), `say`//! (role `speech`), `transcribe` (role `dictation`), and the hidden//! `tiny-worker` entry the tiny client spawns. A role resolves to the first//! available model of its chain (`eukhe_core::tiny`); with none it fails//! naming the chain and the download command. Speech and dictation are//! local only; a translate chain may end on the default provider model.
use std::future::Future;use std::path::{Path, PathBuf};use std::process::ExitCode;
use eukhe_chord::context::BACKGROUND_CONTEXT;use eukhe_core::settings::SettingsManager;use eukhe_core::tiny::{ run_text, Candidate, Pick, Role, RoleSettings, RoleUnavailable, TextBackends, TinyClient, TinyHome,};use eukhe_tiny::{CatalogModel, SpeakRequest, TranscribeRequest};use eukhe_types::pi_ai::Model;
use crate::config::{get_agent_dir, APP_NAME};
/// The audio players `say` tries, in order.const PLAYERS: [&str; 3] = ["pw-play", "paplay", "aplay"];
/// `eukhe tiny-worker <model> --backend onnx ...`: the shared worker of one/// model (spawned by the tiny client, never by hand).pub fn run_tiny_worker(args: &[String]) -> i32 { let runtime = match tokio::runtime::Builder::new_multi_thread() .enable_all() .build() { Ok(runtime) => runtime, Err(error) => { eprintln!("Error: {error}"); return 1; } }; let code = runtime.block_on(eukhe_tiny::run_worker(args, eukhe_tiny_onnx::open)); i32::from(code != ExitCode::SUCCESS)}
/// What every role command reads: the settings, the tiny home, the client.struct RoleContext { agent_dir: PathBuf, settings: SettingsManager, home: TinyHome, client: TinyClient,}
impl RoleContext { fn open() -> Result<Self, String> { let cwd = std::env::current_dir().map_err(|error| format!("Cannot read cwd: {error}"))?; Self::in_dirs(&cwd, get_agent_dir()) }
/// The settings of `cwd` and the tiny home and client of `agent_dir`. fn in_dirs(cwd: &Path, agent_dir: PathBuf) -> Result<Self, String> { let settings = SettingsManager::create(cwd, &agent_dir); let worker_exe = eukhe_core::tiny::worker_exe() .map_err(|error| format!("Cannot find the {APP_NAME} executable: {error}"))?; let client = eukhe_core::tiny::client(&agent_dir, settings.cvars(), worker_exe); Ok(Self { home: TinyHome::under(&agent_dir), agent_dir, settings, client, }) }
fn role_settings(&self, role: Role) -> RoleSettings { eukhe_core::tiny::role_settings(self.settings.cvars(), role) }
/// The local model `role` resolves to (speech and dictation). fn local_model(&self, role: Role) -> Result<&'static CatalogModel, String> { let settings = self.role_settings(role); match eukhe_core::tiny::resolve(role, &settings, &self.home, "") { Pick::Use(candidate) => match candidate.local_model() { Some(model) => Ok(model), None => Err(format!("the {} role takes local models only", role.id())), }, Pick::Unavailable => Err(RoleUnavailable::new(role, &settings, "").to_string()), } }}
fn block_on<T>(future: impl Future<Output = T>) -> Result<T, String> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|error| error.to_string())?; Ok(runtime.block_on(future))}
/// Prints `result`'s error and maps it to an exit code.fn exit(result: Result<(), String>) -> i32 { match result { Ok(()) => 0, Err(error) => { eprintln!("Error: {error}"); 1 } }}
/// `eukhe translate <lang> <text...>`.pub fn run_translate_command(args: &[String]) -> i32 { exit(translate(args))}
fn translate(args: &[String]) -> Result<(), String> { let usage = || format!("Usage: {APP_NAME} translate <lang> <text...>"); let (language, words) = args.split_first().ok_or_else(usage)?; let text = words.join(" "); if text.trim().is_empty() { return Err(usage()); } let context = RoleContext::open()?; let settings = context.role_settings(Role::Translate); block_on(async { // Local models first; the default provider model only when the // chain gets that far. let (candidate, models, session) = match eukhe_core::tiny::resolve(Role::Translate, &settings, &context.home, "") { Pick::Use(candidate) => (candidate, None, None), Pick::Unavailable => { let (models, session) = default_model(&context).await?; let selector = session .as_ref() .map(eukhe_core::tiny::model_selector) .unwrap_or_default(); match eukhe_core::tiny::resolve( Role::Translate, &settings, &context.home, &selector, ) { Pick::Use(candidate) => (candidate, Some(models), session), Pick::Unavailable => { return Err(RoleUnavailable::new(Role::Translate, &settings, &selector) .to_string()) } } } }; let backends = TextBackends { client: &context.client, models: models.as_ref(), session_model: session.as_ref(), }; let reply = run_text( &candidate, eukhe_tiny::translate_request(language, &text), &backends, ) .await .map_err(|error| error.to_string())?; let translation = eukhe_tiny::translation_text(language, &text, &reply).map_err(|error| { let model = match &candidate { Candidate::Local(id) => format!("{}{id}", eukhe_tiny::LOCAL_PREFIX), Candidate::Remote(selector) => selector.clone(), }; format!("{error} (model {model}; modelRoles.translate picks another)") })?; println!("{translation}"); Ok(()) })?}
/// The model collection and the default provider model (the one a fresh/// session would use), when one has credentials. The models.json errors/// and a saved default model that did not run go to stderr.#[tracing::instrument(skip_all)]async fn default_model( context: &RoleContext,) -> Result<(eukhe_ai::models::Models, Option<Model>), String> { let loaded = eukhe_core::durable::create_models(&context.agent_dir); if !loaded.diagnostics.is_empty() { eprintln!( "Warning: errors loading models.json:\n{}", loaded.diagnostics.join("\n") ); } let models = loaded.models; let resolved = eukhe_core::durable::resolve_session_model( &models, &context.settings, None, None, &BACKGROUND_CONTEXT, ) .await .map_err(|error| error.to_string())?; if let Some(substitution) = resolved .as_ref() .and_then(|resolved| resolved.substituted.as_ref()) { eprintln!( "Warning: the saved default model {}/{} did not run: {}.", substitution.wanted.provider, substitution.wanted.model_id, substitution.reason ); } let model = resolved .and_then(|resolved| models.get_model(&resolved.model.provider, &resolved.model.model_id)); Ok((models, model))}
/// `say` arguments.#[derive(Debug, PartialEq)]struct SayArgs { text: String, out: Option<PathBuf>, voice: Option<String>,}
fn parse_say(args: &[String]) -> Result<SayArgs, String> { let usage = || format!("Usage: {APP_NAME} say <text...> [--out <file.wav>] [--voice <id>]"); let mut words = Vec::new(); let mut out = None; let mut voice = None; let mut rest = args.iter(); while let Some(arg) = rest.next() { match arg.as_str() { "--out" => out = Some(PathBuf::from(rest.next().ok_or_else(usage)?)), "--voice" => voice = Some(rest.next().ok_or_else(usage)?.clone()), _ => words.push(arg.as_str()), } } let text = words.join(" "); if text.trim().is_empty() { return Err(usage()); } Ok(SayArgs { text, out, voice })}
/// `eukhe say <text...> [--out file.wav] [--voice id]`.pub fn run_say_command(args: &[String]) -> i32 { exit(say(args))}
fn say(args: &[String]) -> Result<(), String> { let args = parse_say(args)?; let context = RoleContext::open()?; let model = context.local_model(Role::Speech)?; let request = SpeakRequest { text: args.text, voice: args .voice .unwrap_or_else(|| eukhe_core::tiny::local_voice(context.settings.cvars())), speed: 1.0, }; let audio = block_on(context.client.speak(model, request))?.map_err(|error| error.to_string())?; match args.out { Some(out) => eukhe_tiny_onnx::write_wav(&out, &audio).map_err(|error| error.to_string()), None => play(&audio), }}
/// The first of [`PLAYERS`] on `PATH`.fn find_player(path: Option<&std::ffi::OsStr>) -> Option<PathBuf> { let dirs: Vec<PathBuf> = std::env::split_paths(path?).collect(); PLAYERS.iter().find_map(|player| { dirs.iter() .map(|dir| dir.join(player)) .find(|candidate| candidate.is_file()) })}
/// Plays `audio` through a temp WAV with the first player found.fn play(audio: &eukhe_tiny::Audio) -> Result<(), String> { let Some(player) = find_player(std::env::var_os("PATH").as_deref()) else { return Err(format!( "no audio player found ({}); write the speech to a file with --out <file.wav>", PLAYERS.join(", ") )); }; let wav = std::env::temp_dir().join(format!("eukhe-say-{}.wav", std::process::id())); eukhe_tiny_onnx::write_wav(&wav, audio).map_err(|error| error.to_string())?; let status = std::process::Command::new(&player).arg(&wav).status(); let _ = std::fs::remove_file(&wav); match status { Ok(status) if status.success() => Ok(()), Ok(status) => Err(format!("{} exited with {status}", player.display())), Err(error) => Err(format!("{}: {error}", player.display())), }}
/// `transcribe` arguments.#[derive(Debug, PartialEq)]struct TranscribeArgs { file: PathBuf, language: String,}
fn parse_transcribe(args: &[String]) -> Result<TranscribeArgs, String> { let usage = || format!("Usage: {APP_NAME} transcribe <file.wav> [--language <code>]"); let mut file = None; let mut language = String::new(); let mut rest = args.iter(); while let Some(arg) = rest.next() { match arg.as_str() { "--language" => language.clone_from(rest.next().ok_or_else(usage)?), _ if file.is_none() => file = Some(PathBuf::from(arg)), _ => return Err(usage()), } } Ok(TranscribeArgs { file: file.ok_or_else(usage)?, language, })}
/// `eukhe transcribe <file.wav> [--language xx]`.pub fn run_transcribe_command(args: &[String]) -> i32 { exit(transcribe(args))}
fn transcribe(args: &[String]) -> Result<(), String> { let args = parse_transcribe(args)?; let cwd = std::env::current_dir().map_err(|error| format!("Cannot read cwd: {error}"))?; let text = block_on(transcribe_file( cwd, get_agent_dir(), args.file, args.language, ))??; println!("{text}"); Ok(())}
/// The trimmed text of the WAV file `wav`, read by the `dictation` role's/// local model (settings of `cwd` and `agent_dir`); `language` empty/// detects the language.#[tracing::instrument]async fn transcribe_file( cwd: PathBuf, agent_dir: PathBuf, wav: PathBuf, language: String,) -> Result<String, String> { let audio = eukhe_tiny_onnx::read_wav(&wav).map_err(|error| error.to_string())?; let context = RoleContext::in_dirs(&cwd, agent_dir)?; let model = context.local_model(Role::Dictation)?; let transcript = context .client .transcribe(model, TranscribeRequest { audio, language }) .await .map_err(|error| error.to_string())?; Ok(transcript.text.trim().to_owned())}
/// The TUI's dictation transcriber: [`transcribe_file`] in the session's/// cwd and agent dir, with the language detected.pub(crate) struct RoleTranscriber { pub(crate) cwd: PathBuf, pub(crate) agent_dir: PathBuf,}
impl eukhe_tui::dictation::Transcriber for RoleTranscriber { fn transcribe(&self, wav: PathBuf) -> eukhe_tui::dictation::DictationFuture<String> { Box::pin(transcribe_file( self.cwd.clone(), self.agent_dir.clone(), wav, String::new(), )) }}
#[cfg(test)]mod tests;