Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
48 changes: 42 additions & 6 deletions crates/tinymemory-remote/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -22,6 +22,7 @@ pub(crate) struct HttpClient {
inner: reqwest::Client,
endpoint: Url,
auth: Auth,
subject_id: Option<HeaderValue>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -145,6 +146,13 @@ fn credential_header(value: &str) -> anyhow::Result<HeaderValue> {
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> {
HeaderValue::from_str(value).context("subject id is not a valid HTTP header value")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark the subject header as sensitive

When a request or its headers are formatted by diagnostics or middleware, this plain HeaderValue renders the configured subject ID because, unlike credential_header, it is never marked sensitive. That contradicts the accepted specification's requirement that neither required header value appear in Debug; set the sensitive flag before storing the subject header.

AGENTS.md reference: AGENTS.md:L196-L196

Useful? React with 👍 / 👎.

}

/// 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
Expand All @@ -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> {
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> {
Self::new_with_subject(
endpoint,
Auth::Bearer(credential.into()),
Some(subject_header(subject_id)?),
)
}

/// A client authenticating with `Authorization: Token <key>`.
pub(crate) fn token(endpoint: &str, credential: Option<&str>) -> anyhow::Result<Self> {
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> {
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<Self> {
fn new_with_subject(
endpoint: &str,
auth: Auth,
subject_id: Option<HeaderValue>,
) -> anyhow::Result<Self> {
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");
Expand All @@ -207,6 +236,7 @@ impl HttpClient {
inner: Self::build_inner(std::time::Duration::from_secs(60))?,
endpoint,
auth,
subject_id,
})
}

Expand Down Expand Up @@ -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,
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

use super::{credential_header, Auth, HttpClient};

impl HttpClient {
fn test_new(endpoint: &str, auth: Auth) -> anyhow::Result<Self> {
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
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions crates/tinymemory-remote/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions crates/tinymemory-remote/src/livingbrain/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading