regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use std::{collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};

use crate::{
    domain::{
        errors::{AgentError, AgentResult, ErrorCode},
        pi_rpc::{PiRpcCommand, PiRpcResponse},
        protocol::AvailableModel,
    },
    infrastructure::pi_rpc_probe::PiRpcProbe,
    operational::pi_rpc_manager::PiRpcManager,
};
use serde::Deserialize;
use serde_json::Value;

#[derive(Clone)]
pub(crate) struct PiModels {
    command: Vec<String>,
    cwd: PathBuf,
    request_timeout: Duration,
    manager: Arc<PiRpcManager>,
}

impl PiModels {
    pub(crate) fn new(
        command: Vec<String>,
        cwd: PathBuf,
        request_timeout: Duration,
        manager: Arc<PiRpcManager>,
    ) -> Self {
        Self {
            command,
            cwd,
            request_timeout,
            manager,
        }
    }

    pub(crate) async fn list(&self) -> AgentResult<Vec<AvailableModel>> {
        if self.command.is_empty() {
            return Err(AgentError::new(
                ErrorCode::CatalogNotConfigured,
                "Pi RPC command is not configured for model discovery",
            ));
        }
        match self.manager.available_models().await {
            Ok(Some(models)) => return project_available_models(&models),
            Ok(None) => {}
            Err(error) => return Err(catalog_transport_error("active Pi session", error)),
        }

        let response = PiRpcProbe::new(self.command.clone(), self.request_timeout)
            .request(
                &self.cwd,
                PiRpcCommand::GetAvailableModels {
                    id: "__regy:models:probe".into(),
                },
            )
            .await
            .map_err(|error| catalog_transport_error("ephemeral Pi probe", error))?;
        match response {
            PiRpcResponse::GetAvailableModels { data, .. } => {
                project_available_models(&data.models)
            }
            PiRpcResponse::Failure { error, .. } => Err(AgentError::new(
                ErrorCode::CatalogUnavailable,
                format!("Pi get_available_models failed: {error}"),
            )),
            response => Err(AgentError::new(
                ErrorCode::CatalogResponseInvalid,
                format!(
                    "Pi returned {} while waiting for get_available_models",
                    response.command()
                ),
            )),
        }
    }

    pub(crate) fn manager(&self) -> Arc<PiRpcManager> {
        self.manager.clone()
    }
}

fn catalog_transport_error(context: &str, error: AgentError) -> AgentError {
    let code = if error.message().contains("invalid Pi RPC JSON record") {
        ErrorCode::CatalogResponseInvalid
    } else {
        ErrorCode::CatalogUnavailable
    };
    AgentError::new(
        code,
        format!("{context} model discovery failed: {}", error.message()),
    )
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PiModelProjection {
    provider: String,
    id: String,
    name: String,
    reasoning: bool,
    #[serde(default)]
    thinking_level_map: Option<BTreeMap<String, Option<String>>>,
}

pub(crate) fn project_available_models(raw: &[Value]) -> AgentResult<Vec<AvailableModel>> {
    if raw.is_empty() {
        return Err(AgentError::new(
            ErrorCode::CatalogAuthenticationFailed,
            "Pi returned no authenticated models",
        ));
    }

    raw.iter()
        .enumerate()
        .map(|(index, value)| {
            let model =
                serde_json::from_value::<PiModelProjection>(value.clone()).map_err(|err| {
                    AgentError::new(
                        ErrorCode::CatalogResponseInvalid,
                        format!("invalid Pi model at index {index}: {err}"),
                    )
                })?;
            validate_non_empty(&model.provider, "provider", index)?;
            validate_non_empty(&model.id, "id", index)?;
            validate_non_empty(&model.name, "name", index)?;
            Ok(AvailableModel {
                provider: model.provider,
                id: model.id,
                name: model.name,
                thinking_levels: thinking_levels(
                    model.reasoning,
                    model.thinking_level_map.as_ref(),
                ),
            })
        })
        .collect()
}

fn validate_non_empty(value: &str, field: &str, index: usize) -> AgentResult<()> {
    if value.trim().is_empty() {
        return Err(AgentError::new(
            ErrorCode::CatalogResponseInvalid,
            format!("Pi model at index {index} has an empty {field}"),
        ));
    }
    Ok(())
}

fn thinking_levels(reasoning: bool, map: Option<&BTreeMap<String, Option<String>>>) -> Vec<String> {
    if !reasoning {
        return vec!["off".into()];
    }

    let mut levels = ["off", "minimal", "low", "medium", "high"]
        .into_iter()
        .filter(|level| {
            map.and_then(|values| values.get(*level))
                .is_none_or(Option::is_some)
        })
        .map(str::to_string)
        .collect::<Vec<_>>();
    for level in ["xhigh", "max"] {
        if map
            .and_then(|values| values.get(level))
            .is_some_and(Option::is_some)
        {
            levels.push(level.into());
        }
    }
    levels
}