diff --git a/Cargo.lock b/Cargo.lock index a998d4d..e5599e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -83,6 +84,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -101,6 +103,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -327,6 +330,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -868,6 +880,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.2" @@ -879,6 +901,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1258,6 +1297,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -1592,6 +1632,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -1825,6 +1871,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tinymemory-remote" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "tinymemory", + "tinymemory-api", + "tokio", +] + [[package]] name = "tinymemory-tinycortex" version = "0.1.0" @@ -2008,6 +2070,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2046,6 +2109,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2099,6 +2163,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 5fbbc9a..d676144 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -members = [".", "api", "core", "adapters/tinycortex"] -default-members = [".", "api", "core", "adapters/tinycortex"] +members = [".", "api", "core", "adapters/tinycortex", "adapters/remote"] +default-members = [".", "api", "core", "adapters/tinycortex", "adapters/remote"] # `vendor/` holds engine submodules (tinycortex, tinybus, tinyagents), each of # which is its own workspace with its own lockfile. Same exclusion # `vendor/tinycortex` uses for its own nested vendor directory. diff --git a/README.md b/README.md index 17604be..d2eb938 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ src/ └── mandatory/ the three mandatory capability families, composed once over the `Memory` storage trait adapters/ -└── tinycortex/ the TinyCortex engine seen through the contract +├── tinycortex/ the TinyCortex engine seen through the contract +└── remote/ native HTTP dialects for Supermemory, Mem0, and Cognee vendor/ ├── tinycortex/ the engine, pinned as a submodule └── tinybus/ pinned TinyBus submodule @@ -69,6 +70,25 @@ that skips enforcement is the entire reason the policy layer exists. widen `capabilities()` in lockstep with the accessors. 4. Reserve the driver id: `DriverRegistry::builtin().with_reserved("my-engine", DriverClass::Embedded)`. +## Remote engines + +The `tinymemory-remote` crate supports the self-hosted native APIs of +Supermemory, Mem0, and Cognee. Each adapter stores TinyMemory's key, category, +session, and provenance in backend metadata (or a Cognee raw-data envelope), so +exact CRUD and portability survive the seam while recall remains engine-native. + +```rust +use tinymemory_remote::{SupermemoryMemory, supermemory_provider}; + +let memory = SupermemoryMemory::new("http://localhost:6767", Some("sm_..."))?; +let provider = supermemory_provider(memory); +# Ok::<_, anyhow::Error>(provider) +``` + +All three 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). + ## Development ```bash diff --git a/adapters/remote/Cargo.toml b/adapters/remote/Cargo.toml new file mode 100644 index 0000000..870bd70 --- /dev/null +++ b/adapters/remote/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "tinymemory-remote" +publish = false +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT" +description = "HTTP adapters for self-hosted Supermemory, Mem0, and Cognee" +repository = "https://github.com/tinyhumansai/tinymemory" + +[dependencies] +# The engine-neutral contract and mandatory-family composition. +tinymemory = { path = "../.." } +tinymemory-api = { path = "../../api" } +# Memory is an object-safe async trait and each native HTTP dialect is async. +async-trait = "0.1" +# The storage trait deliberately uses opaque backend errors. +anyhow = "1" +# Native self-hosted APIs are HTTP/JSON; multipart is required by Cognee. +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } +# Remote records are translated through a private, lossless envelope. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Supermemory custom ids are bounded, so namespace/key identities use SHA-256. +sha2 = "0.10" + +[dev-dependencies] +# Adapter tests run lightweight native-API doubles over a real TCP transport. +axum = { version = "0.8", features = ["multipart"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" +unreachable_pub = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" +missing_errors_doc = "warn" diff --git a/adapters/remote/examples/conformance.rs b/adapters/remote/examples/conformance.rs new file mode 100644 index 0000000..3321b5b --- /dev/null +++ b/adapters/remote/examples/conformance.rs @@ -0,0 +1,112 @@ +//! Live mandatory-family smoke test for a self-hosted remote engine. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tinymemory_api::provider::MemoryProvider; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::types::{MemoryCategory, MemoryTaint}; +use tinymemory_remote::{ + cognee_provider, mem0_provider, supermemory_provider, CogneeMemory, Mem0Memory, + SupermemoryMemory, +}; + +fn usage() -> anyhow::Error { + anyhow::anyhow!("usage: conformance [credential]") +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let engine = args.next().ok_or_else(usage)?; + let endpoint = args.next().ok_or_else(usage)?; + let credential = args.next(); + let provider: Arc = match engine.as_str() { + "supermemory" => Arc::new(supermemory_provider(SupermemoryMemory::new( + &endpoint, + credential.as_deref(), + )?)), + "mem0" => Arc::new(mem0_provider(Mem0Memory::new( + &endpoint, + credential.as_deref(), + )?)), + "cognee" => Arc::new(cognee_provider(CogneeMemory::new( + &endpoint, + credential.as_deref(), + )?)), + _ => return Err(usage()), + }; + + tinymemory_api::provider::audit_provider(provider.as_ref())?; + let health = provider.health().await; + anyhow::ensure!(health.is_usable(), "driver health is {health:?}"); + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let namespace = format!("tinymemory-conformance-{suffix}"); + let key = "native-round-trip"; + let content = format!("TinyMemory native adapter conformance marker {suffix}"); + + provider + .store( + &namespace, + key, + &content, + MemoryCategory::Core, + Some("live-conformance"), + MemoryTaint::ExternalSync, + ) + .await?; + let stored = provider + .get(&namespace, key) + .await? + .ok_or_else(|| anyhow::anyhow!("stored record was not readable"))?; + anyhow::ensure!(stored.content == content, "stored content changed"); + anyhow::ensure!( + stored.taint == MemoryTaint::ExternalSync, + "stored taint changed" + ); + + let hits = provider + .recall( + "conformance marker", + 10, + &OwnedRecallOpts { + namespace: Some(namespace.clone()), + ..OwnedRecallOpts::default() + }, + None, + ) + .await?; + anyhow::ensure!(!hits.is_empty(), "native recall returned no record"); + + let mut cursor = None; + let mut exported_keys = Vec::new(); + loop { + let page = provider.export_page(cursor.as_deref(), 100).await?; + exported_keys.extend(page.records.iter().filter_map(|record| { + record + .payload + .get("key") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + })); + let Some(next) = page.next_cursor else { + break; + }; + cursor = Some(next); + } + anyhow::ensure!( + exported_keys.iter().any(|exported| exported == key), + "portability export omitted the record; exported keys: {exported_keys:?}" + ); + anyhow::ensure!( + provider.forget(&namespace, key).await?, + "forget missed record" + ); + + println!( + "{}: Core, Recall, and Portability passed", + provider.driver_id() + ); + Ok(()) +} diff --git a/adapters/remote/src/cognee.rs b/adapters/remote/src/cognee.rs new file mode 100644 index 0000000..75012da --- /dev/null +++ b/adapters/remote/src/cognee.rs @@ -0,0 +1,335 @@ +//! Self-hosted Cognee REST adapter. + +use anyhow::{anyhow, Context}; +use async_trait::async_trait; +use reqwest::{multipart, Method}; +use serde_json::{json, Value}; +use tinymemory_api::recall::RecallOpts; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::MemoryTaint; + +use crate::common::{encode, Dialect, HttpClient, RemoteMemory, StoredEntry}; + +/// Stable driver id used by configuration and status output. +pub use tinymemory::registry::COGNEE_DRIVER_ID; + +/// A self-hosted Cognee server exposed through TinyMemory's storage contract. +#[derive(Debug)] +pub struct CogneeMemory { + inner: RemoteMemory, +} + +impl CogneeMemory { + /// Connect to a Cognee server. + /// + /// `access_token` is sent as a bearer token. Local deployments with + /// backend access control disabled may pass `None`. + /// + /// # Errors + /// + /// Returns an error when `endpoint` is not an HTTP(S) URL. + pub fn new(endpoint: &str, access_token: Option<&str>) -> anyhow::Result { + Ok(Self { + inner: RemoteMemory::new(CogneeDialect { + client: HttpClient::bearer(endpoint, access_token)?, + }), + }) + } +} + +#[async_trait] +impl Memory for CogneeMemory { + fn name(&self) -> &str { + self.inner.name() + } + async fn store( + &self, + n: &str, + k: &str, + c: &str, + cat: tinymemory_api::types::MemoryCategory, + s: Option<&str>, + ) -> anyhow::Result<()> { + self.inner.store(n, k, c, cat, s).await + } + async fn store_with_taint( + &self, + n: &str, + k: &str, + c: &str, + cat: tinymemory_api::types::MemoryCategory, + s: Option<&str>, + t: MemoryTaint, + ) -> anyhow::Result<()> { + self.inner.store_with_taint(n, k, c, cat, s, t).await + } + async fn recall( + &self, + q: &str, + l: usize, + o: RecallOpts<'_>, + ) -> anyhow::Result> { + self.inner.recall(q, l, o).await + } + async fn get( + &self, + n: &str, + k: &str, + ) -> anyhow::Result> { + self.inner.get(n, k).await + } + async fn list( + &self, + n: Option<&str>, + c: Option<&tinymemory_api::types::MemoryCategory>, + s: Option<&str>, + ) -> anyhow::Result> { + self.inner.list(n, c, s).await + } + async fn forget(&self, n: &str, k: &str) -> anyhow::Result { + self.inner.forget(n, k).await + } + async fn namespace_summaries( + &self, + ) -> anyhow::Result> { + self.inner.namespace_summaries().await + } + async fn count(&self) -> anyhow::Result { + self.inner.count().await + } + async fn health_check(&self) -> bool { + self.inner.health_check().await + } +} + +#[derive(Debug)] +struct CogneeDialect { + client: HttpClient, +} + +#[derive(Debug, Clone)] +struct Dataset { + id: String, + name: String, +} + +impl CogneeDialect { + fn dataset_name(namespace: &str) -> String { + format!("tinymemory__{}", encode(namespace)) + } + fn filename(key: &str) -> String { + format!("{}.tinymemory.json", encode(key)) + } + + async fn datasets(&self) -> anyhow::Result> { + let response: Value = self + .client + .json(Method::GET, "api/v1/datasets", None) + .await?; + Ok(response + .as_array() + .into_iter() + .flatten() + .filter_map(|value| { + Some(Dataset { + id: value.get("id")?.as_str()?.to_owned(), + name: value.get("name")?.as_str()?.to_owned(), + }) + }) + .filter(|dataset| dataset.name.starts_with("tinymemory__")) + .collect()) + } + + async fn dataset_entries(&self, dataset: &Dataset) -> anyhow::Result> { + let response: Value = self + .client + .json( + Method::GET, + &format!("api/v1/datasets/{}/data", dataset.id), + None, + ) + .await?; + let mut entries = Vec::new(); + for data in response.as_array().into_iter().flatten() { + let Some(id) = data.get("id").and_then(Value::as_str) else { + continue; + }; + let Some(name) = data.get("name").and_then(Value::as_str) else { + continue; + }; + // Cognee's text loader strips the final `.json` extension from + // uploaded filenames; API-shaped test doubles may preserve it. + if !name.ends_with(".tinymemory") && !name.ends_with(".tinymemory.json") { + continue; + } + let raw = self + .client + .text( + Method::GET, + &format!("api/v1/datasets/{}/data/{id}/raw", dataset.id), + ) + .await?; + let mut entry: StoredEntry = + serde_json::from_str(&raw).context("Cognee record envelope is invalid")?; + entry.remote_id = format!("{}:{id}", dataset.id); + if entry.timestamp.is_empty() { + entry.timestamp = data + .get("updatedAt") + .or_else(|| data.get("updated_at")) + .or_else(|| data.get("createdAt")) + .or_else(|| data.get("created_at")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + } + entries.push(entry); + } + Ok(entries) + } + + async fn find_dataset(&self, namespace: &str) -> anyhow::Result> { + let name = Self::dataset_name(namespace); + Ok(self + .datasets() + .await? + .into_iter() + .find(|dataset| dataset.name == name)) + } + + async fn delete_entry(&self, entry: &StoredEntry) -> anyhow::Result<()> { + let (dataset_id, data_id) = entry + .remote_id + .split_once(':') + .ok_or_else(|| anyhow!("Cognee record has no dataset id"))?; + self.client + .empty( + Method::DELETE, + &format!("api/v1/datasets/{dataset_id}/data/{data_id}"), + None, + ) + .await?; + Ok(()) + } +} + +#[async_trait] +impl Dialect for CogneeDialect { + fn name(&self) -> &'static str { + COGNEE_DRIVER_ID + } + + async fn upsert(&self, entry: StoredEntry) -> anyhow::Result<()> { + if let Some(existing) = self + .entries() + .await? + .into_iter() + .find(|item| item.namespace == entry.namespace && item.key == entry.key) + { + self.delete_entry(&existing).await?; + } + let body = serde_json::to_vec(&entry)?; + let form = multipart::Form::new() + .text("datasetName", Self::dataset_name(&entry.namespace)) + .text("run_in_background", "false") + .part( + "data", + multipart::Part::bytes(body) + .file_name(Self::filename(&entry.key)) + .mime_str("application/json")?, + ); + let response = self + .client + .multipart("api/v1/remember")? + .multipart(form) + .send() + .await?; + if !response.status().is_success() { + return Err(anyhow!( + "memory API api/v1/remember returned HTTP {}", + response.status() + )); + } + Ok(()) + } + + async fn entries(&self) -> anyhow::Result> { + let mut entries = Vec::new(); + for dataset in self.datasets().await? { + entries.extend(self.dataset_entries(&dataset).await?); + } + Ok(entries) + } + + async fn search( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result> { + let datasets = opts.namespace.map(Self::dataset_name); + let response: Value = self + .client + .json( + Method::POST, + "api/v1/recall", + Some(&json!({ + "query": query, + "search_type": "CHUNKS", + "datasets": datasets.map(|name| vec![name]), + "top_k": limit, + "only_context": true, + "session_id": opts.session_id + })), + ) + .await?; + let mut entries = Vec::new(); + for value in response.as_array().into_iter().flatten() { + let text = value + .get("text") + .or_else(|| value.get("content")) + .or_else(|| value.get("result_object")) + .and_then(Value::as_str); + if let Some(text) = text { + if let Ok(mut entry) = serde_json::from_str::(text) { + entry.score = value.get("score").and_then(Value::as_f64); + entries.push(entry); + } else { + // CHUNKS recall may coalesce adjacent source documents into + // newline-delimited text. Each source remains a complete + // TinyMemory envelope, so decode them independently. + for line in text.lines() { + if let Ok(mut entry) = serde_json::from_str::(line) { + entry.score = value.get("score").and_then(Value::as_f64); + entries.push(entry); + } + } + } + } + } + Ok(entries) + } + + async fn delete(&self, namespace: &str, key: &str) -> anyhow::Result { + let Some(dataset) = self.find_dataset(namespace).await? else { + return Ok(false); + }; + let Some(entry) = self + .dataset_entries(&dataset) + .await? + .into_iter() + .find(|item| item.key == key) + else { + return Ok(false); + }; + self.delete_entry(&entry).await?; + Ok(true) + } + + async fn health(&self) -> bool { + self.client.healthy("health").await + } +} + +#[cfg(test)] +#[path = "cognee_test.rs"] +mod test; diff --git a/adapters/remote/src/cognee_test.rs b/adapters/remote/src/cognee_test.rs new file mode 100644 index 0000000..b424047 --- /dev/null +++ b/adapters/remote/src/cognee_test.rs @@ -0,0 +1,132 @@ +//! Cognee adapter contract tests over dataset, raw-file, and recall APIs. + +#![allow(clippy::expect_used)] + +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post}, + Json, Router, +}; +use serde_json::{json, Value}; +use tinymemory_api::{ + provider::{MemoryCore, MemoryProvider, MemoryRecall}, + recall::OwnedRecallOpts, + types::{MemoryCategory, MemoryTaint}, +}; + +#[derive(Clone, Default)] +struct AppState(Arc>>>); + +async fn datasets(State(state): State) -> Json { + let values = if state.0.lock().expect("state lock").is_some() { + vec![json!({"id": "dataset-1", "name": "tinymemory__70726f6a656374"})] + } else { + vec![] + }; + Json(Value::Array(values)) +} +async fn data(State(state): State) -> Json { + let values = if state.0.lock().expect("state lock").is_some() { + vec![ + json!({"id": "data-1", "name": "6b6579.tinymemory.json", "created_at": "2026-08-12T00:00:00Z"}), + ] + } else { + vec![] + }; + Json(Value::Array(values)) +} +async fn raw(State(state): State) -> impl IntoResponse { + state.0.lock().expect("state lock").clone().map_or_else( + || (StatusCode::NOT_FOUND, Vec::new()), + |body| (StatusCode::OK, body), + ) +} +async fn remember(State(state): State, mut multipart: Multipart) -> StatusCode { + while let Some(field) = multipart.next_field().await.expect("multipart") { + if field.name() == Some("data") { + *state.0.lock().expect("state lock") = + Some(field.bytes().await.expect("body").to_vec()); + } + } + StatusCode::OK +} +async fn remove(State(state): State) -> StatusCode { + *state.0.lock().expect("state lock") = None; + StatusCode::NO_CONTENT +} +async fn recall(State(state): State) -> Json { + let records = state + .0 + .lock() + .expect("state lock") + .as_ref() + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()) + .map(|text| vec![json!({"text": text, "score": 0.8})]) + .unwrap_or_default(); + Json(Value::Array(records)) +} + +#[tokio::test] +async fn native_cognee_round_trips_the_tinymemory_contract() { + let state = AppState::default(); + let app = Router::new() + .route("/api/v1/datasets", get(datasets)) + .route("/api/v1/datasets/{dataset}/data", get(data)) + .route("/api/v1/datasets/{dataset}/data/{data}/raw", get(raw)) + .route("/api/v1/datasets/{dataset}/data/{data}", delete(remove)) + .route("/api/v1/remember", post(remember)) + .route("/api/v1/recall", post(recall)) + .route("/health", get(|| async { StatusCode::OK })) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let driver = crate::cognee_provider(super::CogneeMemory::new(&endpoint, None).expect("client")); + tinymemory_api::provider::audit_provider(&driver).expect("honest capabilities"); + driver + .store( + "project", + "key", + "knowledge graph", + MemoryCategory::Conversation, + Some("session"), + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + let entry = driver + .get("project", "key") + .await + .expect("get") + .expect("entry"); + assert_eq!(entry.content, "knowledge graph"); + assert_eq!(entry.taint, MemoryTaint::ExternalSync); + assert_eq!( + driver + .recall( + "graph", + 3, + &OwnedRecallOpts { + namespace: Some("project".into()), + ..OwnedRecallOpts::default() + }, + None + ) + .await + .expect("recall") + .len(), + 1 + ); + assert!(driver.forget("project", "key").await.expect("forget")); + assert!(!driver.forget("project", "key").await.expect("forget again")); + assert!(driver.health().await.is_usable()); +} diff --git a/adapters/remote/src/common.rs b/adapters/remote/src/common.rs new file mode 100644 index 0000000..62eea35 --- /dev/null +++ b/adapters/remote/src/common.rs @@ -0,0 +1,400 @@ +//! Shared transport and exact-record behavior for remote engine dialects. + +use std::collections::BTreeMap; + +use anyhow::{bail, Context}; +use async_trait::async_trait; +use reqwest::{Method, RequestBuilder, StatusCode, Url}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::{ + MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, +}; + +#[derive(Clone)] +pub(crate) struct HttpClient { + inner: reqwest::Client, + endpoint: Url, + auth: Auth, +} + +#[derive(Clone)] +enum Auth { + None, + Bearer(String), + ApiKey(String), +} + +impl std::fmt::Debug for HttpClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpClient") + .field("endpoint", &self.endpoint.origin().ascii_serialization()) + .field("authenticated", &!matches!(self.auth, Auth::None)) + .finish() + } +} + +impl HttpClient { + pub(crate) fn bearer(endpoint: &str, credential: Option<&str>) -> anyhow::Result { + Self::new( + endpoint, + credential.map_or(Auth::None, |value| Auth::Bearer(value.into())), + ) + } + + pub(crate) fn api_key(endpoint: &str, credential: Option<&str>) -> anyhow::Result { + Self::new( + endpoint, + credential.map_or(Auth::None, |value| Auth::ApiKey(value.into())), + ) + } + + fn new(endpoint: &str, auth: Auth) -> 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"); + } + if !endpoint.path().ends_with('/') { + let path = format!("{}/", endpoint.path()); + endpoint.set_path(&path); + } + Ok(Self { + inner: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build()?, + endpoint, + auth, + }) + } + + fn request(&self, method: Method, path: &str) -> anyhow::Result { + let url = self + .endpoint + .join(path.trim_start_matches('/')) + .context("memory API path is invalid")?; + let request = self.inner.request(method, url); + Ok(match &self.auth { + Auth::None => request, + Auth::Bearer(token) => request.bearer_auth(token), + Auth::ApiKey(key) => request.header("X-API-Key", key), + }) + } + + pub(crate) async fn json( + &self, + method: Method, + path: &str, + body: Option<&serde_json::Value>, + ) -> anyhow::Result { + let mut request = self.request(method, path)?; + if let Some(body) = body { + request = request.json(body); + } + let response = request.send().await.context("memory API request failed")?; + let status = response.status(); + if !status.is_success() { + bail!("memory API {path} returned HTTP {status}"); + } + response + .json() + .await + .with_context(|| format!("memory API {path} returned invalid JSON")) + } + + pub(crate) async fn text(&self, method: Method, path: &str) -> anyhow::Result { + let response = self.request(method, path)?.send().await?; + let status = response.status(); + if !status.is_success() { + bail!("memory API {path} returned HTTP {status}"); + } + response + .text() + .await + .context("memory API response was unreadable") + } + + pub(crate) async fn empty( + &self, + method: Method, + path: &str, + body: Option<&serde_json::Value>, + ) -> anyhow::Result { + let mut request = self.request(method, path)?; + if let Some(body) = body { + request = request.json(body); + } + let response = request.send().await?; + let status = response.status(); + if !status.is_success() { + bail!("memory API {path} returned HTTP {status}"); + } + Ok(status) + } + + pub(crate) fn multipart(&self, path: &str) -> anyhow::Result { + self.request(Method::POST, path) + } + + pub(crate) async fn healthy(&self, path: &str) -> bool { + let Ok(request) = self.request(Method::GET, path) else { + return false; + }; + request + .send() + .await + .is_ok_and(|response| response.status().is_success()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct StoredEntry { + #[serde(default)] + pub(crate) remote_id: String, + pub(crate) namespace: String, + pub(crate) key: String, + pub(crate) content: String, + pub(crate) category: MemoryCategory, + #[serde(default)] + pub(crate) timestamp: String, + #[serde(default)] + pub(crate) session_id: Option, + #[serde(default)] + pub(crate) score: Option, + #[serde(default)] + pub(crate) taint: MemoryTaint, +} + +impl StoredEntry { + pub(crate) fn new( + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> Self { + Self { + remote_id: String::new(), + namespace: namespace.to_owned(), + key: key.to_owned(), + content: content.to_owned(), + category, + timestamp: String::new(), + session_id: session_id.map(str::to_owned), + score: None, + taint, + } + } + + pub(crate) fn into_memory_entry(self) -> MemoryEntry { + MemoryEntry { + id: if self.remote_id.is_empty() { + stable_id(&self.namespace, &self.key) + } else { + self.remote_id + }, + key: self.key, + content: self.content, + namespace: Some(self.namespace), + category: self.category, + timestamp: self.timestamp, + session_id: self.session_id, + score: self.score, + taint: self.taint, + } + } +} + +pub(crate) fn stable_id(namespace: &str, key: &str) -> String { + let mut digest = Sha256::new(); + digest.update(namespace.as_bytes()); + digest.update([0]); + digest.update(key.as_bytes()); + format!("tm_{}", encode(&digest.finalize()[..20])) +} + +pub(crate) fn encode(value: impl AsRef<[u8]>) -> String { + let value = value.as_ref(); + value.iter().fold( + String::with_capacity(value.len() * 2), + |mut output, byte| { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + output + }, + ) +} + +pub(crate) fn category(raw: Option<&str>) -> MemoryCategory { + raw.and_then(|value| value.parse().ok()) + .unwrap_or_else(|| MemoryCategory::Custom("remote".into())) +} + +#[async_trait] +pub(crate) trait Dialect: Send + Sync + std::fmt::Debug { + fn name(&self) -> &'static str; + async fn upsert(&self, entry: StoredEntry) -> anyhow::Result<()>; + async fn entries(&self) -> anyhow::Result>; + async fn search( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result>; + async fn delete(&self, namespace: &str, key: &str) -> anyhow::Result; + async fn health(&self) -> bool; +} + +#[derive(Debug)] +pub(crate) struct RemoteMemory { + dialect: D, +} + +impl RemoteMemory { + pub(crate) fn new(dialect: D) -> Self { + Self { dialect } + } +} + +#[async_trait] +impl Memory for RemoteMemory { + fn name(&self) -> &str { + self.dialect.name() + } + + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + ) -> anyhow::Result<()> { + self.store_with_taint( + namespace, + key, + content, + category, + session_id, + MemoryTaint::Internal, + ) + .await + } + + async fn store_with_taint( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> anyhow::Result<()> { + if namespace.is_empty() || key.is_empty() { + bail!("namespace and key must not be empty"); + } + self.dialect + .upsert(StoredEntry::new( + namespace, key, content, category, session_id, taint, + )) + .await + } + + async fn recall( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result> { + if limit == 0 || query.trim().is_empty() { + return Ok(Vec::new()); + } + let min_score = opts.min_score; + let mut entries = self.dialect.search(query, limit, opts.clone()).await?; + entries.retain(|entry| matches_filters(entry, &opts)); + if let Some(minimum) = min_score { + entries.retain(|entry| entry.score.is_none_or(|score| score >= minimum)); + } + entries.truncate(limit); + Ok(entries + .into_iter() + .map(StoredEntry::into_memory_entry) + .collect()) + } + + async fn get(&self, namespace: &str, key: &str) -> anyhow::Result> { + Ok(self + .dialect + .entries() + .await? + .into_iter() + .find(|entry| entry.namespace == namespace && entry.key == key) + .map(StoredEntry::into_memory_entry)) + } + + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> anyhow::Result> { + let mut entries = self.dialect.entries().await?; + entries.retain(|entry| { + namespace.is_none_or(|value| entry.namespace == value) + && category.is_none_or(|value| &entry.category == value) + && session_id.is_none_or(|value| entry.session_id.as_deref() == Some(value)) + }); + Ok(entries + .into_iter() + .map(StoredEntry::into_memory_entry) + .collect()) + } + + async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result { + self.dialect.delete(namespace, key).await + } + + async fn namespace_summaries(&self) -> anyhow::Result> { + let mut summaries: BTreeMap = BTreeMap::new(); + for entry in self.dialect.entries().await? { + let summary = + summaries + .entry(entry.namespace.clone()) + .or_insert_with(|| NamespaceSummary { + namespace: entry.namespace, + count: 0, + last_updated: None, + }); + summary.count += 1; + if !entry.timestamp.is_empty() + && summary + .last_updated + .as_ref() + .is_none_or(|current| current < &entry.timestamp) + { + summary.last_updated = Some(entry.timestamp); + } + } + Ok(summaries.into_values().collect()) + } + + async fn count(&self) -> anyhow::Result { + Ok(self.dialect.entries().await?.len()) + } + + async fn health_check(&self) -> bool { + self.dialect.health().await + } +} + +fn matches_filters(entry: &StoredEntry, opts: &RecallOpts<'_>) -> bool { + opts.namespace.is_none_or(|value| entry.namespace == value) + && opts + .category + .as_ref() + .is_none_or(|value| &entry.category == value) + && opts + .session_id + .is_none_or(|value| entry.session_id.as_deref() == Some(value)) +} diff --git a/adapters/remote/src/lib.rs b/adapters/remote/src/lib.rs new file mode 100644 index 0000000..c513c98 --- /dev/null +++ b/adapters/remote/src/lib.rs @@ -0,0 +1,40 @@ +//! Native HTTP adapters for self-hosted memory engines. +//! +//! The adapters preserve TinyMemory's exact `(namespace, key)` upsert contract +//! in backend metadata while delegating semantic recall to each engine's native +//! search API. They advertise Core, Recall, and Portability through +//! [`tinymemory::mandatory::MemoryTraitProvider`]. +//! +//! Credentials are accepted only at construction and are never exposed by +//! `Debug` implementations or error messages. + +pub mod cognee; +mod common; +pub mod mem0; +pub mod supermemory; + +pub use cognee::{CogneeMemory, COGNEE_DRIVER_ID}; +pub use mem0::{Mem0Memory, MEM0_DRIVER_ID}; +pub use supermemory::{SupermemoryMemory, SUPERMEMORY_DRIVER_ID}; + +use std::sync::Arc; + +use tinymemory::mandatory::MemoryTraitProvider; + +/// Wrap a Supermemory HTTP backend as a bound TinyMemory provider. +#[must_use] +pub fn supermemory_provider(memory: SupermemoryMemory) -> MemoryTraitProvider { + MemoryTraitProvider::new(Arc::new(memory), SUPERMEMORY_DRIVER_ID) +} + +/// Wrap a Mem0 HTTP backend as a bound TinyMemory provider. +#[must_use] +pub fn mem0_provider(memory: Mem0Memory) -> MemoryTraitProvider { + MemoryTraitProvider::new(Arc::new(memory), MEM0_DRIVER_ID) +} + +/// Wrap a Cognee HTTP backend as a bound TinyMemory provider. +#[must_use] +pub fn cognee_provider(memory: CogneeMemory) -> MemoryTraitProvider { + MemoryTraitProvider::new(Arc::new(memory), COGNEE_DRIVER_ID) +} diff --git a/adapters/remote/src/mem0.rs b/adapters/remote/src/mem0.rs new file mode 100644 index 0000000..67071de --- /dev/null +++ b/adapters/remote/src/mem0.rs @@ -0,0 +1,273 @@ +//! Self-hosted Mem0 REST adapter. + +use anyhow::Context; +use async_trait::async_trait; +use reqwest::Method; +use serde_json::{json, Value}; +use tinymemory_api::recall::RecallOpts; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::MemoryTaint; + +use crate::common::{category, Dialect, HttpClient, RemoteMemory, StoredEntry}; + +/// Stable driver id used by configuration and status output. +pub use tinymemory::registry::MEM0_DRIVER_ID; + +/// A self-hosted Mem0 server exposed through TinyMemory's storage contract. +#[derive(Debug)] +pub struct Mem0Memory { + inner: RemoteMemory, +} + +impl Mem0Memory { + /// Connect to a Mem0 REST server. + /// + /// `api_key` is sent as `X-API-Key`. Pass `None` only when the server is + /// explicitly running with `AUTH_DISABLED=true` for local development. + /// + /// # Errors + /// + /// Returns an error when `endpoint` is not an HTTP(S) URL. + pub fn new(endpoint: &str, api_key: Option<&str>) -> anyhow::Result { + Ok(Self { + inner: RemoteMemory::new(Mem0Dialect { + client: HttpClient::api_key(endpoint, api_key)?, + }), + }) + } +} + +#[async_trait] +impl Memory for Mem0Memory { + fn name(&self) -> &str { + self.inner.name() + } + async fn store( + &self, + n: &str, + k: &str, + c: &str, + cat: tinymemory_api::types::MemoryCategory, + s: Option<&str>, + ) -> anyhow::Result<()> { + self.inner.store(n, k, c, cat, s).await + } + async fn store_with_taint( + &self, + n: &str, + k: &str, + c: &str, + cat: tinymemory_api::types::MemoryCategory, + s: Option<&str>, + t: MemoryTaint, + ) -> anyhow::Result<()> { + self.inner.store_with_taint(n, k, c, cat, s, t).await + } + async fn recall( + &self, + q: &str, + l: usize, + o: RecallOpts<'_>, + ) -> anyhow::Result> { + self.inner.recall(q, l, o).await + } + async fn get( + &self, + n: &str, + k: &str, + ) -> anyhow::Result> { + self.inner.get(n, k).await + } + async fn list( + &self, + n: Option<&str>, + c: Option<&tinymemory_api::types::MemoryCategory>, + s: Option<&str>, + ) -> anyhow::Result> { + self.inner.list(n, c, s).await + } + async fn forget(&self, n: &str, k: &str) -> anyhow::Result { + self.inner.forget(n, k).await + } + async fn namespace_summaries( + &self, + ) -> anyhow::Result> { + self.inner.namespace_summaries().await + } + async fn count(&self) -> anyhow::Result { + self.inner.count().await + } + async fn health_check(&self) -> bool { + self.inner.health_check().await + } +} + +#[derive(Debug)] +struct Mem0Dialect { + client: HttpClient, +} + +impl Mem0Dialect { + async fn values(&self) -> anyhow::Result> { + let response: Value = self + .client + .json(Method::GET, "memories?top_k=1000", None) + .await?; + Ok(response + .get("results") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) + } + + fn decode(value: &Value) -> Option { + let metadata = value.get("metadata")?.as_object()?; + let namespace = metadata.get("tinymemory_namespace")?.as_str()?.to_owned(); + let key = metadata.get("tinymemory_key")?.as_str()?.to_owned(); + Some(StoredEntry { + remote_id: value.get("id")?.as_str()?.to_owned(), + namespace, + key, + content: value + .get("memory") + .or_else(|| value.get("data"))? + .as_str()? + .to_owned(), + category: category(metadata.get("tinymemory_category").and_then(Value::as_str)), + timestamp: value + .get("updated_at") + .or_else(|| value.get("created_at")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + session_id: metadata + .get("tinymemory_session_id") + .and_then(Value::as_str) + .map(str::to_owned), + score: value.get("score").and_then(Value::as_f64), + taint: metadata + .get("tinymemory_taint") + .and_then(Value::as_str) + .map(MemoryTaint::from_db_str) + .unwrap_or_default(), + }) + } + + fn metadata(entry: &StoredEntry) -> Value { + let mut value = json!({ + "tinymemory_namespace": entry.namespace, + "tinymemory_key": entry.key, + "tinymemory_category": entry.category.to_string(), + "tinymemory_taint": entry.taint.as_db_str(), + }); + if let (Some(object), Some(session_id)) = (value.as_object_mut(), &entry.session_id) { + object.insert("tinymemory_session_id".into(), json!(session_id)); + } + value + } +} + +#[async_trait] +impl Dialect for Mem0Dialect { + fn name(&self) -> &'static str { + MEM0_DRIVER_ID + } + + async fn upsert(&self, entry: StoredEntry) -> anyhow::Result<()> { + let existing = self + .entries() + .await? + .into_iter() + .find(|item| item.namespace == entry.namespace && item.key == entry.key); + let metadata = Self::metadata(&entry); + if let Some(existing) = existing { + self.client + .empty( + Method::PUT, + &format!("memories/{}", existing.remote_id), + Some(&json!({"text": entry.content, "metadata": metadata})), + ) + .await?; + } else { + self.client + .empty( + Method::POST, + "memories", + Some(&json!({ + "messages": [{"role": "user", "content": entry.content}], + "user_id": entry.namespace, + "run_id": entry.session_id, + "metadata": metadata, + "infer": false + })), + ) + .await?; + } + Ok(()) + } + + async fn entries(&self) -> anyhow::Result> { + Ok(self + .values() + .await? + .iter() + .filter_map(Self::decode) + .collect()) + } + + async fn search( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result> { + let mut filters = serde_json::Map::new(); + if let Some(namespace) = opts.namespace { + filters.insert("user_id".into(), json!(namespace)); + } + let response: Value = self + .client + .json( + Method::POST, + "search", + Some(&json!({ + "query": query, "filters": filters, "top_k": limit, "threshold": opts.min_score + })), + ) + .await?; + let values = response + .get("results") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + Ok(values.iter().filter_map(Self::decode).collect()) + } + + async fn delete(&self, namespace: &str, key: &str) -> anyhow::Result { + let Some(entry) = self + .entries() + .await? + .into_iter() + .find(|item| item.namespace == namespace && item.key == key) + else { + return Ok(false); + }; + self.client + .empty( + Method::DELETE, + &format!("memories/{}", entry.remote_id), + None, + ) + .await + .context("failed to delete Mem0 memory")?; + Ok(true) + } + + async fn health(&self) -> bool { + self.client.healthy("api/health").await || self.client.healthy("").await + } +} + +#[cfg(test)] +#[path = "mem0_test.rs"] +mod test; diff --git a/adapters/remote/src/mem0_test.rs b/adapters/remote/src/mem0_test.rs new file mode 100644 index 0000000..57fa865 --- /dev/null +++ b/adapters/remote/src/mem0_test.rs @@ -0,0 +1,142 @@ +//! Mem0 adapter contract tests over its native HTTP shapes. + +#![allow(clippy::expect_used)] + +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, post, put}, + Json, Router, +}; +use serde_json::{json, Value}; +use tinymemory_api::{ + provider::{MemoryCore, MemoryProvider, MemoryRecall}, + recall::OwnedRecallOpts, + types::{MemoryCategory, MemoryTaint}, +}; + +#[derive(Clone, Default)] +struct AppState(Arc>>); + +async fn list(State(state): State) -> Json { + Json(json!({"results": state.0.lock().expect("state lock").clone()})) +} + +async fn add(State(state): State, Json(body): Json) -> Json { + let mut records = state.0.lock().expect("state lock"); + let id = format!("mem-{}", records.len() + 1); + records.push(json!({ + "id": id, + "memory": body.pointer("/messages/0/content"), + "metadata": body.get("metadata"), + "created_at": "2026-08-12T00:00:00Z" + })); + Json(json!({"results": [{"id": id}]})) +} + +async fn update( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> StatusCode { + if let Some(record) = state + .0 + .lock() + .expect("state lock") + .iter_mut() + .find(|record| record["id"] == id) + { + record["memory"] = body["text"].clone(); + record["metadata"] = body["metadata"].clone(); + } + StatusCode::OK +} + +async fn remove(State(state): State, Path(id): Path) -> StatusCode { + state + .0 + .lock() + .expect("state lock") + .retain(|record| record["id"] != id); + StatusCode::OK +} + +async fn search(State(state): State) -> Json { + let mut records = state.0.lock().expect("state lock").clone(); + for record in &mut records { + record["score"] = json!(0.9); + } + Json(json!({"results": records})) +} + +#[tokio::test] +async fn native_mem0_round_trips_the_tinymemory_contract() { + let state = AppState::default(); + let app = Router::new() + .route("/memories", get(list).post(add)) + .route("/memories/{id}", put(update).delete(remove)) + .route("/search", post(search)) + .route("/api/health", get(|| async { StatusCode::OK })) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let memory = super::Mem0Memory::new(&endpoint, None).expect("client"); + let driver = crate::mem0_provider(memory); + tinymemory_api::provider::audit_provider(&driver).expect("honest capabilities"); + driver + .store( + "people", + "alice", + "likes tea", + MemoryCategory::Core, + Some("s1"), + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + driver + .store( + "people", + "alice", + "likes coffee", + MemoryCategory::Daily, + Some("s2"), + MemoryTaint::Internal, + ) + .await + .expect("upsert"); + let entry = driver + .get("people", "alice") + .await + .expect("get") + .expect("entry"); + assert_eq!(entry.content, "likes coffee"); + assert_eq!(entry.category, MemoryCategory::Daily); + let hits = driver + .recall( + "coffee", + 2, + &OwnedRecallOpts { + namespace: Some("people".into()), + ..OwnedRecallOpts::default() + }, + None, + ) + .await + .expect("recall"); + assert_eq!(hits.len(), 1); + assert!(driver.forget("people", "alice").await.expect("forget")); + assert!(!driver + .forget("people", "alice") + .await + .expect("forget again")); + assert!(driver.health().await.is_usable()); +} diff --git a/adapters/remote/src/supermemory.rs b/adapters/remote/src/supermemory.rs new file mode 100644 index 0000000..38afce1 --- /dev/null +++ b/adapters/remote/src/supermemory.rs @@ -0,0 +1,332 @@ +//! Self-hosted Supermemory API adapter. + +use async_trait::async_trait; +use reqwest::Method; +use serde_json::{json, Value}; +use tinymemory_api::recall::RecallOpts; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::MemoryTaint; + +use crate::common::{category, Dialect, HttpClient, RemoteMemory, StoredEntry}; + +/// Stable driver id used by configuration and status output. +pub use tinymemory::registry::SUPERMEMORY_DRIVER_ID; + +/// A self-hosted Supermemory server exposed through TinyMemory's storage contract. +#[derive(Debug)] +pub struct SupermemoryMemory { + inner: RemoteMemory, +} + +impl SupermemoryMemory { + /// Connect to a Supermemory server using its bearer API key. + /// + /// # Errors + /// + /// Returns an error when `endpoint` is not an HTTP(S) URL. + pub fn new(endpoint: &str, api_key: Option<&str>) -> anyhow::Result { + Ok(Self { + inner: RemoteMemory::new(SupermemoryDialect { + client: HttpClient::bearer(endpoint, api_key)?, + }), + }) + } +} + +#[async_trait] +impl Memory for SupermemoryMemory { + fn name(&self) -> &str { + self.inner.name() + } + async fn store( + &self, + n: &str, + k: &str, + c: &str, + cat: tinymemory_api::types::MemoryCategory, + s: Option<&str>, + ) -> anyhow::Result<()> { + self.inner.store(n, k, c, cat, s).await + } + async fn store_with_taint( + &self, + n: &str, + k: &str, + c: &str, + cat: tinymemory_api::types::MemoryCategory, + s: Option<&str>, + t: MemoryTaint, + ) -> anyhow::Result<()> { + self.inner.store_with_taint(n, k, c, cat, s, t).await + } + async fn recall( + &self, + q: &str, + l: usize, + o: RecallOpts<'_>, + ) -> anyhow::Result> { + self.inner.recall(q, l, o).await + } + async fn get( + &self, + n: &str, + k: &str, + ) -> anyhow::Result> { + self.inner.get(n, k).await + } + async fn list( + &self, + n: Option<&str>, + c: Option<&tinymemory_api::types::MemoryCategory>, + s: Option<&str>, + ) -> anyhow::Result> { + self.inner.list(n, c, s).await + } + async fn forget(&self, n: &str, k: &str) -> anyhow::Result { + self.inner.forget(n, k).await + } + async fn namespace_summaries( + &self, + ) -> anyhow::Result> { + self.inner.namespace_summaries().await + } + async fn count(&self) -> anyhow::Result { + self.inner.count().await + } + async fn health_check(&self) -> bool { + self.inner.health_check().await + } +} + +#[derive(Debug)] +struct SupermemoryDialect { + client: HttpClient, +} + +impl SupermemoryDialect { + fn metadata(entry: &StoredEntry) -> Value { + let mut metadata = serde_json::Map::from_iter([ + ("tinymemory_namespace".into(), json!(entry.namespace)), + ("tinymemory_key".into(), json!(entry.key)), + ( + "tinymemory_category".into(), + json!(entry.category.to_string()), + ), + ("tinymemory_taint".into(), json!(entry.taint.as_db_str())), + ]); + if let Some(session) = &entry.session_id { + metadata.insert("tinymemory_session_id".into(), json!(session)); + } + Value::Object(metadata) + } + + fn decode(value: &Value) -> Option { + let metadata = value.get("metadata")?.as_object()?; + Some(StoredEntry { + remote_id: value.get("id")?.as_str()?.to_owned(), + namespace: metadata.get("tinymemory_namespace")?.as_str()?.to_owned(), + key: metadata.get("tinymemory_key")?.as_str()?.to_owned(), + content: value + .get("content") + .or_else(|| value.get("memory")) + .or_else(|| value.get("chunk"))? + .as_str()? + .to_owned(), + category: category(metadata.get("tinymemory_category").and_then(Value::as_str)), + timestamp: value + .get("updatedAt") + .or_else(|| value.get("createdAt")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + session_id: metadata + .get("tinymemory_session_id") + .and_then(Value::as_str) + .map(str::to_owned), + score: value.get("similarity").and_then(Value::as_f64), + taint: metadata + .get("tinymemory_taint") + .and_then(Value::as_str) + .map(MemoryTaint::from_db_str) + .unwrap_or_default(), + }) + } + + async fn memories(&self) -> anyhow::Result> { + let tags: Value = self + .client + .json(Method::GET, "v3/container-tags/list", None) + .await?; + let container_tags = tags + .as_array() + .into_iter() + .flatten() + .filter_map(|value| value.get("containerTag").and_then(Value::as_str)) + .collect::>(); + if container_tags.is_empty() { + return Ok(Vec::new()); + } + let mut entries = Vec::new(); + for container_tag in container_tags { + let mut page = 1_u64; + loop { + let response: Value = self + .client + .json( + Method::POST, + "v4/memories/list", + Some(&json!({ + "limit": 200, + "page": page, + "sort": "createdAt", + "order": "desc", + "containerTags": [container_tag] + })), + ) + .await?; + let memories = response + .get("memoryEntries") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for memory in memories { + let is_latest = memory + .get("isLatest") + .and_then(Value::as_bool) + .unwrap_or(true); + let is_forgotten = memory + .get("isForgotten") + .and_then(Value::as_bool) + .unwrap_or(false); + if is_latest && !is_forgotten { + let Some(entry) = Self::decode(&memory) else { + continue; + }; + entries.push(entry); + } + } + let total_pages = response + .pointer("/pagination/totalPages") + .and_then(Value::as_u64) + .unwrap_or(1); + if page >= total_pages { + break; + } + page += 1; + } + } + Ok(entries) + } +} + +#[async_trait] +impl Dialect for SupermemoryDialect { + fn name(&self) -> &'static str { + SUPERMEMORY_DRIVER_ID + } + + async fn upsert(&self, entry: StoredEntry) -> anyhow::Result<()> { + let existing = self + .memories() + .await? + .into_iter() + .find(|item| item.namespace == entry.namespace && item.key == entry.key); + let metadata = Self::metadata(&entry); + if let Some(existing) = existing { + self.client + .empty( + Method::PATCH, + "v4/memories", + Some(&json!({ + "id": existing.remote_id, + "newContent": entry.content, + "metadata": metadata + })), + ) + .await?; + } else { + self.client + .empty( + Method::POST, + "v4/memories", + Some(&json!({ + "memories": [{ + "content": entry.content, + "isStatic": false, + "metadata": metadata + }], + "containerTag": entry.namespace, + })), + ) + .await?; + } + Ok(()) + } + + async fn entries(&self) -> anyhow::Result> { + self.memories().await + } + + async fn search( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result> { + let mut body = json!({ + "q": query, + "searchMode": "memories", + "limit": limit + }); + if let Some(object) = body.as_object_mut() { + if let Some(namespace) = opts.namespace { + object.insert("containerTag".into(), json!(namespace)); + } + if let Some(minimum) = opts.min_score { + object.insert("threshold".into(), json!(minimum)); + } + } + let response: Value = self + .client + .json(Method::POST, "v4/search", Some(&body)) + .await?; + Ok(response + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Self::decode) + .collect()) + } + + async fn delete(&self, namespace: &str, key: &str) -> anyhow::Result { + let Some(entry) = self + .memories() + .await? + .into_iter() + .find(|item| item.namespace == namespace && item.key == key) + else { + return Ok(false); + }; + self.client + .empty( + Method::DELETE, + "v4/memories", + Some(&json!({ + "id": entry.remote_id, + "containerTag": namespace, + "reason": "deleted through TinyMemory" + })), + ) + .await?; + Ok(true) + } + + async fn health(&self) -> bool { + self.client.healthy("").await + } +} + +#[cfg(test)] +#[path = "supermemory_test.rs"] +mod test; diff --git a/adapters/remote/src/supermemory_test.rs b/adapters/remote/src/supermemory_test.rs new file mode 100644 index 0000000..28d5075 --- /dev/null +++ b/adapters/remote/src/supermemory_test.rs @@ -0,0 +1,133 @@ +//! Supermemory adapter contract tests over its native HTTP shapes. + +#![allow(clippy::expect_used)] + +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde_json::{json, Value}; +use tinymemory_api::{ + provider::{MemoryCore, MemoryProvider, MemoryRecall}, + recall::OwnedRecallOpts, + types::{MemoryCategory, MemoryTaint}, +}; + +#[derive(Clone, Default)] +struct AppState(Arc>>); + +async fn tags() -> Json { + Json(json!([{"containerTag": "project"}])) +} + +async fn list(State(state): State) -> Json { + let records = state.0.lock().expect("state lock"); + Json(json!({"memoryEntries": records.clone(), "pagination": {"totalPages": 1}})) +} +async fn add(State(state): State, Json(body): Json) -> Json { + let mut records = state.0.lock().expect("state lock"); + let id = format!("doc-{}", records.len() + 1); + records.push(json!({ + "id": id, + "memory": body["memories"][0]["content"], + "metadata": body["memories"][0]["metadata"], + "createdAt": "2026-08-12T00:00:00Z", + "isLatest": true, + "isForgotten": false + })); + Json(json!({"memories": [{"id": id}]})) +} +async fn update(State(state): State, Json(body): Json) -> StatusCode { + let id = body["id"].as_str().unwrap_or_default(); + if let Some(record) = state + .0 + .lock() + .expect("state lock") + .iter_mut() + .find(|r| r["id"] == id) + { + record["memory"] = body["newContent"].clone(); + record["metadata"] = body["metadata"].clone(); + } + StatusCode::OK +} +async fn remove(State(state): State, Json(body): Json) -> StatusCode { + let id = body["id"].as_str().unwrap_or_default(); + state + .0 + .lock() + .expect("state lock") + .retain(|r| r["id"] != id); + StatusCode::OK +} +async fn search(State(state): State) -> Json { + let results = state.0.lock().expect("state lock").iter().map(|r| json!({"id": r["id"], "memory": r["memory"], "metadata": r["metadata"], "similarity": 0.95})).collect::>(); + Json(json!({"results": results})) +} + +#[tokio::test] +async fn native_supermemory_round_trips_the_tinymemory_contract() { + let state = AppState::default(); + let app = Router::new() + .route("/v3/container-tags/list", get(tags)) + .route("/v4/memories/list", post(list)) + .route("/v4/memories", post(add).patch(update).delete(remove)) + .route("/v4/search", post(search)) + .route("/", get(|| async { StatusCode::OK })) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let driver = crate::supermemory_provider( + super::SupermemoryMemory::new(&endpoint, Some("secret")).expect("client"), + ); + tinymemory_api::provider::audit_provider(&driver).expect("honest capabilities"); + driver + .store( + "project", + "decision", + "use Rust", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + driver + .store( + "project", + "decision", + "use Rust 2024", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("upsert"); + let entry = driver + .get("project", "decision") + .await + .expect("get") + .expect("entry"); + assert_eq!(entry.content, "use Rust 2024"); + assert_eq!(entry.taint, MemoryTaint::ExternalSync); + assert_eq!( + driver + .recall("Rust", 1, &OwnedRecallOpts::default(), None) + .await + .expect("recall") + .len(), + 1 + ); + assert!(driver.forget("project", "decision").await.expect("forget")); + assert!(driver.health().await.is_usable()); +} diff --git a/integration/remote-engines/README.md b/integration/remote-engines/README.md new file mode 100644 index 0000000..4eb2dae --- /dev/null +++ b/integration/remote-engines/README.md @@ -0,0 +1,38 @@ +# Remote engine conformance + +This harness boots the native self-hosted APIs that `tinymemory-remote` targets. +The Mem0 and Cognee build contexts are pinned to the upstream revisions used +when the dialects were verified. Supermemory's current self-hosted distribution +is its official `supermemory local` server rather than an upstream Compose +file, so the small Dockerfile containerizes that command. + +Run one profile at a time from the repository root: + +```sh +docker compose -f integration/remote-engines/docker-compose.yml --profile supermemory up -d --build +docker compose -f integration/remote-engines/docker-compose.yml logs supermemory +# Copy the `sm_...` API key printed on first boot. +cargo run -p tinymemory-remote --example conformance -- \ + supermemory http://localhost:6767 sm_... + +docker compose -f integration/remote-engines/docker-compose.yml \ + --profile mem0 up -d --build +cargo run -p tinymemory-remote --example conformance -- mem0 http://localhost:8888 + +docker compose -f integration/remote-engines/docker-compose.yml \ + --profile cognee up -d --build +cargo run -p tinymemory-remote --example conformance -- cognee http://localhost:8001 +``` + +Mem0 and Cognee require an inference provider for their native semantic +pipelines. By default the harness starts a deterministic OpenAI-compatible test +service, which proves HTTP, persistence, embeddings, and adapter translation +without an external credential. Set `OPENAI_API_KEY` and `OPENAI_BASE_URL` to +exercise a real compatible provider instead. The test service is a wiring +fixture, not a quality benchmark. + +Stop the harness without deleting its named volumes: + +```sh +docker compose -f integration/remote-engines/docker-compose.yml down +``` diff --git a/integration/remote-engines/docker-compose.yml b/integration/remote-engines/docker-compose.yml new file mode 100644 index 0000000..52e3e49 --- /dev/null +++ b/integration/remote-engines/docker-compose.yml @@ -0,0 +1,101 @@ +name: tinymemory-remote-engines + +services: + supermemory: + profiles: [supermemory] + build: + context: . + dockerfile: supermemory.Dockerfile + environment: + OPENAI_API_KEY: ${OPENAI_API_KEY:-local-superrag-only} + SUPERMEMORY_DATA_DIR: /data + PORT: 6767 + ports: ["6767:6767"] + volumes: ["supermemory-data:/data"] + + mem0: + profiles: [mem0] + build: + context: https://github.com/mem0ai/mem0.git#d70cc00ab39ee09ddcd982581d24d6c435d4fc09:server + dockerfile_inline: | + FROM python:3.12-slim + WORKDIR /app + COPY requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir "psycopg[binary]>=3.2,<4" + RUN mkdir -p /app/history + COPY . . + EXPOSE 8000 + command: sh -c "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000" + environment: + AUTH_DISABLED: "true" + JWT_SECRET: tinymemory-live-test-only + OPENAI_API_KEY: ${OPENAI_API_KEY:-tinymemory-test} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-http://mock-inference:8080/v1} + POSTGRES_HOST: postgres + POSTGRES_PASSWORD: tinymemory + POSTGRES_DB: mem0_app + APP_DB_NAME: mem0_app + MEM0_TELEMETRY: "false" + ports: ["8888:8000"] + depends_on: + postgres: + condition: service_healthy + mock-inference: + condition: service_healthy + + postgres: + profiles: [mem0] + image: pgvector/pgvector:pg17 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: tinymemory + POSTGRES_DB: mem0_app + healthcheck: + test: ["CMD-SHELL", "pg_isready -q -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + volumes: ["mem0-postgres:/var/lib/postgresql/data"] + + cognee: + profiles: [cognee] + build: + context: https://github.com/topoteretes/cognee.git#4b9dd362625dfd3621c344e571a86f5bc7a55ee8 + dockerfile: Dockerfile + environment: + LLM_API_KEY: ${OPENAI_API_KEY:-tinymemory-test} + LLM_PROVIDER: openai + LLM_MODEL: openai/gpt-5-mini + LLM_ENDPOINT: ${OPENAI_BASE_URL:-http://mock-inference:8080/v1} + EMBEDDING_PROVIDER: openai + EMBEDDING_MODEL: openai/text-embedding-3-small + EMBEDDING_ENDPOINT: ${OPENAI_BASE_URL:-http://mock-inference:8080/v1} + EMBEDDING_API_KEY: ${OPENAI_API_KEY:-tinymemory-test} + EMBEDDING_DIMENSIONS: 1536 + GRAPH_DATABASE_PROVIDER: turso + GRAPH_DATABASE_URL: /app/.cognee_system/graph.sqlite + ENABLE_BACKEND_ACCESS_CONTROL: "false" + REQUIRE_AUTHENTICATION: "false" + TELEMETRY_DISABLED: "true" + ports: ["8001:8000"] + volumes: ["cognee-data:/app/.cognee_system"] + depends_on: + mock-inference: + condition: service_healthy + + mock-inference: + profiles: [mem0, cognee] + build: + context: . + dockerfile: mock-inference.Dockerfile + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"] + interval: 2s + timeout: 2s + retries: 10 + +volumes: + supermemory-data: + mem0-postgres: + cognee-data: diff --git a/integration/remote-engines/mock-inference.Dockerfile b/integration/remote-engines/mock-inference.Dockerfile new file mode 100644 index 0000000..4068437 --- /dev/null +++ b/integration/remote-engines/mock-inference.Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.13-alpine + +WORKDIR /app +COPY mock_inference.py . + +EXPOSE 8080 +CMD ["python", "mock_inference.py"] diff --git a/integration/remote-engines/mock_inference.py b/integration/remote-engines/mock_inference.py new file mode 100644 index 0000000..ed19fa6 --- /dev/null +++ b/integration/remote-engines/mock_inference.py @@ -0,0 +1,109 @@ +"""Deterministic OpenAI-compatible inference double for live engine wiring tests.""" + +import hashlib +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def schema_value(schema): + if not isinstance(schema, dict): + return None + if "enum" in schema and schema["enum"]: + return schema["enum"][0] + schema_type = schema.get("type") + if schema_type == "object" or "properties" in schema: + properties = schema.get("properties", {}) + return {name: schema_value(value) for name, value in properties.items()} + if schema_type == "array": + return [] + if schema_type in ("number", "integer"): + return 0 + if schema_type == "boolean": + return False + return "tinymemory" + + +def embedding(text, dimensions): + digest = hashlib.sha256(text.encode()).digest() + vector = [0.0] * dimensions + for index, byte in enumerate(digest): + vector[index % dimensions] += (byte + 1) / 256.0 + return vector + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, _format, *_args): + return + + def send_json(self, status, value): + body = json.dumps(value).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/health": + self.send_json(200, {"status": "ok"}) + elif self.path.endswith("/models"): + self.send_json(200, {"object": "list", "data": []}) + else: + self.send_json(404, {"error": "not found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + request = json.loads(self.rfile.read(length) or b"{}") + if self.path.endswith("/embeddings"): + inputs = request.get("input", []) + if isinstance(inputs, str): + inputs = [inputs] + dimensions = int(request.get("dimensions") or 1536) + data = [ + { + "object": "embedding", + "index": index, + "embedding": embedding(str(text), dimensions), + } + for index, text in enumerate(inputs) + ] + self.send_json( + 200, + { + "object": "list", + "data": data, + "model": request.get("model", "test"), + "usage": {"prompt_tokens": 1, "total_tokens": 1}, + }, + ) + return + if self.path.endswith("/chat/completions"): + response_format = request.get("response_format", {}) + schema = response_format.get("json_schema", {}).get("schema", {}) + content = json.dumps(schema_value(schema) if schema else {}) + self.send_json( + 200, + { + "id": "tinymemory-test", + "object": "chat.completion", + "created": 0, + "model": request.get("model", "test"), + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + }, + ) + return + self.send_json(404, {"error": "not found"}) + + +ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever() diff --git a/integration/remote-engines/supermemory.Dockerfile b/integration/remote-engines/supermemory.Dockerfile new file mode 100644 index 0000000..305aa0b --- /dev/null +++ b/integration/remote-engines/supermemory.Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install --global supermemory@4 + +ENV PORT=6767 +EXPOSE 6767 + +ENTRYPOINT ["supermemory", "local"] diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 005e97b..c41bd82 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -59,6 +59,15 @@ pub use tinymemory_api::null::NULL_DRIVER_ID; /// still refuse to bind something *else* under this name. pub const TINYCORTEX_DRIVER_ID: &str = "tinycortex"; +/// Driver id of the native Supermemory HTTP adapter. +pub const SUPERMEMORY_DRIVER_ID: &str = "supermemory"; + +/// Driver id of the native Mem0 HTTP adapter. +pub const MEM0_DRIVER_ID: &str = "mem0"; + +/// Driver id of the native Cognee HTTP adapter. +pub const COGNEE_DRIVER_ID: &str = "cognee"; + /// The trust state a driver entry must carry for an external class to bind. pub const TRUSTED: &str = "trusted"; @@ -148,12 +157,16 @@ impl Default for DriverRegistry { } impl DriverRegistry { - /// The registry every host starts from: `null` and `tinycortex`. + /// The registry every host starts from: the null placeholder, TinyCortex, + /// and the three supported native HTTP engines. #[must_use] pub fn builtin() -> Self { let mut reserved = BTreeMap::new(); reserved.insert(NULL_DRIVER_ID.to_string(), DriverClass::Null); reserved.insert(TINYCORTEX_DRIVER_ID.to_string(), DriverClass::Embedded); + reserved.insert(SUPERMEMORY_DRIVER_ID.to_string(), DriverClass::External); + reserved.insert(MEM0_DRIVER_ID.to_string(), DriverClass::External); + reserved.insert(COGNEE_DRIVER_ID.to_string(), DriverClass::External); Self { reserved } } @@ -217,6 +230,12 @@ impl DriverRegistry { // host's own config blocks. But only a reserved id is admitted // implicitly — see the module docs. let Some(entry) = entry else { + if self.reserved_class(id) == Some(DriverClass::External) { + return Err(refuse(&format!( + "no {} entry; external drivers require endpoint, credential, and trust configuration", + labels.driver_entry + ))); + } return self.implicit(id, &refuse, &format!("no {} entry", labels.driver_entry)); }; @@ -260,11 +279,6 @@ impl DriverRegistry { labels.drivers ))); } - // A distinct reason string from the trust refusal above, so a test - // for the trust rule cannot pass for the wrong reason. - return Err(refuse( - "external driver transport is not implemented yet (the http adapter lands in M4)", - )); } Ok(admission) diff --git a/src/registry/test.rs b/src/registry/test.rs index 0a770e4..6d193e9 100644 --- a/src/registry/test.rs +++ b/src/registry/test.rs @@ -45,14 +45,14 @@ fn an_empty_driver_id_is_refused_and_names_the_config_section() { } #[test] -fn an_unknown_id_without_an_entry_is_refused_rather_than_guessed() { +fn an_external_builtin_without_an_entry_is_refused_for_missing_configuration() { let refusal = DriverRegistry::builtin() .admit("supermemory", None, labels()) - .expect_err("an unknown id without an entry is refused"); + .expect_err("an external id without an entry is refused"); assert_eq!(refusal.configured_driver, "supermemory"); assert!( - refusal.reason.contains("unknown driver id \"supermemory\""), - "reason should name the id: {}", + refusal.reason.contains("external drivers require endpoint"), + "reason should name the missing external configuration: {}", refusal.reason ); assert!( @@ -65,8 +65,8 @@ fn an_unknown_id_without_an_entry_is_refused_rather_than_guessed() { } #[test] -fn an_unknown_id_with_a_classless_entry_is_refused() { - let refusal = DriverRegistry::builtin() +fn an_unreserved_id_with_a_classless_entry_is_refused() { + let refusal = DriverRegistry::empty() .admit("supermemory", Some(entry(None, TRUSTED)), labels()) .expect_err("a classless entry cannot admit an arbitrary id"); assert!( @@ -166,35 +166,39 @@ fn an_untrusted_external_driver_is_refused_for_trust() { ); } -/// A trusted external driver is still refused, but for a *different* reason — -/// so the trust test above cannot pass for the wrong reason. #[test] -fn a_trusted_external_driver_is_refused_for_transport_not_trust() { - let refusal = DriverRegistry::builtin() +fn a_trusted_external_driver_is_admitted() { + let admitted = DriverRegistry::builtin() .admit("remote", Some(entry(Some("external"), TRUSTED)), labels()) - .expect_err("external transport does not exist yet"); - assert!( - refusal.reason.contains("transport is not implemented yet"), - "reason should be the transport refusal: {}", - refusal.reason - ); - assert!( - !refusal.reason.contains("untrusted"), - "the transport refusal must be distinct from the trust refusal: {}", - refusal.reason - ); + .expect("the HTTP transport exists"); + assert_eq!(admitted.class, DriverClass::External); +} + +#[test] +fn supported_external_ids_have_a_fixed_class() { + let registry = DriverRegistry::builtin(); + for id in [SUPERMEMORY_DRIVER_ID, MEM0_DRIVER_ID, COGNEE_DRIVER_ID] { + let admitted = registry + .admit(id, Some(entry(Some("external"), TRUSTED)), labels()) + .expect("supported external driver admits"); + assert_eq!(admitted.class, DriverClass::External); + } } #[test] fn a_host_can_reserve_an_additional_driver_id() { - let registry = DriverRegistry::builtin().with_reserved("mem0", DriverClass::Embedded); + let registry = DriverRegistry::builtin().with_reserved("custom-memory", DriverClass::Embedded); let admitted = registry - .admit("mem0", None, labels()) + .admit("custom-memory", None, labels()) .expect("a host-reserved id admits implicitly"); assert_eq!(admitted.class, DriverClass::Embedded); let refusal = registry - .admit("mem0", Some(entry(Some("null"), TRUSTED)), labels()) + .admit( + "custom-memory", + Some(entry(Some("null"), TRUSTED)), + labels(), + ) .expect_err("the confirm-never-override rule applies to host-reserved ids too"); assert!(refusal.reason.contains("is built in and is always class")); }