diff --git a/README.md b/README.md index 03681ebc..59fc6516 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ composition — no storage engine, no HTTP stack, no native library. | --- | --- | | `tinycortex` | the embedded TinyCortex engine, as `tinymemory::tinycortex` | | `supermemory`, `mem0`, `cognee`, `agentmemory` | the matching HTTP adapter, as `tinymemory::remote` | +| `livingbrain` | the LivingBrain Brain API client, as `tinymemory::remote` (not a `MemoryProvider`) | | `engines` | all five of the above | | `core` | `tinymemory::core` — the memory subsystem | | `sync` | `tinymemory::sync` — the Composio normalisers | @@ -113,7 +114,7 @@ error. None of these crates are on crates.io yet, so you take the facade by git. Which patch table you need depends on the engine you pick. -**Remote engines (Supermemory, Mem0, Cognee, AgentMemory) — no patch table:** +**Remote engines and clients (Supermemory, Mem0, Cognee, AgentMemory, LivingBrain) — no patch table:** ```toml [dependencies] @@ -351,6 +352,32 @@ All four advertise the mandatory Core, Recall, and Portability families. The live Docker harness and conformance command are documented in [`integration/remote-engines/`](integration/remote-engines/README.md). +LivingBrain is different: its hosted API accepts asynchronous captures and +returns compiled pages, native semantic search, graph data, and markdown +exports. It is available behind `livingbrain`, but is intentionally a +brain-scoped client rather than a `MemoryProvider`, because it cannot uphold +TinyMemory's exact namespace/key CRUD and portability contract: + +```rust,no_run +use tinymemory::remote::{Capture, CaptureKind, LivingBrain}; + +async fn capture_note() -> anyhow::Result<()> { + let brain = LivingBrain::cloud("lbk_...", "host-subject-id", "brain-id")?; + let _receipt = brain.capture(&Capture { + kind: CaptureKind::Note, + content: Some("Customer prefers concise weekly updates.".into()), + fetch_url: None, + origin_ref: Some("crm:customer-42:note-9".into()), + source: Some("crm".into()), + label: Some("CRM note".into()), + }).await?; + Ok(()) +} +``` + +Pass credentials from the host's secret store; never commit them. Every request +uses both `Authorization: Bearer` and `x-subject-id`. + One of them restricts what it will store. Supermemory removes `U+0000` and `U+FFFD` from content server-side, so the adapter refuses such content with `MemoryError::Invalid` rather than storing a value the service would quietly diff --git a/crates/tinymemory-remote/src/common.rs b/crates/tinymemory-remote/src/common.rs index d150f474..a517f2ef 100644 --- a/crates/tinymemory-remote/src/common.rs +++ b/crates/tinymemory-remote/src/common.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use anyhow::{bail, Context}; use async_trait::async_trait; -use reqwest::header::{HeaderValue, AUTHORIZATION}; +use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION}; use reqwest::{Method, RequestBuilder, StatusCode, Url}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -22,6 +22,7 @@ pub(crate) struct HttpClient { inner: reqwest::Client, endpoint: Url, auth: Auth, + subject_id: Option, } #[derive(Clone)] @@ -145,6 +146,13 @@ fn credential_header(value: &str) -> anyhow::Result { Ok(header) } +/// Validates LivingBrain's caller-controlled subject identifier before it is +/// placed in a request header. It is not a credential, but it must still not +/// be allowed to inject another header or to drift into a transport failure. +fn subject_header(value: &str) -> anyhow::Result { + HeaderValue::from_str(value).context("subject id is not a valid HTTP header value") +} + /// The caller's statement of a request's idempotence — every `json`/`text` /// call site must choose, which is what makes the read/write retry split /// CHECKABLE instead of conventional (#68 review, Major 4: the first cut's @@ -171,30 +179,51 @@ pub(crate) enum Attempts { impl HttpClient { /// Builds a client that optionally authenticates with a bearer token. pub(crate) fn bearer(endpoint: &str, credential: Option<&str>) -> anyhow::Result { - Self::new( + Self::new_with_subject( endpoint, credential.map_or(Auth::None, |value| Auth::Bearer(value.into())), + None, + ) + } + + /// Builds a bearer-authenticated client that identifies every request's + /// end user with LivingBrain's required `x-subject-id` header. + pub(crate) fn bearer_with_subject( + endpoint: &str, + credential: &str, + subject_id: &str, + ) -> anyhow::Result { + Self::new_with_subject( + endpoint, + Auth::Bearer(credential.into()), + Some(subject_header(subject_id)?), ) } /// A client authenticating with `Authorization: Token `. pub(crate) fn token(endpoint: &str, credential: Option<&str>) -> anyhow::Result { - Self::new( + Self::new_with_subject( endpoint, credential.map_or(Auth::None, |value| Auth::Token(value.into())), + None, ) } /// Builds a client that optionally authenticates with `X-API-Key`. pub(crate) fn api_key(endpoint: &str, credential: Option<&str>) -> anyhow::Result { - Self::new( + Self::new_with_subject( endpoint, credential.map_or(Auth::None, |value| Auth::ApiKey(value.into())), + None, ) } /// Validates and normalizes an endpoint before constructing the transport. - fn new(endpoint: &str, auth: Auth) -> anyhow::Result { + fn new_with_subject( + endpoint: &str, + auth: Auth, + subject_id: Option, + ) -> anyhow::Result { let mut endpoint = Url::parse(endpoint).context("memory endpoint is not a valid URL")?; if !matches!(endpoint.scheme(), "http" | "https") { bail!("memory endpoint must use http or https"); @@ -207,6 +236,7 @@ impl HttpClient { inner: Self::build_inner(std::time::Duration::from_secs(60))?, endpoint, auth, + subject_id, }) } @@ -240,13 +270,19 @@ impl HttpClient { .join(path.trim_start_matches('/')) .context("memory API path is invalid")?; let request = self.inner.request(method, url); - Ok(match &self.auth { + let request = match &self.auth { Auth::None => request, Auth::Bearer(token) => request.bearer_auth(token), Auth::ApiKey(key) => request.header("X-API-Key", credential_header(key)?), Auth::Token(key) => { request.header(AUTHORIZATION, credential_header(&format!("Token {key}"))?) } + }; + Ok(match &self.subject_id { + Some(subject_id) => { + request.header(HeaderName::from_static("x-subject-id"), subject_id.clone()) + } + None => request, }) } diff --git a/crates/tinymemory-remote/src/common_credential_header_tests.rs b/crates/tinymemory-remote/src/common_credential_header_tests.rs index 5f424ba1..0f3578db 100644 --- a/crates/tinymemory-remote/src/common_credential_header_tests.rs +++ b/crates/tinymemory-remote/src/common_credential_header_tests.rs @@ -4,6 +4,12 @@ use super::{credential_header, Auth, HttpClient}; +impl HttpClient { + fn test_new(endpoint: &str, auth: Auth) -> anyhow::Result { + Self::new_with_subject(endpoint, auth, None) + } +} + /// The point of the helper. `reqwest` only redacts a header value whose /// sensitive flag is set, and `RequestBuilder::header` handed a plain /// string leaves it clear -- which is how an API key ends up rendered in @@ -48,7 +54,7 @@ fn both_manual_schemes_send_a_sensitive_authorization_value() { Auth::ApiKey("cg-secret".into()), Auth::Token("m0-secret".into()), ] { - let client = HttpClient::new("https://example.test", auth).expect("valid endpoint"); + let client = HttpClient::test_new("https://example.test", auth).expect("valid endpoint"); let request = client .request(reqwest::Method::GET, "v1/thing") .expect("a plain key builds") diff --git a/crates/tinymemory-remote/src/lib.rs b/crates/tinymemory-remote/src/lib.rs index 9f2b596c..04bbaa24 100644 --- a/crates/tinymemory-remote/src/lib.rs +++ b/crates/tinymemory-remote/src/lib.rs @@ -12,6 +12,7 @@ pub mod cognee; mod cognee_graph; mod common; mod graph_provider; +pub mod livingbrain; pub mod mem0; mod mem0_graph; mod mem0_provider; @@ -21,6 +22,11 @@ pub use agentmemory::{AgentMemoryMemory, AGENTMEMORY_API_ENDPOINT, AGENTMEMORY_D pub use cognee::{CogneeMemory, COGNEE_DRIVER_ID}; pub use cognee_graph::CogneeGraph; pub use graph_provider::GraphMemoryProvider; +pub use livingbrain::{ + Capture, CaptureBatchReceipt, CaptureKind, CaptureReceipt, CaptureSource, ChatSender, ChatTurn, + ChatTurnReceipt, LivingBrain, LivingBrainExport, LivingBrainSearchResult, + LIVINGBRAIN_API_ENDPOINT, +}; pub use mem0::{Mem0Memory, MEM0_API_ENDPOINT, MEM0_DRIVER_ID}; pub use mem0_graph::Mem0Graph; pub use mem0_provider::Mem0Provider; diff --git a/crates/tinymemory-remote/src/livingbrain/README.md b/crates/tinymemory-remote/src/livingbrain/README.md new file mode 100644 index 00000000..66819f34 --- /dev/null +++ b/crates/tinymemory-remote/src/livingbrain/README.md @@ -0,0 +1,24 @@ +# LivingBrain client + +This module is a brain-scoped client for LivingBrain's hosted API. It is not a +`MemoryProvider`: captures are asynchronously compiled into native pages, so +the service cannot provide TinyMemory's exact namespace/key CRUD contract. + +`LivingBrain` is constructed with the API endpoint, a bearer key, a subject id, +and one brain id. It sends the key only in `Authorization: Bearer` and attaches +the subject id as `x-subject-id`; neither credential is rendered through the +client's `Debug` output. + +The public surface submits individual and bounded batch captures, conversation +turns, semantic searches, and source cleanup. It also reads native pages, +graphs, source status, and markdown exports. Native page and graph shapes are +returned as JSON because LivingBrain owns their schema. + +Captures with a stable `origin_ref` are retry-safe and retry transient failures. +Captures without one make exactly one request. Batch captures require an +`origin_ref` per item and accept at most 100 items. Callers can preserve host +provenance with `Capture::source`. + +`types.rs` contains the request and response contracts. `test.rs` uses a local +HTTP simulation to verify wire routes, headers, payloads, validation, and +credential redaction without contacting the hosted service. diff --git a/crates/tinymemory-remote/src/livingbrain/mod.rs b/crates/tinymemory-remote/src/livingbrain/mod.rs new file mode 100644 index 00000000..46925e57 --- /dev/null +++ b/crates/tinymemory-remote/src/livingbrain/mod.rs @@ -0,0 +1,329 @@ +//! LivingBrain's hosted Brain API client. +//! +//! This is deliberately not a [`tinymemory_api::traits::Memory`] adapter. +//! LivingBrain accepts asynchronous captures and exposes compiled pages, not +//! TinyMemory's exact `(namespace, key)` record contract. + +use reqwest::Method; +use serde_json::{json, Value}; + +use crate::common::{Attempts, HttpClient}; + +mod types; + +use types::SourceList; +pub use types::{ + Capture, CaptureBatchReceipt, CaptureKind, CaptureReceipt, CaptureSource, ChatSender, ChatTurn, + ChatTurnReceipt, LivingBrainExport, LivingBrainSearchResult, +}; + +/// Public base URL for LivingBrain's hosted API. +pub const LIVINGBRAIN_API_ENDPOINT: &str = "https://api.livingbrain.com"; + +/// A client scoped to exactly one LivingBrain brain and one host subject. +#[derive(Debug)] +pub struct LivingBrain { + client: HttpClient, + brain_id: String, +} + +impl LivingBrain { + /// Connects to a particular hosted LivingBrain brain. + /// + /// The API key is sent only as a sensitive bearer-authentication header; + /// every request also carries the given `subject_id` as `x-subject-id`. + /// + /// # Errors + /// + /// Returns an error when connection fields are blank, the endpoint is not + /// HTTP(S), or the subject id cannot be encoded as an HTTP header. + pub fn new( + endpoint: &str, + api_key: &str, + subject_id: &str, + brain_id: &str, + ) -> anyhow::Result { + for (name, value) in [ + ("LivingBrain API key", api_key), + ("LivingBrain subject id", subject_id), + ("LivingBrain brain id", brain_id), + ] { + anyhow::ensure!(!value.trim().is_empty(), "{name} must not be empty"); + } + validate_path_segment(brain_id, "LivingBrain brain id")?; + Ok(Self { + client: HttpClient::bearer_with_subject(endpoint, api_key, subject_id)?, + brain_id: brain_id.into(), + }) + } + + /// Connects to LivingBrain's hosted API. + /// + /// # Errors + /// + /// Returns an error when a connection field is blank or invalid. + pub fn cloud(api_key: &str, subject_id: &str, brain_id: &str) -> anyhow::Result { + Self::new(LIVINGBRAIN_API_ENDPOINT, api_key, subject_id, brain_id) + } + + /// Rebuilds the transport with a different per-request deadline. + /// + /// # Errors + /// + /// Fails only if the underlying HTTP client cannot be rebuilt. + pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> anyhow::Result { + self.client = self.client.clone().with_timeout(timeout)?; + Ok(self) + } + + /// Submits one capture for asynchronous ingestion. + /// + /// A nonempty `origin_ref` makes a caller retry safe: LivingBrain uses it + /// as its idempotency key. The client does not generate one on its own. + /// + /// # Errors + /// + /// Returns an error when the capture shape is invalid or the service + /// rejects or cannot accept it. + pub async fn capture(&self, capture: &Capture) -> anyhow::Result { + capture.validate()?; + let attempts = if capture.origin_ref.is_some() { + Attempts::RetryTransient + } else { + Attempts::Once + }; + self.client + .json( + Method::POST, + &format!("v1/brains/{}/captures", self.brain_id), + Some(&capture.to_json()), + attempts, + ) + .await + } + + /// Submits a bounded batch of captures for asynchronous ingestion. + /// + /// Every capture needs a stable `origin_ref`, so a transient retry cannot + /// create duplicate sources. + /// + /// # Errors + /// + /// Returns an error when the batch is empty, exceeds the service limit, + /// contains an invalid capture, or the service rejects it. + pub async fn capture_batch(&self, captures: &[Capture]) -> anyhow::Result { + const MAX_BATCH_SIZE: usize = 100; + anyhow::ensure!( + !captures.is_empty(), + "LivingBrain capture batch must not be empty" + ); + anyhow::ensure!( + captures.len() <= MAX_BATCH_SIZE, + "LivingBrain capture batch must contain at most {MAX_BATCH_SIZE} captures" + ); + for capture in captures { + capture.validate()?; + anyhow::ensure!( + capture.origin_ref.is_some(), + "LivingBrain batch captures require an origin_ref" + ); + } + self.client + .json( + Method::POST, + &format!("v1/brains/{}/captures/batch", self.brain_id), + Some(&json!({ "captures": captures.iter().map(Capture::to_json).collect::>() })), + Attempts::RetryTransient, + ) + .await + } + + /// Submits one conversation turn for LivingBrain's worthiness classifier. + /// + /// The service may successfully decline to capture a turn. In that case + /// [`ChatTurnReceipt::worthy`] is false and `source_id` is absent. + /// + /// # Errors + /// + /// Returns an error when the turn is invalid or the service rejects it. + pub async fn capture_chat_turn(&self, turn: &ChatTurn) -> anyhow::Result { + turn.validate()?; + self.client + .json( + Method::POST, + &format!("v1/brains/{}/captures/chat-turn", self.brain_id), + Some(&turn.to_json()), + Attempts::Once, + ) + .await + } + + /// Searches LivingBrain's compiled pages using its native ranking. + /// + /// # Errors + /// + /// Returns an error when the query or bounds are invalid, or the service + /// cannot complete the search. + pub async fn search( + &self, + query: &str, + top_k: usize, + min_similarity: Option, + ) -> anyhow::Result> { + anyhow::ensure!( + !query.trim().is_empty(), + "LivingBrain search query must not be empty" + ); + anyhow::ensure!(top_k > 0, "LivingBrain search top_k must be positive"); + if let Some(min_similarity) = min_similarity { + anyhow::ensure!( + (0.0..=1.0).contains(&min_similarity), + "LivingBrain minimum similarity must be between zero and one" + ); + } + self.client + .json( + Method::POST, + &format!("v1/brains/{}/search", self.brain_id), + Some(&json!({ + "query": query, + "topK": top_k, + "minSimilarity": min_similarity, + })), + Attempts::RetryTransient, + ) + .await + } + + /// Reads one native LivingBrain page by slug. + /// + /// The page model evolves independently of TinyMemory, so this method + /// preserves it as JSON rather than pretending it is an exact record. + /// + /// # Errors + /// + /// Returns an error when `slug` is blank or the service cannot read it. + pub async fn page(&self, slug: &str) -> anyhow::Result { + validate_path_segment(slug, "LivingBrain page slug")?; + self.client + .json( + Method::GET, + &format!("v1/brains/{}/pages/{slug}", self.brain_id), + None, + Attempts::RetryTransient, + ) + .await + } + + /// Lists the native LivingBrain pages for the configured brain. + /// + /// Page fields are intentionally kept as JSON because LivingBrain evolves + /// this model independently of TinyMemory's exact-record contract. + /// + /// # Errors + /// + /// Returns an error when the service cannot list the pages. + pub async fn pages(&self) -> anyhow::Result { + self.client + .json( + Method::GET, + &format!("v1/brains/{}/pages", self.brain_id), + None, + Attempts::RetryTransient, + ) + .await + } + + /// Returns the service's graph payload for this brain. + /// + /// # Errors + /// + /// Returns an error when the service cannot retrieve the graph. + pub async fn graph(&self) -> anyhow::Result { + self.client + .json( + Method::GET, + &format!("v1/brains/{}/graph", self.brain_id), + None, + Attempts::RetryTransient, + ) + .await + } + + /// Lists ingestion-source status for the configured brain. + /// + /// # Errors + /// + /// Returns an error when the service cannot retrieve source status. + pub async fn sources(&self) -> anyhow::Result> { + let response: SourceList = self + .client + .json( + Method::GET, + &format!("v1/brains/{}/sources", self.brain_id), + None, + Attempts::RetryTransient, + ) + .await?; + Ok(response.items) + } + + /// Deletes one ingest source created in this brain. + /// + /// This is primarily useful for caller-managed cleanup of temporary + /// captures; it does not delete arbitrary pages by slug. + /// + /// # Errors + /// + /// Returns an error when `source_id` is invalid or the service rejects the + /// deletion. + pub async fn remove_source(&self, source_id: &str) -> anyhow::Result<()> { + validate_path_segment(source_id, "LivingBrain source id")?; + self.client + .empty( + Method::DELETE, + &format!("v1/brains/{}/sources/{source_id}", self.brain_id), + None, + ) + .await?; + Ok(()) + } + + /// Exports the configured brain as LivingBrain's markdown bundle. + /// + /// # Errors + /// + /// Returns an error when the service cannot produce the export. + pub async fn export_markdown(&self) -> anyhow::Result { + self.client + .json( + Method::GET, + &format!("v1/brains/{}/export/markdown", self.brain_id), + None, + Attempts::RetryTransient, + ) + .await + } +} + +/// Rejects characters that could change the shape of a URL path assembled +/// from a caller-controlled brain id or page slug. LivingBrain ids and slugs +/// are opaque but URL-safe identifiers, so accepting query, fragment, or path +/// separators would be an input bug, not a compatibility feature. +fn validate_path_segment(value: &str, name: &str) -> anyhow::Result<()> { + anyhow::ensure!(!value.trim().is_empty(), "{name} must not be empty"); + anyhow::ensure!( + !matches!(value, "." | ".."), + "{name} must not be a dot path segment" + ); + anyhow::ensure!( + value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')), + "{name} must be a URL-safe identifier" + ); + Ok(()) +} + +#[cfg(test)] +mod test; diff --git a/crates/tinymemory-remote/src/livingbrain/test.rs b/crates/tinymemory-remote/src/livingbrain/test.rs new file mode 100644 index 00000000..fce54c18 --- /dev/null +++ b/crates/tinymemory-remote/src/livingbrain/test.rs @@ -0,0 +1,313 @@ +//! LivingBrain client tests against a local simulation of the public API. + +#![allow(clippy::expect_used)] + +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + routing::{delete, get, post}, + Json, Router, +}; +use serde_json::{json, Value}; + +use super::{Capture, CaptureKind, ChatSender, ChatTurn, LivingBrain}; + +#[derive(Default)] +struct ApiState { + captured: Mutex>, +} + +fn authorized(headers: &HeaderMap) -> bool { + headers + .get("authorization") + .is_some_and(|value| value == "Bearer test-key") + && headers + .get("x-subject-id") + .is_some_and(|value| value == "subject-1") +} + +async fn capture( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + state.captured.lock().expect("state lock").push(body); + ( + StatusCode::CREATED, + Json(json!({"id": "source-1", "status": "queued"})), + ) +} + +async fn capture_batch(headers: HeaderMap, Json(body): Json) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + assert_eq!(body["captures"][0]["originRef"], "event:batch-1"); + assert_eq!(body["captures"][0]["kind"], "text"); + assert_eq!(body["captures"][0]["source"], "import"); + ( + StatusCode::CREATED, + Json(json!({"items": [{"id": "source-batch-1", "status": "queued"}]})), + ) +} + +async fn search(headers: HeaderMap, Json(body): Json) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + assert_eq!(body["query"], "customer preferences"); + assert_eq!(body["topK"], 3); + ( + StatusCode::OK, + Json(json!([{ + "pageId": "page-1", + "slug": "customer-preferences", + "title": "Customer preferences", + "summary": "Prefers short weekly updates.", + "pageType": "entity", + "status": "active", + "similarity": 0.92 + }])), + ) +} + +async fn chat_turn(headers: HeaderMap, Json(body): Json) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + assert_eq!(body["sender"], "user"); + ( + StatusCode::CREATED, + Json(json!({"worthy": true, "reason": "durable preference", "sourceId": "source-2"})), + ) +} + +async fn page(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + ( + StatusCode::OK, + Json(json!({"slug": "customer-preferences", "content": "..."})), + ) +} + +async fn pages(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + ( + StatusCode::OK, + Json(json!({"items": [{"slug": "customer-preferences"}]})), + ) +} + +async fn graph(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + ( + StatusCode::OK, + Json(json!({"nodes": [{"id": "page-1"}], "edges": []})), + ) +} + +async fn sources(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + ( + StatusCode::OK, + Json(json!({"items": [{"id": "source-1", "status": "ready"}]})), + ) +} + +async fn export(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + ( + StatusCode::OK, + Json(json!({"brainId": "brain-1", "markdown": "# Customer"})), + ) +} + +async fn remove_source(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"message": "missing auth"})), + ); + } + (StatusCode::OK, Json(json!({"deleted": true}))) +} + +async fn simulated_client() -> (LivingBrain, Arc) { + let state = Arc::new(ApiState::default()); + let app = Router::new() + .route("/v1/brains/brain-1/captures", post(capture)) + .route("/v1/brains/brain-1/captures/batch", post(capture_batch)) + .route("/v1/brains/brain-1/captures/chat-turn", post(chat_turn)) + .route("/v1/brains/brain-1/search", post(search)) + .route("/v1/brains/brain-1/pages/customer-preferences", get(page)) + .route("/v1/brains/brain-1/pages", get(pages)) + .route("/v1/brains/brain-1/graph", get(graph)) + .route("/v1/brains/brain-1/sources", get(sources)) + .route("/v1/brains/brain-1/export/markdown", get(export)) + .route("/v1/brains/brain-1/sources/source-1", delete(remove_source)) + .with_state(Arc::clone(&state)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind simulated API"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve simulated API"); + }); + ( + LivingBrain::new(&endpoint, "test-key", "subject-1", "brain-1").expect("client"), + state, + ) +} + +#[tokio::test] +async fn simulated_api_carries_required_headers_and_native_payloads() { + let (client, state) = simulated_client().await; + let receipt = client + .capture(&Capture { + kind: CaptureKind::Note, + content: Some("Weekly updates should be concise".into()), + fetch_url: None, + origin_ref: Some("event:123".into()), + source: Some("crm".into()), + label: Some("Call notes".into()), + }) + .await + .expect("capture"); + assert_eq!(receipt.id, "source-1"); + assert_eq!(receipt.status.as_deref(), Some("queued")); + assert_eq!( + state.captured.lock().expect("state lock")[0]["originRef"], + "event:123" + ); + assert_eq!( + state.captured.lock().expect("state lock")[0]["source"], + "crm" + ); + let batch = client + .capture_batch(&[Capture { + kind: CaptureKind::Text, + content: Some("A durable batch note".into()), + fetch_url: None, + origin_ref: Some("event:batch-1".into()), + source: Some("import".into()), + label: None, + }]) + .await + .expect("capture batch"); + assert_eq!(batch.items[0].id, "source-batch-1"); + assert!(client + .capture_batch(&[]) + .await + .expect_err("empty batch must be rejected") + .to_string() + .contains("must not be empty")); + + let results = client + .search("customer preferences", 3, Some(0.5)) + .await + .expect("search"); + assert_eq!(results[0].slug, "customer-preferences"); + assert_eq!( + client.page("customer-preferences").await.expect("page")["content"], + "..." + ); + assert_eq!( + client.pages().await.expect("pages")["items"][0]["slug"], + "customer-preferences" + ); + assert_eq!( + client.graph().await.expect("graph")["nodes"][0]["id"], + "page-1" + ); + assert_eq!( + client.sources().await.expect("sources")[0] + .status + .as_deref(), + Some("ready") + ); + let chat = client + .capture_chat_turn(&ChatTurn { + text: "I prefer concise weekly updates.".into(), + sender: ChatSender::User, + origin_ref: Some("chat:123".into()), + agent_name: None, + }) + .await + .expect("chat turn"); + assert!(chat.worthy); + assert_eq!(chat.source_id.as_deref(), Some("source-2")); + client.remove_source("source-1").await.expect("cleanup"); + assert_eq!( + client.export_markdown().await.expect("export").markdown, + "# Customer" + ); +} + +#[test] +fn connection_fields_and_capture_shape_are_checked_without_a_request() { + let error = LivingBrain::cloud("", "subject", "brain").expect_err("blank key"); + assert!(format!("{error}").contains("API key")); + let client = LivingBrain::cloud("test-key", "subject", "brain").expect("client"); + let rendered = format!("{client:?}"); + assert!( + !rendered.contains("test-key"), + "credential leaked: {rendered}" + ); + let invalid = Capture { + kind: CaptureKind::Note, + content: Some("text".into()), + fetch_url: Some("https://example.test".into()), + origin_ref: None, + source: None, + label: None, + }; + assert!(invalid.validate().is_err()); + for segment in [".", ".."] { + let error = LivingBrain::cloud("test-key", "subject", segment).expect_err("dot segment"); + assert!(format!("{error}").contains("dot path segment")); + } +} diff --git a/crates/tinymemory-remote/src/livingbrain/types.rs b/crates/tinymemory-remote/src/livingbrain/types.rs new file mode 100644 index 00000000..7460c365 --- /dev/null +++ b/crates/tinymemory-remote/src/livingbrain/types.rs @@ -0,0 +1,222 @@ +//! Typed request and response values for the LivingBrain API. + +use serde::Deserialize; +use serde_json::{json, Value}; + +/// A kind of content LivingBrain can capture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureKind { + /// A text note. + Note, + /// Arbitrary text content. + Text, + /// A remote or uploaded file. + File, + /// A web URL. + Url, + /// An audio or textual transcript. + Transcript, + /// An agent/user chat turn. + ChatTurn, + /// Content received from an integration. + Integration, +} + +impl CaptureKind { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Note => "note", + Self::Text => "text", + Self::File => "file", + Self::Url => "url", + Self::Transcript => "transcript", + Self::ChatTurn => "chat_turn", + Self::Integration => "integration", + } + } +} + +/// A capture submitted to LivingBrain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Capture { + /// The source-content kind. + pub kind: CaptureKind, + /// Inline textual content, mutually exclusive with [`Self::fetch_url`]. + pub content: Option, + /// A URL LivingBrain should fetch, mutually exclusive with [`Self::content`]. + pub fetch_url: Option, + /// Stable host event id used by LivingBrain for deduplication. + pub origin_ref: Option, + /// Host provenance retained by LivingBrain with the capture. + pub source: Option, + /// An optional display label. + pub label: Option, +} + +impl Capture { + pub(super) fn validate(&self) -> anyhow::Result<()> { + let has_content = self + .content + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + let has_url = self + .fetch_url + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + anyhow::ensure!( + has_content != has_url, + "LivingBrain capture needs exactly one of content or fetch_url" + ); + for (name, value) in [ + ("LivingBrain origin_ref", self.origin_ref.as_deref()), + ("LivingBrain source", self.source.as_deref()), + ] { + if let Some(value) = value { + anyhow::ensure!(!value.trim().is_empty(), "{name} must not be empty"); + } + } + Ok(()) + } + + pub(super) fn to_json(&self) -> Value { + json!({ + "kind": self.kind.as_str(), + "content": self.content, + "fetchUrl": self.fetch_url, + "originRef": self.origin_ref, + "source": self.source, + "label": self.label, + }) + } +} + +/// A sender role for a conversation turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatSender { + /// A turn authored by the end user. + User, + /// A turn authored by an agent. + Agent, +} + +impl ChatSender { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Agent => "agent", + } + } +} + +/// A conversation turn submitted for optional capture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatTurn { + /// Text spoken in the turn. + pub text: String, + /// Who authored the turn. + pub sender: ChatSender, + /// Stable host event id used for deduplication when the turn is captured. + pub origin_ref: Option, + /// Optional name of the responding agent. + pub agent_name: Option, +} + +impl ChatTurn { + pub(super) fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + !self.text.trim().is_empty(), + "LivingBrain chat turn text must not be empty" + ); + if let Some(origin_ref) = &self.origin_ref { + anyhow::ensure!( + !origin_ref.trim().is_empty(), + "LivingBrain chat turn origin_ref must not be empty" + ); + } + Ok(()) + } + + pub(super) fn to_json(&self) -> Value { + json!({ + "text": self.text, + "sender": self.sender.as_str(), + "originRef": self.origin_ref, + "agentName": self.agent_name, + }) + } +} + +/// LivingBrain's decision about a submitted conversation turn. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ChatTurnReceipt { + /// Whether LivingBrain accepted the turn for capture. + pub worthy: bool, + /// The service's explanation for the decision. + pub reason: String, + /// The ingest-source id when the turn was accepted. + #[serde(rename = "sourceId", default)] + pub source_id: Option, +} + +/// A source created or accepted by LivingBrain capture ingestion. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CaptureReceipt { + /// Service-assigned ingest-source id. + pub id: String, + /// Native ingestion status, when returned by the endpoint. + #[serde(default)] + pub status: Option, +} + +/// Per-source outcomes returned from a batch capture submission. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CaptureBatchReceipt { + /// The individual sources accepted or rejected by the service. + pub items: Vec, +} + +/// A captured source and its current asynchronous-ingestion state. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CaptureSource { + /// Service-assigned ingest-source id. + pub id: String, + /// Native ingestion status, when returned by the endpoint. + #[serde(default)] + pub status: Option, +} + +#[derive(Deserialize)] +pub(super) struct SourceList { + pub(super) items: Vec, +} + +/// One result from LivingBrain's page search. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct LivingBrainSearchResult { + /// Stable page id. + #[serde(rename = "pageId")] + pub page_id: String, + /// URL-safe page identifier. + pub slug: String, + /// Page title. + pub title: String, + /// Search-result summary. + pub summary: String, + /// LivingBrain's page type. + #[serde(rename = "pageType")] + pub page_type: String, + /// Current LivingBrain page state. + pub status: String, + /// Native similarity score in the inclusive range zero to one. + pub similarity: f64, +} + +/// LivingBrain's markdown export payload. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct LivingBrainExport { + /// The brain represented by this export. + #[serde(rename = "brainId")] + pub brain_id: String, + /// The service's complete markdown bundle. + pub markdown: String, +} diff --git a/crates/tinymemory/Cargo.toml b/crates/tinymemory/Cargo.toml index cdb15e6b..9cb503ab 100644 --- a/crates/tinymemory/Cargo.toml +++ b/crates/tinymemory/Cargo.toml @@ -93,6 +93,9 @@ supermemory = ["dep:tinymemory-remote"] mem0 = ["dep:tinymemory-remote"] cognee = ["dep:tinymemory-remote"] agentmemory = ["dep:tinymemory-remote"] +# LivingBrain exposes a brain-scoped remote API client, not an exact-record +# `MemoryProvider`; see docs/specs/livingbrain-remote-api.md. +livingbrain = ["dep:tinymemory-remote"] # Every engine at once. A host that binds its driver from configuration rather # than at compile time wants this: `DriverRegistry` admission is a static policy # table, so which adapters are compiled in decides what it can actually bind. diff --git a/crates/tinymemory/src/lib.rs b/crates/tinymemory/src/lib.rs index 58626d27..7dc0c6cd 100644 --- a/crates/tinymemory/src/lib.rs +++ b/crates/tinymemory/src/lib.rs @@ -98,7 +98,8 @@ pub use tinymemory_tinycortex as tinycortex; feature = "supermemory", feature = "mem0", feature = "cognee", - feature = "agentmemory" + feature = "agentmemory", + feature = "livingbrain" ))] pub use tinymemory_remote as remote; diff --git a/crates/tinymemory/tests/feature_surface.rs b/crates/tinymemory/tests/feature_surface.rs index d6b7db24..8589cdd6 100644 --- a/crates/tinymemory/tests/feature_surface.rs +++ b/crates/tinymemory/tests/feature_surface.rs @@ -24,7 +24,8 @@ compile_error!("contacts must imply core"); feature = "tinycortex", feature = "supermemory", feature = "mem0", - feature = "cognee" + feature = "cognee", + feature = "agentmemory" )) ))] compile_error!("engines must expose every engine adapter"); diff --git a/docs/specs/README.md b/docs/specs/README.md index 7066fa11..7959126a 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -1,6 +1,7 @@ # Specifications - [Granular ingestion and retrieval API](ingestion-retrieval-api.md) +- [LivingBrain remote Brain API](livingbrain-remote-api.md) Specifications define what the system must do before implementation details take over. Create one for behavior that changes a public API, crosses module diff --git a/docs/specs/livingbrain-remote-api.md b/docs/specs/livingbrain-remote-api.md new file mode 100644 index 00000000..0a3f5dd7 --- /dev/null +++ b/docs/specs/livingbrain-remote-api.md @@ -0,0 +1,155 @@ +# LivingBrain remote Brain API + +**Status:** Accepted + +**Owner:** TinyMemory maintainers + +## Problem + +Some hosts need a managed, shared knowledge brain instead of a local memory +engine. LivingBrain provides a hosted Brain API with capture, semantic search, +pages, graph, profile, change-feed, and markdown-export operations. It is not +a record store: captures are compiled into pages asynchronously and the public +API does not expose TinyMemory's exact `(namespace, key)` CRUD operations. + +TinyMemory needs a defined integration boundary so a host can use this service +without treating it as a drop-in `Memory` implementation and silently breaking +the mandatory Core or Portability promises. + +## Goals + +- Add an optional `livingbrain` remote-engine feature exposed as + `tinymemory::remote`. +- Provide a typed LivingBrain client for the supported Brain operations. +- Keep the client explicitly brain-scoped: every operation uses one configured + `brain_id` and one `subject_id`. +- Send `Authorization: Bearer ` and `x-subject-id: ` on + every request; credentials must never appear in `Debug`, errors, examples, + fixtures, or version-controlled configuration. +- Preserve LivingBrain's native concepts rather than flattening pages, graph + edges, or asynchronous ingestion into fake TinyMemory records. +- Document the provider contract and a deterministic test double before an + adapter is enabled in the facade. + +## Non-goals + +- Claiming that LivingBrain implements `tinymemory_api::traits::Memory` or + advertising Core, Recall, or Portability through `MemoryTraitProvider`. +- Creating or deleting a customer's brain implicitly during client + construction. +- Storing a user-supplied API key or subject id in repository files. +- Registering webhooks, connecting Telegram, or changing profile/brief + settings in the first integration. + +## Remote contract + +The documented OpenAPI contract is version `1.0`. The public API gateway is +`https://api.livingbrain.com` and serves the OpenAPI document. The document's +alternate `api.lbs.chatchat.com` server value is not used as the default: it +was not DNS-resolvable from the supported build environment. The adapter +defaults to the public gateway and permits an HTTP(S) override only for tests +or a future documented deployment mode. + +The initial public constructor is conceptually: + +```rust +let brain = LivingBrain::new( + "https://api.livingbrain.com", + "lbk_...", + "host-subject-id", + "brain-id", +)?; +``` + +The eventual concrete name may follow the remote crate's conventions, but its +arguments and credential ownership are fixed by this specification. Empty +credentials, subject ids, and brain ids are rejected locally. The client owns +the API key; it accepts neither a prebuilt request client with headers nor a +global environment lookup. Hosts load secrets from their own secret store, for +example `LIVINGBRAIN_API_KEY`, and pass the value at construction. + +### Supported operations + +| TinyMemory-facing operation | LivingBrain endpoint | Required behavior | +| --- | --- | --- | +| `capture` | `POST /v1/brains/{brainId}/captures` | Submit note, text, URL, file, transcript, or integration input. `Capture::source` carries host provenance and `origin_ref` carries stable external identity. | +| `capture_batch` | `POST /v1/brains/{brainId}/captures/batch` | Submit a bounded batch and return the service's per-source outcome. | +| `capture_chat_turn` | `POST /v1/brains/{brainId}/captures/chat-turn` | Return LivingBrain's `worthy` decision; a not-worthy turn is a successful result, not an error. | +| `search` | `POST /v1/brains/{brainId}/search` | Return native page-search results, including `similarity`, page state, summary, and slug. | +| `page` / `pages` | `GET /v1/brains/{brainId}/pages/{slug}` / `GET /v1/brains/{brainId}/pages` | Read the native page model; do not invent a namespace/key translation. | +| `graph` | `GET /v1/brains/{brainId}/graph` | Return the service's graph payload intact enough to render or inspect connections. | +| `sources` | `GET /v1/brains/{brainId}/sources` | Expose ingest status so callers can observe asynchronous capture completion. | +| `remove_source` | `DELETE /v1/brains/{brainId}/sources/{sourceId}` | Remove a caller-created temporary ingest source, including after a live integration test. | +| `export_markdown` | `GET /v1/brains/{brainId}/export/markdown` | Return the native markdown bundle for user-directed export only. | + +`capture` requires exactly one of `content` and `fetchUrl` when the selected +capture kind needs input. `originRef` is the service's deduplication key and +must be stable for retries of the same host event. The adapter must not retry a +capture with a newly generated `originRef`, because that converts a retry into +a duplicate ingestion. A batch is limited to 100 captures, each with a stable +`originRef`, and returns its per-source outcomes. + +### Capability boundary + +LivingBrain is a *brain API client*, not a TinyMemory mandatory-family driver. +It therefore has no `livingbrain_provider` function in the first release and +does not appear in `DriverRegistry::builtin()` as an `Embedded` or `External` +driver. A host that wants both systems may use LivingBrain for durable, +semantically compiled knowledge and continue binding a normal TinyMemory +`MemoryProvider` for exact record storage. + +If a future version needs an engine adapter, it must first define a separate +durable envelope and prove exact get/list/forget/export behavior. A semantic +search result or a markdown export is not evidence of exact-key portability. + +## Errors, retries, and limits + +- Invalid endpoint or blank connection fields fail during construction. +- `401` and `403` are terminal credential/authorization failures and are never + retried. +- `400` and `404` are terminal request or resource failures and are never + retried. +- `429`, `502`, `503`, `504`, and transport timeouts use the existing bounded + remote-read retry policy only when the request is idempotent. Capture retries + require a caller-supplied stable `originRef`. +- Responses are subject to the remote crate's existing byte cap. An oversized + response fails rather than being partially decoded. +- Search validates `top_k` and similarity bounds before issuing a request. + +## Security and operational constraints + +- The API key is a tenant credential; `x-subject-id` is the host's end-user + identity. The host must not substitute a shared tenant id for the subject id. +- Capture content, search queries, page contents, and provenance leave the + host. The host's egress, redaction, consent, taint, and audit policy must run + before this client is called. +- The adapter must redact bearer values from all diagnostics and must not place + headers in errors. +- Live tests are opt-in and read credentials only from the process environment; + ordinary unit and conformance tests use a local HTTP double. + +## Acceptance criteria + +1. `tinymemory-remote` exposes an optional, documented `livingbrain` client + without adding a mandatory-family provider or registry driver. +2. Each request carries both required headers, and tests prove neither header + value is exposed by `Debug` or an error message. +3. A capture with a stable `originRef` can be retried safely; the adapter never + synthesizes a different idempotency key for a retry. +4. Search, source-status inspection, page reads, graph retrieval, and markdown + export decode documented responses with a local HTTP double. +5. Validation, authentication, rate-limit, transient, and oversized-response + failures have deterministic behavior covered by tests. +6. The facade feature list and README describe LivingBrain as a Brain API + client, not as a `MemoryProvider` engine. + +## Resolved decisions + +- The host configures an existing `brain_id`; creation remains an explicit + product-level workflow rather than a side effect of connecting a client. +- The client exposes source status but does not poll. A host choosing to wait + owns cadence, timeout, and user-visible progress policy. +- Search results and exports have stable Rust types. Pages and graphs remain + JSON payloads initially, preserving the provider's evolving native model. +- The facade feature is named `livingbrain`, alongside the remote engines, and + its documentation makes the client/provider distinction explicit.