velixar 0.1.0

Governed, auditable memory for AI agents. Official Rust client for the Velixar API.
Documentation
//! Governed, auditable memory for AI agents — the official Rust client.
//!
//! ```no_run
//! use velixar::{Velixar, StoreOpts, SearchOpts};
//!
//! # async fn run() -> velixar::Result<()> {
//! let client = Velixar::new("vlx_your_key")?;
//!
//! client
//!     .store("Alex is a vegetarian and is allergic to nuts.",
//!            StoreOpts::new().user("alex").tags(["diet", "allergy"]))
//!     .await?;
//!
//! let hits = client
//!     .search("what can I cook for dinner?", SearchOpts::new().user("alex"))
//!     .await?;
//!
//! for m in hits.memories {
//!     println!("{} ({:.2})", m.content, m.score.unwrap_or_default());
//! }
//! # Ok(())
//! # }
//! ```
//!
//! # One key, many users
//!
//! A key belongs to your workspace; `user` scopes a memory to one of *your* end
//! users. A search scoped to a user returns only that user's memories — enforced
//! server-side. This is the model all the Velixar SDKs are built around.
//!
//! # Scopes
//!
//! Keys carry scopes. `memory:read` and `memory:write` are granted by default;
//! [`delete`](Velixar::delete) needs `memory:delete`, which is opt-in because it is
//! irreversible. Calling it without that scope returns [`Error::Scope`] — not a
//! silent no-op, and not a 404.

mod error;

pub use error::{ApiError, Error, Result};

use error::ErrorEnvelope;
use serde::{Deserialize, Serialize};

const DEFAULT_BASE_URL: &str = "https://api.velixarai.com/v1";

/// A stored memory.
///
/// Fields are `Option` exactly where the API genuinely omits them — modelled from
/// real responses rather than an idealised schema, so a missing `score` on a
/// non-search read does not turn into a decode error.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Memory {
    pub id: String,
    pub content: String,
    /// Similarity score. Present on search hits; absent elsewhere.
    #[serde(default)]
    pub score: Option<f32>,
    #[serde(default)]
    pub tags: Vec<String>,
    #[serde(default)]
    pub tier: Option<u8>,
    /// The end user this memory belongs to, if it was stored for one.
    #[serde(default)]
    pub user_id: Option<String>,
    /// Provenance: `agent`, `user`, `upload`, `session`, `connected`, …
    #[serde(default)]
    pub source_class: Option<String>,
    #[serde(default)]
    pub created_at: Option<String>,
}

/// The result of a [`search`](Velixar::search).
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct SearchResult {
    #[serde(default)]
    pub memories: Vec<Memory>,
    #[serde(default)]
    pub count: usize,
}

/// Options for [`store`](Velixar::store).
#[derive(Debug, Default, Clone, Serialize)]
pub struct StoreOpts {
    #[serde(rename = "user_id", skip_serializing_if = "Option::is_none")]
    user_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    tier: Option<u8>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
}

impl StoreOpts {
    pub fn new() -> Self {
        Self::default()
    }
    /// Attribute this memory to one of your end users.
    pub fn user(mut self, user_id: impl Into<String>) -> Self {
        self.user_id = Some(user_id.into());
        self
    }
    /// 0 = pinned, 1 = session, 2 = semantic, 3 = org.
    pub fn tier(mut self, tier: u8) -> Self {
        self.tier = Some(tier);
        self
    }
    pub fn tags<I, S>(mut self, tags: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.tags = tags.into_iter().map(Into::into).collect();
        self
    }
}

/// Options for [`search`](Velixar::search).
#[derive(Debug, Default, Clone)]
pub struct SearchOpts {
    user_id: Option<String>,
    limit: Option<usize>,
}

impl SearchOpts {
    pub fn new() -> Self {
        Self::default()
    }
    /// Scope the search to one end user.
    pub fn user(mut self, user_id: impl Into<String>) -> Self {
        self.user_id = Some(user_id.into());
        self
    }
    pub fn limit(mut self, limit: usize) -> Self {
        self.limit = Some(limit);
        self
    }
}

#[derive(Serialize)]
struct StoreBody<'a> {
    content: &'a str,
    #[serde(flatten)]
    opts: StoreOpts,
}

/// `POST /v1/memory` returns a FLAT `{"id":"…","stored":true}` — not an envelope.
/// Captured from production. Do not "tidy" this into `{"data":{"id":…}}`.
#[derive(Deserialize)]
struct StoreResponse {
    id: String,
}

#[derive(Deserialize)]
struct DeleteResponse {
    #[serde(default)]
    deleted: bool,
}

/// The Velixar client. Cheap to clone; wraps a pooled HTTP client.
#[derive(Debug, Clone)]
pub struct Velixar {
    http: reqwest::Client,
    base_url: String,
    api_key: String,
}

impl Velixar {
    /// Create a client against the hosted API.
    pub fn new(api_key: impl Into<String>) -> Result<Self> {
        Self::builder(api_key).build()
    }

    /// Configure a client (custom base URL, your own HTTP client).
    pub fn builder(api_key: impl Into<String>) -> Builder {
        Builder {
            api_key: api_key.into(),
            base_url: DEFAULT_BASE_URL.to_string(),
            http: None,
        }
    }

    /// Store a memory. Returns its id.
    pub async fn store(&self, content: &str, opts: StoreOpts) -> Result<String> {
        let body = StoreBody { content, opts };
        let res: StoreResponse = self
            .send(self.req(reqwest::Method::POST, "/memory").json(&body))
            .await?;
        Ok(res.id)
    }

    /// Search by meaning.
    pub async fn search(&self, query: &str, opts: SearchOpts) -> Result<SearchResult> {
        let mut req = self
            .req(reqwest::Method::GET, "/memory/search")
            .query(&[("query", query)]);
        if let Some(u) = &opts.user_id {
            req = req.query(&[("user_id", u.as_str())]);
        }
        if let Some(l) = opts.limit {
            req = req.query(&[("limit", l.to_string().as_str())]);
        }
        self.send(req).await
    }

    /// Delete a memory. Requires a key with `memory:delete`; without it this
    /// returns [`Error::Scope`].
    pub async fn delete(&self, id: &str) -> Result<bool> {
        let res: DeleteResponse = self
            .send(self.req(reqwest::Method::DELETE, &format!("/memory/{id}")))
            .await?;
        Ok(res.deleted)
    }

    /// Service health.
    pub async fn health(&self) -> Result<serde_json::Value> {
        self.send(self.req(reqwest::Method::GET, "/health")).await
    }

    fn req(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
        self.http
            .request(method, format!("{}{}", self.base_url, path))
            .bearer_auth(&self.api_key)
    }

    /// Send, then map the response. Errors decode from the documented envelope; a
    /// body that does not match it becomes [`Error::Decode`] rather than a panic
    /// or a misleading success.
    async fn send<T: serde::de::DeserializeOwned>(
        &self,
        req: reqwest::RequestBuilder,
    ) -> Result<T> {
        let res = req.send().await?;
        let status = res.status();
        let text = res.text().await?;

        if !status.is_success() {
            return Err(match serde_json::from_str::<ErrorEnvelope>(&text) {
                Ok(env) => Error::from_status(status.as_u16(), env.error),
                // Not our envelope — a proxy 502, an HTML error page. Say so plainly
                // rather than pretending we understood it.
                Err(_) => Error::Api {
                    status: status.as_u16(),
                    body: ApiError {
                        code: format!("HTTP_{}", status.as_u16()),
                        message: text.chars().take(200).collect(),
                    },
                },
            });
        }

        serde_json::from_str(&text).map_err(|e| {
            Error::Decode(format!(
                "{e}: {}",
                text.chars().take(200).collect::<String>()
            ))
        })
    }
}

/// Builder for [`Velixar`].
pub struct Builder {
    api_key: String,
    base_url: String,
    http: Option<reqwest::Client>,
}

impl Builder {
    /// Point at a different deployment. Trailing slashes are trimmed, so
    /// `https://x/v1` and `https://x/v1/` behave identically — otherwise you get
    /// `//memory` and a 404 that is nobody's fault and everybody's problem.
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        let mut u = url.into();
        while u.ends_with('/') {
            u.pop();
        }
        self.base_url = u;
        self
    }

    /// Supply your own HTTP client (timeouts, proxy, pool).
    pub fn http_client(mut self, http: reqwest::Client) -> Self {
        self.http = Some(http);
        self
    }

    pub fn build(self) -> Result<Velixar> {
        Ok(Velixar {
            http: self.http.unwrap_or_default(),
            base_url: self.base_url,
            api_key: self.api_key,
        })
    }
}