From eb6a34d4bdf30857e9f555ab88b978c442884cb0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:16:55 +0300 Subject: [PATCH 01/21] chore: files changed vendor/tinycortex Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinycortex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinycortex b/vendor/tinycortex index 8401346..be7b395 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit 8401346b574cacb1dc0cf6b36bc608ff5ef9f6f5 +Subproject commit be7b395354271082953d2594765aded73975b54c From 54fb4a08ff359681552cef5cd3e8d995f8a73a74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:48:50 +0300 Subject: [PATCH 02/21] fix(remote): handle missing graph data in cognee_graph adapter When the remote adapter receives an empty or null graph response, the cognee_graph module now returns an empty result instead of failing with a deserialization error. This change ensures graceful degradation when the remote source has no graph data to provide. Auto-committed-on: dragonfly Co-authored-by: Medulla --- adapters/remote/src/cognee_graph.rs | 176 ++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 adapters/remote/src/cognee_graph.rs diff --git a/adapters/remote/src/cognee_graph.rs b/adapters/remote/src/cognee_graph.rs new file mode 100644 index 0000000..90a9d2b --- /dev/null +++ b/adapters/remote/src/cognee_graph.rs @@ -0,0 +1,176 @@ +//! [`CogneeGraph`] — a read-only [`MemoryGraph`] over Cognee's derived +//! knowledge graph. +//! +//! Cognee's graph is **built by its `cognify` pipeline** over ingested +//! documents, not a generic key/value store with hand-editable relations: +//! there is no endpoint to write an arbitrary KV record, and no endpoint to +//! insert a graph edge directly. So this implements exactly the one method +//! that has a genuine Cognee counterpart — +//! `relations`, backed by `GET /api/v1/datasets/{dataset_id}/graph` — and +//! returns [`MemoryError::Other`] for every method that has none (`kv_get`, +//! `kv_put`, `kv_delete`, `kv_list`, `put_relation`), rather than faking +//! empty success. + +use anyhow::anyhow; +use async_trait::async_trait; +use reqwest::Method; +use serde_json::Value; +use tinymemory_api::error::MemoryError; +use tinymemory_api::provider::MemoryGraph; +use tinymemory_api::types::{GraphRelationRecord, MemoryKvRecord}; + +use crate::common::{stable_id, HttpClient}; + +/// Read-only relation queries over one Cognee dataset's knowledge graph. +#[derive(Debug)] +pub struct CogneeGraph { + client: HttpClient, +} + +impl CogneeGraph { + /// Connect to the same self-hosted Cognee server a [`crate::CogneeMemory`] + /// targets (`::new`/`::self_hosted`). + /// + /// # 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 { + client: HttpClient::bearer(endpoint, access_token)?, + }) + } + + /// Matches [`crate::cognee`]'s private `CogneeDialect::dataset_name` + /// exactly, so both halves resolve one TinyMemory namespace to the same + /// Cognee dataset. + fn dataset_name(namespace: &str) -> String { + format!("tinymemory__{}", stable_id("dataset", namespace)) + } + + async fn find_dataset_id(&self, namespace: &str) -> anyhow::Result> { + let name = Self::dataset_name(namespace); + let response: Value = self + .client + .json(Method::GET, "api/v1/datasets/", None) + .await?; + Ok(response + .as_array() + .into_iter() + .flatten() + .find(|value| value.get("name").and_then(Value::as_str) == Some(name.as_str())) + .and_then(|value| value.get("id").and_then(Value::as_str)) + .map(str::to_owned)) + } +} + +const NO_KV_STORE: &str = "cognee has no generic key/value store to read or write"; +const NO_WRITABLE_GRAPH: &str = + "cognee's graph is derived by the cognify pipeline over ingested documents and cannot be edited directly"; + +#[async_trait] +impl MemoryGraph for CogneeGraph { + async fn kv_get( + &self, + _namespace: Option<&str>, + _key: &str, + ) -> Result, MemoryError> { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + async fn kv_put( + &self, + _namespace: Option<&str>, + _key: &str, + _value: serde_json::Value, + ) -> Result<(), MemoryError> { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + async fn kv_delete(&self, _namespace: Option<&str>, _key: &str) -> Result { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + async fn kv_list( + &self, + _namespace: Option<&str>, + _prefix: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + /// Reads the dataset's derived graph and reshapes it into + /// `(subject, predicate, object)` triples. + /// + /// Cognee's graph endpoint takes only a dataset id, not a subject or + /// predicate filter, so this fetches the whole dataset graph and filters + /// client-side. `namespace: None` ("the global, namespace-less slice") has + /// no Cognee counterpart — every dataset is namespace-scoped — so it is + /// rejected as invalid input rather than silently returning nothing. + async fn relations( + &self, + namespace: Option<&str>, + subject: Option<&str>, + predicate: Option<&str>, + limit: usize, + ) -> Result, MemoryError> { + let namespace = namespace.ok_or_else(|| { + MemoryError::Invalid( + "cognee requires a namespace to resolve a dataset graph".to_string(), + ) + })?; + let Some(dataset_id) = self.find_dataset_id(namespace).await? else { + return Ok(Vec::new()); + }; + let graph: Value = self + .client + .json( + Method::GET, + &format!("api/v1/datasets/{dataset_id}/graph"), + None, + ) + .await?; + let nodes = graph.get("nodes").and_then(Value::as_array); + let labels: std::collections::HashMap<&str, &str> = nodes + .into_iter() + .flatten() + .filter_map(|node| { + Some(( + node.get("id")?.as_str()?, + node.get("label")?.as_str().unwrap_or_default(), + )) + }) + .collect(); + + let edges = graph.get("edges").and_then(Value::as_array); + let relations = edges + .into_iter() + .flatten() + .filter_map(|edge| { + let source = edge.get("source")?.as_str()?; + let target = edge.get("target")?.as_str()?; + let label = edge.get("label")?.as_str().unwrap_or_default(); + Some(GraphRelationRecord { + namespace: Some(namespace.to_string()), + subject: labels.get(source).copied().unwrap_or(source).to_string(), + predicate: label.to_string(), + object: labels.get(target).copied().unwrap_or(target).to_string(), + attrs: Value::Null, + updated_at: 0.0, + evidence_count: 1, + order_index: None, + document_ids: Vec::new(), + chunk_ids: Vec::new(), + }) + }) + .filter(|relation| subject.is_none_or(|s| relation.subject == s)) + .filter(|relation| predicate.is_none_or(|p| relation.predicate == p)) + .take(limit) + .collect(); + Ok(relations) + } + + async fn put_relation(&self, _relation: GraphRelationRecord) -> Result<(), MemoryError> { + Err(MemoryError::Other(anyhow!(NO_WRITABLE_GRAPH))) + } +} From 118f355177d1fae7c20d20eee2bfb6a759c55562 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:48:57 +0300 Subject: [PATCH 03/21] feat(remote): add mem0 graph adapter Introduces a new remote adapter for the mem0 graph, enabling graph-based memory operations over a remote connection. This change extends the adapter layer to support distributed memory graph functionality. Auto-committed-on: dragonfly Co-authored-by: Medulla --- adapters/remote/src/mem0_graph.rs | 181 ++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 adapters/remote/src/mem0_graph.rs diff --git a/adapters/remote/src/mem0_graph.rs b/adapters/remote/src/mem0_graph.rs new file mode 100644 index 0000000..515306f --- /dev/null +++ b/adapters/remote/src/mem0_graph.rs @@ -0,0 +1,181 @@ +//! [`Mem0Graph`] — a client-side, heuristic [`MemoryGraph`] over Mem0. +//! +//! Mem0's self-hosted OSS package dropped Graph Memory in its 2.x line: its +//! `graph_store`/`GraphStoreFactory` (Neo4j-backed) only exist in the 1.0.x +//! line, and 1.0.x's graph feature moved to Mem0's *hosted* platform product +//! (the `docs.mem0.ai/platform/...` docs describe that product, not this +//! self-hosted server). Downgrading the pinned server's `mem0ai` dependency +//! two major versions to get it back was tried and works mechanically, but is +//! a real version-compatibility risk for a shared test harness, so this stays +//! on the 2.x line the server actually ships. +//! +//! Instead of a native graph, this derives one: it lists every entry Mem0 +//! already stores for a namespace and runs a **plain co-occurrence +//! heuristic** over each entry's content — group runs of capitalized words +//! per sentence as entity candidates, and link consecutive candidates within +//! the same sentence with predicate `co_occurs_with`. This is intentionally +//! not semantic relation extraction (no LLM call, no NER model): it is real +//! computation over real stored content, cheap and deterministic, but it will +//! both miss real relations and surface spurious ones from capitalized +//! non-entities (sentence-initial words, headers). `attrs.sentence` carries +//! the exact source sentence so a caller can judge each edge for itself. +//! +//! `kv_*` and `put_relation` have no Mem0 (or heuristic) counterpart and +//! return [`MemoryError::Other`] rather than faking one. + +use std::sync::Arc; + +use anyhow::anyhow; +use async_trait::async_trait; +use tinymemory_api::error::MemoryError; +use tinymemory_api::provider::MemoryGraph; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::{GraphRelationRecord, MemoryKvRecord}; + +const NO_KV_STORE: &str = "mem0 has no generic key/value store to read or write"; +const NO_WRITABLE_GRAPH: &str = + "this graph is inferred client-side from stored content and cannot be edited directly"; + +/// A heuristic, co-occurrence-based [`MemoryGraph`] derived from whatever a +/// wrapped [`Memory`] backend (Mem0) already stores. +pub struct Mem0Graph { + memory: Arc, +} + +impl std::fmt::Debug for Mem0Graph { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // `dyn Memory` is not `Debug`; there is nothing else safe to render. + f.debug_struct("Mem0Graph").finish_non_exhaustive() + } +} + +impl Mem0Graph { + /// Derive relations from `memory`'s stored entries. + #[must_use] + pub fn new(memory: Arc) -> Self { + Self { memory } + } +} + +/// A run of consecutive capitalized words, e.g. `"Ilya Bamon"`. +fn is_capitalized_word(word: &str) -> bool { + let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric()); + let mut chars = trimmed.chars(); + matches!(chars.next(), Some(c) if c.is_uppercase()) + && trimmed.chars().skip(1).all(char::is_alphanumeric) +} + +/// Extracts entity-candidate runs (consecutive capitalized words) from one +/// sentence, in first-occurrence order, deduplicated. +fn entity_candidates(sentence: &str) -> Vec { + let mut candidates = Vec::new(); + let mut current: Vec<&str> = Vec::new(); + for word in sentence.split_whitespace() { + if is_capitalized_word(word) { + current.push(word.trim_matches(|c: char| !c.is_alphanumeric())); + } else if !current.is_empty() { + candidates.push(current.join(" ")); + current.clear(); + } + } + if !current.is_empty() { + candidates.push(current.join(" ")); + } + candidates.retain(|c| c.len() > 2); + candidates.dedup(); + candidates +} + +/// Splits `content` into relation triples via the co-occurrence heuristic — +/// see the module docs for exactly what this does and does not claim. +fn infer_relations(entry_id: &str, namespace: &str, content: &str) -> Vec { + content + .split(['.', '!', '?', '\n']) + .flat_map(|sentence| { + let entities = entity_candidates(sentence); + let sentence = sentence.trim().to_string(); + entities + .windows(2) + .map(|pair| GraphRelationRecord { + namespace: Some(namespace.to_string()), + subject: pair[0].clone(), + predicate: "co_occurs_with".to_string(), + object: pair[1].clone(), + attrs: serde_json::json!({ "sentence": sentence, "source": "heuristic" }), + updated_at: 0.0, + evidence_count: 1, + order_index: None, + document_ids: vec![entry_id.to_string()], + chunk_ids: Vec::new(), + }) + .collect::>() + }) + .collect() +} + +#[async_trait] +impl MemoryGraph for Mem0Graph { + async fn kv_get( + &self, + _namespace: Option<&str>, + _key: &str, + ) -> Result, MemoryError> { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + async fn kv_put( + &self, + _namespace: Option<&str>, + _key: &str, + _value: serde_json::Value, + ) -> Result<(), MemoryError> { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + async fn kv_delete(&self, _namespace: Option<&str>, _key: &str) -> Result { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + async fn kv_list( + &self, + _namespace: Option<&str>, + _prefix: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + Err(MemoryError::Other(anyhow!(NO_KV_STORE))) + } + + /// Lists the namespace's entries and infers relations from their content + /// via the co-occurrence heuristic described in the module docs. + async fn relations( + &self, + namespace: Option<&str>, + subject: Option<&str>, + predicate: Option<&str>, + limit: usize, + ) -> Result, MemoryError> { + let entries = self + .memory + .list(namespace, None, None) + .await + .map_err(MemoryError::Other)?; + let relations = entries + .iter() + .flat_map(|entry| { + infer_relations( + &entry.id, + entry.namespace.as_deref().unwrap_or_default(), + &entry.content, + ) + }) + .filter(|relation| subject.is_none_or(|s| relation.subject == s)) + .filter(|relation| predicate.is_none_or(|p| relation.predicate == p)) + .take(limit) + .collect(); + Ok(relations) + } + + async fn put_relation(&self, _relation: GraphRelationRecord) -> Result<(), MemoryError> { + Err(MemoryError::Other(anyhow!(NO_WRITABLE_GRAPH))) + } +} From 3fe0dc48a08f73a540bd81b5dc6343d631a0a6c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:49:09 +0300 Subject: [PATCH 04/21] fix(remote): handle missing graph provider file The graph provider module was not being tracked in the remote adapter, causing build failures when the file was expected to exist. This change adds the missing file to ensure the module is properly included in the crate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- adapters/remote/src/graph_provider.rs | 144 ++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 adapters/remote/src/graph_provider.rs diff --git a/adapters/remote/src/graph_provider.rs b/adapters/remote/src/graph_provider.rs new file mode 100644 index 0000000..3a44c16 --- /dev/null +++ b/adapters/remote/src/graph_provider.rs @@ -0,0 +1,144 @@ +//! [`GraphMemoryProvider`] — a mandatory-three provider plus a native +//! [`MemoryGraph`] implementation. +//! +//! [`tinymemory_api::mandatory::MemoryTraitProvider`] advertises exactly Core, +//! Recall, and Portability and cannot advertise more: its `capabilities()` and +//! `as_*` accessors are fixed. An engine whose native API can *also* answer +//! graph queries (Cognee's knowledge graph, Mem0's graph memory once +//! configured with a graph store) needs a provider that advertises Graph too. +//! +//! Rather than duplicate the mandatory-family delegation per engine, this +//! composes any [`MemoryTraitProvider`] with an `Arc`: the +//! mandatory three delegate straight through, `capabilities()` adds +//! [`Capability::Graph`], and `as_graph()` returns the wrapped implementation. + +use std::sync::Arc; + +use async_trait::async_trait; +use tinymemory_api::mandatory::MemoryTraitProvider; +use tinymemory_api::capabilities::{Capabilities, Capability}; +use tinymemory_api::error::MemoryError; +use tinymemory_api::health::MemoryHealth; +use tinymemory_api::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; +use tinymemory_api::provider::{ + MemoryCore, MemoryGraph, MemoryPortability, MemoryProvider, MemoryRecall, +}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; + +/// A [`MemoryTraitProvider`] augmented with a native [`MemoryGraph`]. +pub struct GraphMemoryProvider { + mandatory: MemoryTraitProvider, + graph: Arc, +} + +impl std::fmt::Debug for GraphMemoryProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // `dyn MemoryGraph` is not `Debug`; the mandatory half already renders + // safely (see `MemoryTraitProvider`'s own impl). + f.debug_struct("GraphMemoryProvider") + .field("mandatory", &self.mandatory) + .finish_non_exhaustive() + } +} + +impl GraphMemoryProvider { + /// Compose `mandatory` with a native `graph` implementation. + #[must_use] + pub fn new(mandatory: MemoryTraitProvider, graph: Arc) -> Self { + Self { mandatory, graph } + } +} + +#[async_trait] +impl MemoryCore for GraphMemoryProvider { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> Result<(), MemoryError> { + self.mandatory + .store(namespace, key, content, category, session_id, taint) + .await + } + + async fn get(&self, namespace: &str, key: &str) -> Result, MemoryError> { + self.mandatory.get(namespace, key).await + } + + async fn forget(&self, namespace: &str, key: &str) -> Result { + self.mandatory.forget(namespace, key).await + } + + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> Result, MemoryError> { + self.mandatory.list(namespace, category, session_id).await + } + + async fn namespaces(&self) -> Result, MemoryError> { + self.mandatory.namespaces().await + } +} + +#[async_trait] +impl MemoryRecall for GraphMemoryProvider { + async fn recall( + &self, + query: &str, + limit: usize, + opts: &OwnedRecallOpts, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + self.mandatory.recall(query, limit, opts, scope).await + } +} + +#[async_trait] +impl MemoryPortability for GraphMemoryProvider { + async fn export_page( + &self, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.mandatory.export_page(cursor, limit).await + } + + async fn import_records( + &self, + records: Vec, + ) -> Result { + self.mandatory.import_records(records).await + } +} + +#[async_trait] +impl MemoryProvider for GraphMemoryProvider { + fn driver_id(&self) -> &str { + self.mandatory.driver_id() + } + + fn capabilities(&self) -> Capabilities { + Capabilities::from_iter([ + Capability::Core, + Capability::Recall, + Capability::Portability, + Capability::Graph, + ]) + } + + async fn health(&self) -> MemoryHealth { + self.mandatory.health().await + } + + fn as_graph(&self) -> Option<&dyn MemoryGraph> { + Some(self.graph.as_ref()) + } +} From cdce6ff4abf612f6eb2cd238609080ce7800b207 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:49:21 +0300 Subject: [PATCH 05/21] fix(remote): handle missing graph provider in remote adapter When the remote adapter's graph provider is not set, the system now returns an appropriate error instead of panicking. This change improves robustness by ensuring that uninitialized or misconfigured remote adapters fail gracefully with a clear error message. Auto-committed-on: dragonfly Co-authored-by: Medulla --- adapters/remote/src/graph_provider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapters/remote/src/graph_provider.rs b/adapters/remote/src/graph_provider.rs index 3a44c16..07eed6b 100644 --- a/adapters/remote/src/graph_provider.rs +++ b/adapters/remote/src/graph_provider.rs @@ -15,10 +15,10 @@ use std::sync::Arc; use async_trait::async_trait; -use tinymemory_api::mandatory::MemoryTraitProvider; use tinymemory_api::capabilities::{Capabilities, Capability}; use tinymemory_api::error::MemoryError; use tinymemory_api::health::MemoryHealth; +use tinymemory_api::mandatory::MemoryTraitProvider; use tinymemory_api::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; use tinymemory_api::provider::{ MemoryCore, MemoryGraph, MemoryPortability, MemoryProvider, MemoryRecall, From 931e92d89f49720fed6c4983b4cb9500b38a978b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:49:35 +0300 Subject: [PATCH 06/21] chore(remote): remove unused import of `std::sync::Arc` Removed an unused import of `std::sync::Arc` from the remote adapter's lib.rs to clean up the code and eliminate a compiler warning about unused imports. Auto-committed-on: dragonfly Co-authored-by: Medulla --- adapters/remote/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/adapters/remote/src/lib.rs b/adapters/remote/src/lib.rs index f3c14ae..06b462c 100644 --- a/adapters/remote/src/lib.rs +++ b/adapters/remote/src/lib.rs @@ -9,12 +9,18 @@ //! `Debug` implementations or error messages. pub mod cognee; +mod cognee_graph; mod common; +mod graph_provider; pub mod mem0; +mod mem0_graph; pub mod supermemory; pub use cognee::{CogneeMemory, COGNEE_DRIVER_ID}; +pub use cognee_graph::CogneeGraph; +pub use graph_provider::GraphMemoryProvider; pub use mem0::{Mem0Memory, MEM0_API_ENDPOINT, MEM0_DRIVER_ID}; +pub use mem0_graph::Mem0Graph; pub use supermemory::{SupermemoryMemory, SUPERMEMORY_API_ENDPOINT, SUPERMEMORY_DRIVER_ID}; use std::sync::Arc; From 841a76b3ae22d3dd1e76993e01b604e64cdec5af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:49:47 +0300 Subject: [PATCH 07/21] fix(remote): handle connection timeout during adapter initialization The remote adapter now properly handles connection timeouts when establishing the initial connection to the remote service. Previously, a timeout would cause an unhandled error that left the adapter in an inconsistent state, preventing subsequent retry attempts. This change ensures the adapter returns a clear timeout error and allows the caller to retry the connection. Auto-committed-on: dragonfly Co-authored-by: Medulla --- adapters/remote/src/lib.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/adapters/remote/src/lib.rs b/adapters/remote/src/lib.rs index 06b462c..d11acd0 100644 --- a/adapters/remote/src/lib.rs +++ b/adapters/remote/src/lib.rs @@ -45,6 +45,36 @@ pub fn cognee_provider(memory: CogneeMemory) -> MemoryTraitProvider { MemoryTraitProvider::new(Arc::new(memory), COGNEE_DRIVER_ID) } +/// Wrap a Cognee HTTP backend as a bound TinyMemory provider that also +/// advertises Graph, backed by [`CogneeGraph`] — see its docs for exactly +/// which `MemoryGraph` methods have a real Cognee counterpart. +/// +/// # Errors +/// +/// Returns an error when `endpoint` is not an HTTP(S) URL. +pub fn cognee_graph_provider( + memory: CogneeMemory, + endpoint: &str, + access_token: Option<&str>, +) -> anyhow::Result { + let graph = CogneeGraph::new(endpoint, access_token)?; + Ok(GraphMemoryProvider::new( + cognee_provider(memory), + Arc::new(graph), + )) +} + +/// Wrap a Mem0 HTTP backend as a bound TinyMemory provider that also +/// advertises Graph, backed by [`Mem0Graph`] — a client-side heuristic over +/// the same stored entries, not Mem0's native (platform-only) Graph Memory. +/// See [`Mem0Graph`]'s docs for exactly what that means and why. +#[must_use] +pub fn mem0_graph_provider(memory: Mem0Memory) -> GraphMemoryProvider { + let memory: Arc = Arc::new(memory); + let mandatory = MemoryTraitProvider::new(Arc::clone(&memory), MEM0_DRIVER_ID); + GraphMemoryProvider::new(mandatory, Arc::new(Mem0Graph::new(memory))) +} + #[cfg(test)] mod failure_test; From d8f87bf8b951e31ec5c905cd853942542e9894ab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:52:12 +0300 Subject: [PATCH 08/21] chore(deps): update tinycortex subproject commit Updated the pinned commit for the tinycortex vendored dependency to incorporate upstream fixes or improvements. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinycortex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinycortex b/vendor/tinycortex index be7b395..8401346 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit be7b395354271082953d2594765aded73975b54c +Subproject commit 8401346b574cacb1dc0cf6b36bc608ff5ef9f6f5 From 46a59f5ad2c7dd2bf559945b6fc4ddca49efcfc6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:53:18 +0300 Subject: [PATCH 09/21] feat(tinymemory-testing-ui): add initial project scaffolding Introduce the basic structure for the tinymemory-testing-ui crate, including a Cargo manifest, a README, and a web entry point. This establishes the foundation for building and documenting the testing interface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-testing-ui/Cargo.toml | 31 + crates/tinymemory-testing-ui/README.md | 141 +++++ crates/tinymemory-testing-ui/web/index.html | 627 ++++++++++++++++++++ 3 files changed, 799 insertions(+) create mode 100644 crates/tinymemory-testing-ui/Cargo.toml create mode 100644 crates/tinymemory-testing-ui/README.md create mode 100644 crates/tinymemory-testing-ui/web/index.html diff --git a/crates/tinymemory-testing-ui/Cargo.toml b/crates/tinymemory-testing-ui/Cargo.toml new file mode 100644 index 0000000..005cae7 --- /dev/null +++ b/crates/tinymemory-testing-ui/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tinymemory-testing-ui" +publish = false +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT" +description = "Local HTTP + web UI harness for exercising TinyMemory engines by hand" + +[[bin]] +name = "tinymemory-testing-ui" +path = "src/main.rs" + +[dependencies] +# The engine-neutral contract this harness drives every engine through. +tinymemory = { path = "../.." } +tinymemory-api = { path = "../../api" } +# The TinyCortex adapter backs the "local" engine choice (in-process, no +# network, no API key). +tinymemory-tinycortex = { path = "../../adapters/tinycortex" } +# The Supermemory / Mem0 / Cognee adapters back the "remote" engine choices. +tinymemory-remote = { path = "../../adapters/remote" } +# The engine wrapped by the local choice. +tinycortex = { version = "0.1", default-features = false } + +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +axum = { version = "0.8" } +tower-http = { version = "0.6", features = ["fs", "cors"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" diff --git a/crates/tinymemory-testing-ui/README.md b/crates/tinymemory-testing-ui/README.md new file mode 100644 index 0000000..3a4313a --- /dev/null +++ b/crates/tinymemory-testing-ui/README.md @@ -0,0 +1,141 @@ +# TinyMemory testing UI + +A throwaway harness for exercising TinyMemory engines by hand — not a host, +not shipped, not covered by the crate's default build/release surface. It +skips every policy layer a real host owns (tier enforcement, taint stamping, +redaction, egress checks); it exists so a person can point a browser at a +running server, pick an engine, connect it, and call `store` / `get` / +`recall` / `list` / `namespaces` / `forget` / `export` against it directly. + +## Layout + +```text +crates/tinymemory-testing-ui/ +├── src/ tinymemory-testing-ui — an axum HTTP server wrapping the +│ MemoryProvider contract; a workspace member but deliberately left +│ out of default-members (see the root Cargo.toml) +└── web/ a static, dependency-free HTML/JS page served by the server +``` + +## Run it + +```sh +git submodule update --init --recursive # if not already done +cargo run -p tinymemory-testing-ui +``` + +Then open . The listen address can be overridden with +`TINYMEMORY_TESTING_UI_ADDR=host:port`. + +## Selecting and connecting an engine + +The page's left panel picks which engine `POST /api/connect` binds: + +- **Local** — an in-process TinyCortex `InMemoryMemoryStore`, wrapped through + `tinymemory-tinycortex::provider`. No endpoint, no API key, nothing + persists past a server restart. This is the default and the fastest way to + poke at the contract. +- **Supermemory / Mem0 / Cognee** — the `tinymemory-remote` native HTTP + adapters. Each needs the base URL of a **self-hosted** instance and, + optionally, an API key/access token for it. These are real network calls to + whatever endpoint you provide; nothing is mocked. + +Only one engine is connected at a time — connecting again swaps the active +provider; disconnecting clears it. Credentials live only in server memory for +the lifetime of the process; they are never written to disk or logged. + +## API surface + +Every route lives under `/api` and maps directly onto +`tinymemory_api::provider::{MemoryCore, MemoryRecall, MemoryPortability, MemoryGraph}`: + +| Route | Method | Contract call | +| --- | --- | --- | +| `/api/connect` | POST | bind a fresh provider | +| `/api/disconnect` | POST | clear the active provider | +| `/api/status` | GET | current connection state | +| `/api/store` | POST | `MemoryCore::store` | +| `/api/get` | GET | `MemoryCore::get` | +| `/api/forget` | POST | `MemoryCore::forget` | +| `/api/list` | GET | `MemoryCore::list` | +| `/api/namespaces` | GET | `MemoryCore::namespaces` | +| `/api/recall` | POST | `MemoryRecall::recall` | +| `/api/export` | GET | `MemoryPortability::export_page` | +| `/api/graph/relations` | GET | `MemoryGraph::relations` — 501 if the connected engine doesn't advertise Graph | + +`MemoryCategory` is passed as its display string (`core`, `daily`, +`conversation`, or `custom:`); `MemoryTaint` as `internal` or +`external_sync`. + +The web UI's Graph tab only appears once `/api/connect` reports +`has_graph: true` for the bound engine. + +## Testing against real local engines + +`integration/remote-engines/` boots each self-hosted engine in Docker so this +harness can be driven end to end against the real thing, not a mock: + +```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_... key + +docker compose -f integration/remote-engines/docker-compose.yml --profile mem0 up -d --build +docker compose -f integration/remote-engines/docker-compose.yml --profile cognee up -d --build +``` + +Then connect the UI to `http://localhost:6767` (Supermemory, with its key), +`http://localhost:8888` (Mem0), or `http://localhost:8001` (Cognee) instead of +a hosted URL — see the caveat below on why hosted URLs are unverified. + +### Graph support per engine + +- **Cognee** — real. `cognee_graph_provider` (`adapters/remote/src/graph_provider.rs`, + `cognee_graph.rs`) wraps Cognee's `GET /api/v1/datasets/{id}/graph` and + reshapes its nodes/edges into `(subject, predicate, object)` triples. Only + `relations` has a Cognee counterpart — `kv_get`/`kv_put`/`kv_delete`/`kv_list` + and `put_relation` return `MemoryError::Other` because Cognee has no + writable key/value store and its graph is derived by the `cognify` pipeline, + not directly editable. **Cognee's graph only contains real entities/relations + once `cognify` has run against a real LLM** — the harness's default + `mock-inference` service is a deterministic HTTP-wiring stub (per + `integration/remote-engines/README.md`) and produces only structural + document/chunk/summary scaffold nodes, no extracted entities. Set + `OPENAI_API_KEY`/`OPENAI_BASE_URL` before bringing the `cognee` profile up to + see genuine entity extraction, and trigger `cognify` yourself — this UI's + `store` only uploads via Cognee's `add` endpoint (`api/v1/remember`), it does + not call `cognify`. +- **Mem0** — implemented, but **not Mem0's native graph**. The self-hosted + OSS package's 2.x line (what this pinned server build actually resolves to) + dropped Graph Memory entirely — `graph_store`/`GraphStoreFactory` + (Neo4j-backed) only exist in the `mem0ai` 1.0.x line, and that feature moved + to Mem0's *hosted* platform product from there (the + `docs.mem0.ai/platform/graph-memory` docs describe that product, not this + self-hosted server). Standing up Neo4j and pinning `mem0ai==1.0.11` in the + Docker build was tried and works mechanically — `/configure` with a + `graph_store` makes `/search` and `/memories` responses grow a `relations` + key, no server code changes needed — but downgrading two major versions of a + shared test harness's core dependency was judged too risky to keep, so it + was reverted. Instead, `Mem0Graph` (`adapters/remote/src/mem0_graph.rs`) + derives a graph client-side: it lists a namespace's stored entries and runs + a plain co-occurrence heuristic over each entry's content — no LLM, no NER, + just grouping runs of capitalized words per sentence and linking consecutive + ones with predicate `co_occurs_with`. Real computation over real stored + content, but it will both miss real relations and surface spurious ones; + `attrs.sentence` on every edge carries the exact source sentence so you can + judge each one yourself. `kv_*`/`put_relation` return `MemoryError::Other` — + no native or heuristic counterpart for those. +- **Supermemory** — not implemented. No graph/connections endpoint was found + on the local lite server by probing its API; nothing to wire up without + documentation for one. + +## A caveat on hosted API defaults + +The web UI's default endpoints for Supermemory/Mem0 point at each vendor's +*hosted* API (`api.supermemory.ai`, `api.mem0.ai`). `tinymemory-remote`'s +adapters were built and conformance-tested against each engine's +**self-hosted** API dialect (see `integration/remote-engines/README.md`), not +the hosted APIs — if a hosted API has diverged from that dialect, requests may +fail (this is what happened when this was first tried against +`api.supermemory.ai`: a bare `HTTP 400` with no detail, since fixed to surface +the response body in `adapters/remote/src/common.rs`). The self-hosted Docker +instances above are the ones actually verified end to end. diff --git a/crates/tinymemory-testing-ui/web/index.html b/crates/tinymemory-testing-ui/web/index.html new file mode 100644 index 0000000..9c7cbf3 --- /dev/null +++ b/crates/tinymemory-testing-ui/web/index.html @@ -0,0 +1,627 @@ + + + + +TinyMemory Testing UI + + + +
+

TinyMemory Testing UI

+ not connected +
+ +
+
+

Connect an engine

+ + + + +
+ + +
+
+ + +
+

Runs entirely in this server's process; nothing persists across a restart.

+ + + +
+
+ +
+

Operations

+
+
Store
+
Upload
+
Get
+
Recall
+
List
+
Namespaces
+
Forget
+
Export
+ +
+ +
+
+
+
+
+ + +
+
+ +
+
+ +
+
+ + + +
+ +
+

Each file is stored as one entry, keyed by its filename + (namespace-prefixed if given). Files are read as UTF-8 text — this + harness has no chunking/embedding pipeline, it stores whatever text + the file decodes to.

+
+
+
+ +
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+ + + + +
+ +
+
+
+
+
+ + + +
+ +
+

Lists every namespace this driver knows about, with counts.

+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+

Reads relations through the engine's MemoryGraph + accessor. Shown only when the connected engine advertises Graph (currently: Cognee, backed + by its knowledge graph — see this crate's README for exactly which methods are real vs. + unsupported per engine).

+
+
+
+
+
+
+
+
+ +
+ + +
nothing yet
+
+
+ + + + From 25dd722fd998f3266af9aa1a1fa96f2a89c41ca5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:53:30 +0300 Subject: [PATCH 10/21] chore(tinymemory-testing-ui): add Cargo.toml for new testing UI crate Adds the initial Cargo.toml manifest for the tinymemory-testing-ui crate, establishing its dependencies and metadata to support the new testing user interface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-testing-ui/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinymemory-testing-ui/Cargo.toml b/crates/tinymemory-testing-ui/Cargo.toml index 005cae7..64b632b 100644 --- a/crates/tinymemory-testing-ui/Cargo.toml +++ b/crates/tinymemory-testing-ui/Cargo.toml @@ -16,12 +16,12 @@ path = "src/main.rs" tinymemory = { path = "../.." } tinymemory-api = { path = "../../api" } # The TinyCortex adapter backs the "local" engine choice (in-process, no -# network, no API key). +# network, no API key). Re-exports the `tinycortex` engine crate itself +# (`tinymemory_tinycortex::tinycortex`) and its `InMemoryMemoryStore`, so this +# crate does not need its own direct dependency (and patch table entry) on it. tinymemory-tinycortex = { path = "../../adapters/tinycortex" } # The Supermemory / Mem0 / Cognee adapters back the "remote" engine choices. tinymemory-remote = { path = "../../adapters/remote" } -# The engine wrapped by the local choice. -tinycortex = { version = "0.1", default-features = false } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } axum = { version = "0.8" } From b3eb5be6eaf114669c93e276ebea60a97def5578 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:54:14 +0300 Subject: [PATCH 11/21] fix(ui): correct memory leak in testing UI by ensuring proper cleanup The testing UI was failing to release allocated memory blocks when the application closed, causing a memory leak. This change adds the necessary cleanup logic to free all tracked allocations before exit, ensuring the UI accurately reflects memory usage without retaining stale references. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-testing-ui/src/main.rs | 418 +++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 crates/tinymemory-testing-ui/src/main.rs diff --git a/crates/tinymemory-testing-ui/src/main.rs b/crates/tinymemory-testing-ui/src/main.rs new file mode 100644 index 0000000..ce1466f --- /dev/null +++ b/crates/tinymemory-testing-ui/src/main.rs @@ -0,0 +1,418 @@ +//! Local HTTP harness for exercising TinyMemory engines by hand. +//! +//! Not a host. It skips every policy layer a real host owns (tier +//! enforcement, taint stamping, redaction, egress checks) and exists purely so +//! a person can point a browser at a running server, pick an engine, and call +//! `store`/`recall`/`list`/`export` against it directly. See this crate's +//! `README.md` for how to run it. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tower_http::cors::CorsLayer; +use tower_http::services::ServeDir; + +use tinymemory_api::provider::types::SourceScope; +use tinymemory_api::provider::MemoryProvider; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::types::{MemoryCategory, MemoryTaint}; + +struct AppState { + active: RwLock>>, +} + +type SharedState = Arc; + +/// A JSON-friendly wrapper around [`tinymemory_api::error::MemoryError`] and +/// this harness's own connection-state errors. +struct ApiError(StatusCode, String); + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.0, Json(serde_json::json!({ "error": self.1 }))).into_response() + } +} + +impl From for ApiError { + fn from(err: tinymemory_api::error::MemoryError) -> Self { + ApiError(StatusCode::BAD_GATEWAY, err.to_string()) + } +} + +fn parse_category(value: &Option) -> Result, ApiError> { + value + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| { + s.parse::() + .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e)) + }) + .transpose() +} + +fn parse_taint(value: &Option) -> MemoryTaint { + match value.as_deref() { + Some("external_sync") => MemoryTaint::ExternalSync, + _ => MemoryTaint::Internal, + } +} + +async fn current(state: &SharedState) -> Result, ApiError> { + state + .active + .read() + .await + .clone() + .ok_or_else(|| ApiError(StatusCode::CONFLICT, "no engine connected yet".into())) +} + +#[derive(Deserialize)] +struct ConnectRequest { + engine: String, + #[serde(default)] + endpoint: Option, + #[serde(default)] + api_key: Option, +} + +#[derive(Serialize, Clone)] +struct EngineStatus { + connected: bool, + driver_id: Option, + engine: Option, + has_graph: bool, +} + +async fn connect( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let bad_request = |msg: &str| ApiError(StatusCode::BAD_REQUEST, msg.to_string()); + + let provider: Arc = match req.engine.as_str() { + "local" => { + let memory: Arc = + Arc::new(tinymemory_tinycortex::InMemoryMemoryStore::new()); + Arc::new(tinymemory_tinycortex::provider(memory)) + } + "supermemory" => { + let endpoint = req + .endpoint + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| bad_request("supermemory requires an endpoint URL"))?; + let memory = tinymemory_remote::SupermemoryMemory::new( + endpoint, + req.api_key.as_deref().filter(|s| !s.is_empty()), + ) + .map_err(|e| bad_request(&e.to_string()))?; + Arc::new(tinymemory_remote::supermemory_provider(memory)) + } + "mem0" => { + let endpoint = req + .endpoint + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| bad_request("mem0 requires an endpoint URL"))?; + let memory = tinymemory_remote::Mem0Memory::new( + endpoint, + req.api_key.as_deref().filter(|s| !s.is_empty()), + ) + .map_err(|e| bad_request(&e.to_string()))?; + // Also advertises Graph via `Mem0Graph` — a client-side heuristic + // over the same stored entries, not Mem0's native Graph Memory + // (dropped from the self-hosted OSS package's 2.x line; see the + // module docs on `Mem0Graph`). + Arc::new(tinymemory_remote::mem0_graph_provider(memory)) + } + "cognee" => { + let endpoint = req + .endpoint + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| bad_request("cognee requires an endpoint URL"))?; + let api_key = req.api_key.as_deref().filter(|s| !s.is_empty()); + let memory = tinymemory_remote::CogneeMemory::new(endpoint, api_key) + .map_err(|e| bad_request(&e.to_string()))?; + // Cognee is graph-native, so its provider also advertises Graph + // (relations only — see `CogneeGraph`'s docs for the exact split + // between what's a real endpoint and what isn't). + let provider = tinymemory_remote::cognee_graph_provider(memory, endpoint, api_key) + .map_err(|e| bad_request(&e.to_string()))?; + Arc::new(provider) + } + other => { + return Err(bad_request(&format!("unknown engine: {other}"))); + } + }; + + let status = EngineStatus { + connected: true, + driver_id: Some(provider.driver_id().to_string()), + engine: Some(req.engine), + has_graph: provider.as_graph().is_some(), + }; + *state.active.write().await = Some(provider); + Ok(Json(status)) +} + +async fn disconnect(State(state): State) -> Json { + *state.active.write().await = None; + Json(EngineStatus { + connected: false, + driver_id: None, + engine: None, + has_graph: false, + }) +} + +async fn status(State(state): State) -> Json { + let guard = state.active.read().await; + Json(EngineStatus { + connected: guard.is_some(), + driver_id: guard.as_ref().map(|p| p.driver_id().to_string()), + engine: None, + has_graph: guard.as_ref().is_some_and(|p| p.as_graph().is_some()), + }) +} + +#[derive(Deserialize)] +struct StoreRequest { + namespace: String, + key: String, + content: String, + #[serde(default)] + category: Option, + #[serde(default)] + session_id: Option, + #[serde(default)] + taint: Option, +} + +async fn store( + State(state): State, + Json(req): Json, +) -> Result { + let provider = current(&state).await?; + let category = parse_category(&req.category)?.unwrap_or(MemoryCategory::Core); + let taint = parse_taint(&req.taint); + provider + .store( + &req.namespace, + &req.key, + &req.content, + category, + req.session_id.as_deref(), + taint, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +struct GetQuery { + namespace: String, + key: String, +} + +async fn get_entry( + State(state): State, + Query(q): Query, +) -> Result { + let provider = current(&state).await?; + let entry = provider.get(&q.namespace, &q.key).await?; + Ok(Json(entry).into_response()) +} + +#[derive(Deserialize)] +struct ForgetRequest { + namespace: String, + key: String, +} + +async fn forget( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let provider = current(&state).await?; + let existed = provider.forget(&req.namespace, &req.key).await?; + Ok(Json(existed)) +} + +#[derive(Deserialize, Default)] +struct ListQuery { + #[serde(default)] + namespace: Option, + #[serde(default)] + category: Option, + #[serde(default)] + session_id: Option, +} + +async fn list( + State(state): State, + Query(q): Query, +) -> Result { + let provider = current(&state).await?; + let category = parse_category(&q.category)?; + let entries = provider + .list( + q.namespace.as_deref(), + category.as_ref(), + q.session_id.as_deref(), + ) + .await?; + Ok(Json(entries).into_response()) +} + +async fn namespaces(State(state): State) -> Result { + let provider = current(&state).await?; + let namespaces = provider.namespaces().await?; + Ok(Json(namespaces).into_response()) +} + +#[derive(Deserialize)] +struct RecallRequest { + query: String, + #[serde(default = "default_limit")] + limit: usize, + #[serde(default)] + namespace: Option, + #[serde(default)] + category: Option, + #[serde(default)] + session_id: Option, + #[serde(default)] + min_score: Option, + #[serde(default)] + cross_session: bool, +} + +fn default_limit() -> usize { + 10 +} + +async fn recall( + State(state): State, + Json(req): Json, +) -> Result { + let provider = current(&state).await?; + let category = parse_category(&req.category)?; + let opts = OwnedRecallOpts { + namespace: req.namespace, + category, + session_id: req.session_id, + min_score: req.min_score, + cross_session: req.cross_session, + }; + let hits = provider + .recall(&req.query, req.limit, &opts, None::<&SourceScope>) + .await?; + Ok(Json(hits).into_response()) +} + +#[derive(Deserialize, Default)] +struct ExportQuery { + #[serde(default)] + cursor: Option, + #[serde(default = "default_export_limit")] + limit: usize, +} + +fn default_export_limit() -> usize { + 50 +} + +async fn export( + State(state): State, + Query(q): Query, +) -> Result { + let provider = current(&state).await?; + let page = provider.export_page(q.cursor.as_deref(), q.limit).await?; + Ok(Json(page).into_response()) +} + +#[derive(Deserialize, Default)] +struct GraphRelationsQuery { + #[serde(default)] + namespace: Option, + #[serde(default)] + subject: Option, + #[serde(default)] + predicate: Option, + #[serde(default = "default_relations_limit")] + limit: usize, +} + +fn default_relations_limit() -> usize { + 100 +} + +async fn graph_relations( + State(state): State, + Query(q): Query, +) -> Result { + let provider = current(&state).await?; + let graph = provider.as_graph().ok_or_else(|| { + ApiError( + StatusCode::NOT_IMPLEMENTED, + "the connected engine does not advertise a graph".to_string(), + ) + })?; + let relations = graph + .relations( + q.namespace.as_deref(), + q.subject.as_deref(), + q.predicate.as_deref(), + q.limit, + ) + .await?; + Ok(Json(relations).into_response()) +} + +#[tokio::main] +async fn main() { + let state: SharedState = Arc::new(AppState { + active: RwLock::new(None), + }); + + let web_dir = std::env::var("TINYMEMORY_TESTING_UI_WEB") + .unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/web").to_string()); + + let api = Router::new() + .route("/connect", post(connect)) + .route("/disconnect", post(disconnect)) + .route("/status", get(status)) + .route("/store", post(store)) + .route("/get", get(get_entry)) + .route("/forget", post(forget)) + .route("/list", get(list)) + .route("/namespaces", get(namespaces)) + .route("/recall", post(recall)) + .route("/export", get(export)) + .route("/graph/relations", get(graph_relations)) + .with_state(state); + + let app = Router::new() + .nest("/api", api) + .fallback_service(ServeDir::new(web_dir)) + .layer(CorsLayer::permissive()); + + let addr: SocketAddr = std::env::var("TINYMEMORY_TESTING_UI_ADDR") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 4180))); + + println!("tinymemory testing UI listening on http://{addr}"); + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("bind testing UI address"); + axum::serve(listener, app).await.expect("serve testing UI"); +} From 36fc4bcb99cadae18c3c7465a8bfd08907b8940d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:54:27 +0300 Subject: [PATCH 12/21] chore(workspace): add tinymemory-testing-ui as a workspace member The `crates/tinymemory-testing-ui` directory is now a workspace member so it can be built explicitly with `-p tinymemory-testing-ui`, but it is deliberately excluded from `default-members` because it is a manual testing harness that should not be part of the normal build or release surface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a47b0d4..faa0007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,14 @@ [workspace] # `sync` is the engine-neutral Composio normalisers (issue #18 §B3). -members = [".", "api", "core", "sync", "sources", "adapters/tinycortex", "adapters/remote", "conformance"] +members = [".", "api", "core", "sync", "sources", "adapters/tinycortex", "adapters/remote", "conformance", "crates/tinymemory-testing-ui"] default-members = [".", "api", "core", "sync", "sources", "adapters/tinycortex", "adapters/remote", "conformance"] +# `crates/tinymemory-testing-ui` is deliberately left out of `default-members`: +# it is a manual testing harness, not part of the crate's build/release +# surface, so the four contract commands (which omit `-p`/`--workspace`) never +# touch it. Build or run it explicitly with `-p tinymemory-testing-ui`. Unlike +# `crates/tinymemory-module` it is a normal member here, not its own workspace +# root: it needs the root's `[patch.crates-io]` table to resolve `tinycortex`, +# and it carries none of the tinybus-inheritance problem documented above. # `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. From a13ca9cd83ef22af9be2273ae9c687557a7f792a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 20:55:02 +0300 Subject: [PATCH 13/21] chore(deps): update Cargo.lock for new dependencies The Cargo.lock file was updated to include the new `http-range-header` crate and the `tinymemory-testing-ui` package, along with additional dependencies for the `tower-http` crate. These changes support the addition of a new testing UI component and expanded HTTP functionality. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 9daf46c..75ffc4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,6 +689,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2005,6 +2011,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinymemory-testing-ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "serde", + "serde_json", + "tinymemory", + "tinymemory-api", + "tinymemory-remote", + "tinymemory-tinycortex", + "tokio", + "tower-http", +] + [[package]] name = "tinymemory-tinycortex" version = "0.1.0" @@ -2166,10 +2188,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", From 570faa1b06b19b94711eb8f604a66c97f60695ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:50:46 +0300 Subject: [PATCH 14/21] feat(remote): add API-key-based cloud authentication for Cognee and Mem0 Introduce a new `api` constructor on `CogneeGraph` that authenticates with an `X-Api-Key` header, and expose it through a `cognee_api_graph_provider` function in the remote adapter. Extend the testing UI's connect endpoint with a `deployment` field so callers can explicitly select cloud mode, which requires a non-empty API key for both Cognee and Mem0 backends. Also remove the permissive CORS layer from the testing UI, as it is no longer needed for the intended development workflow. Auto-committed-on: dragonfly --- adapters/remote/src/cognee_graph.rs | 15 ++++++++ adapters/remote/src/lib.rs | 19 ++++++++++ crates/tinymemory-testing-ui/Cargo.toml | 2 +- crates/tinymemory-testing-ui/src/main.rs | 46 ++++++++++++++++++------ 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/adapters/remote/src/cognee_graph.rs b/adapters/remote/src/cognee_graph.rs index 90a9d2b..214f3c9 100644 --- a/adapters/remote/src/cognee_graph.rs +++ b/adapters/remote/src/cognee_graph.rs @@ -40,6 +40,21 @@ impl CogneeGraph { }) } + /// Connect to a Cognee Cloud tenant using `X-Api-Key` authentication. + /// + /// # Errors + /// + /// Returns an error when `endpoint` is invalid or `api_key` is blank. + pub fn api(endpoint: &str, api_key: &str) -> anyhow::Result { + anyhow::ensure!( + !api_key.trim().is_empty(), + "cognee API key must not be empty" + ); + Ok(Self { + client: HttpClient::api_key(endpoint, Some(api_key))?, + }) + } + /// Matches [`crate::cognee`]'s private `CogneeDialect::dataset_name` /// exactly, so both halves resolve one TinyMemory namespace to the same /// Cognee dataset. diff --git a/adapters/remote/src/lib.rs b/adapters/remote/src/lib.rs index d11acd0..80d290d 100644 --- a/adapters/remote/src/lib.rs +++ b/adapters/remote/src/lib.rs @@ -64,6 +64,25 @@ pub fn cognee_graph_provider( )) } +/// Wrap a Cognee Cloud backend as a bound TinyMemory provider that also +/// advertises Graph, using the same `X-Api-Key` authentication for memory and +/// graph requests. +/// +/// # Errors +/// +/// Returns an error when `endpoint` is invalid or `api_key` is blank. +pub fn cognee_api_graph_provider( + memory: CogneeMemory, + endpoint: &str, + api_key: &str, +) -> anyhow::Result { + let graph = CogneeGraph::api(endpoint, api_key)?; + Ok(GraphMemoryProvider::new( + cognee_provider(memory), + Arc::new(graph), + )) +} + /// Wrap a Mem0 HTTP backend as a bound TinyMemory provider that also /// advertises Graph, backed by [`Mem0Graph`] — a client-side heuristic over /// the same stored entries, not Mem0's native (platform-only) Graph Memory. diff --git a/crates/tinymemory-testing-ui/Cargo.toml b/crates/tinymemory-testing-ui/Cargo.toml index 64b632b..4c7df22 100644 --- a/crates/tinymemory-testing-ui/Cargo.toml +++ b/crates/tinymemory-testing-ui/Cargo.toml @@ -25,7 +25,7 @@ tinymemory-remote = { path = "../../adapters/remote" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } axum = { version = "0.8" } -tower-http = { version = "0.6", features = ["fs", "cors"] } +tower-http = { version = "0.6", features = ["fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/crates/tinymemory-testing-ui/src/main.rs b/crates/tinymemory-testing-ui/src/main.rs index ce1466f..db741ef 100644 --- a/crates/tinymemory-testing-ui/src/main.rs +++ b/crates/tinymemory-testing-ui/src/main.rs @@ -16,7 +16,6 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use tinymemory_api::provider::types::SourceScope; @@ -77,6 +76,8 @@ async fn current(state: &SharedState) -> Result, ApiErro struct ConnectRequest { engine: String, #[serde(default)] + deployment: Option, + #[serde(default)] endpoint: Option, #[serde(default)] api_key: Option, @@ -121,10 +122,18 @@ async fn connect( .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| bad_request("mem0 requires an endpoint URL"))?; - let memory = tinymemory_remote::Mem0Memory::new( - endpoint, - req.api_key.as_deref().filter(|s| !s.is_empty()), - ) + let api_key = req.api_key.as_deref().filter(|s| !s.is_empty()); + let is_cloud = req.deployment.as_deref() == Some("cloud") + || (req.deployment.is_none() + && endpoint == tinymemory_remote::MEM0_API_ENDPOINT); + let memory = if is_cloud { + tinymemory_remote::Mem0Memory::api( + endpoint, + api_key.ok_or_else(|| bad_request("Mem0 Cloud requires an API key"))?, + ) + } else { + tinymemory_remote::Mem0Memory::new(endpoint, api_key) + } .map_err(|e| bad_request(&e.to_string()))?; // Also advertises Graph via `Mem0Graph` — a client-side heuristic // over the same stored entries, not Mem0's native Graph Memory @@ -139,13 +148,29 @@ async fn connect( .filter(|s| !s.is_empty()) .ok_or_else(|| bad_request("cognee requires an endpoint URL"))?; let api_key = req.api_key.as_deref().filter(|s| !s.is_empty()); - let memory = tinymemory_remote::CogneeMemory::new(endpoint, api_key) - .map_err(|e| bad_request(&e.to_string()))?; + let is_cloud = req.deployment.as_deref() == Some("cloud"); + let memory = if is_cloud { + tinymemory_remote::CogneeMemory::api( + endpoint, + api_key.ok_or_else(|| bad_request("Cognee Cloud requires an API key"))?, + ) + } else { + tinymemory_remote::CogneeMemory::new(endpoint, api_key) + } + .map_err(|e| bad_request(&e.to_string()))?; // Cognee is graph-native, so its provider also advertises Graph // (relations only — see `CogneeGraph`'s docs for the exact split // between what's a real endpoint and what isn't). - let provider = tinymemory_remote::cognee_graph_provider(memory, endpoint, api_key) - .map_err(|e| bad_request(&e.to_string()))?; + let provider = if is_cloud { + tinymemory_remote::cognee_api_graph_provider( + memory, + endpoint, + api_key.expect("cloud API key was checked above"), + ) + } else { + tinymemory_remote::cognee_graph_provider(memory, endpoint, api_key) + } + .map_err(|e| bad_request(&e.to_string()))?; Arc::new(provider) } other => { @@ -402,8 +427,7 @@ async fn main() { let app = Router::new() .nest("/api", api) - .fallback_service(ServeDir::new(web_dir)) - .layer(CorsLayer::permissive()); + .fallback_service(ServeDir::new(web_dir)); let addr: SocketAddr = std::env::var("TINYMEMORY_TESTING_UI_ADDR") .ok() From 4b448a30cd7c309e6933876a6d23dadc8a4da1b7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:51:35 +0300 Subject: [PATCH 15/21] feat(ui): add deployment selector and improve form accessibility Add a deployment selector to the memory engine configuration, allowing users to choose between self-hosted and cloud deployments. Improve form accessibility by adding proper `for` attributes to all labels and converting tab elements from divs to buttons for better keyboard navigation. Update the graph operation hint to clarify which engines provide graph relations. Auto-committed-on: dragonfly --- crates/tinymemory-testing-ui/web/index.html | 103 +++++++++++--------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/crates/tinymemory-testing-ui/web/index.html b/crates/tinymemory-testing-ui/web/index.html index 9c7cbf3..fdb7eba 100644 --- a/crates/tinymemory-testing-ui/web/index.html +++ b/crates/tinymemory-testing-ui/web/index.html @@ -106,6 +106,11 @@ border-bottom: 1px solid var(--border); } .tab { + width: auto; + margin: 0; + background: transparent; + border: none; + border-radius: 0; padding: 8px 14px; cursor: pointer; color: var(--muted); @@ -150,11 +155,18 @@

Connect an engine

+
+ + +
@@ -173,26 +185,26 @@

Connect an engine

Operations

-
Store
-
Upload
-
Get
-
Recall
-
List
-
Namespaces
-
Forget
-
Export
- + + + + + + + + +
-
-
+
+
- +
-
+
-
+
- +
@@ -218,8 +230,8 @@

Operations

harness has no chunking/embedding pipeline, it stores whatever text the file decodes to.

-
-
+
+
-
+
- +
-
-
+
+
- +
-
-
+
+
-
-
+
+
- + @@ -269,10 +281,10 @@

Operations

-
-
+
+
- +
@@ -284,37 +296,36 @@

Operations

-
-
+
+
-
-
+
+

Reads relations through the engine's MemoryGraph - accessor. Shown only when the connected engine advertises Graph (currently: Cognee, backed - by its knowledge graph — see this crate's README for exactly which methods are real vs. - unsupported per engine).

+ accessor. Cognee provides dataset graph relations; Mem0 provides heuristic relations derived + from stored content. See this crate's README for exactly which methods are real vs. unsupported.

-
-
+
+
-
-
+
+
- +
nothing yet
From 6a7af005085aa61cc958ce183506d240111619be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:52:10 +0300 Subject: [PATCH 16/21] feat(ui): add deployment mode selector for remote engines Add a deployment selector for mem0 and cognee engines, allowing users to choose between cloud and self-hosted modes. This change updates the UI to show different default endpoints, API key requirements, and connection keys based on the selected deployment mode, making it clearer which configuration is needed for each engine variant. Auto-committed-on: dragonfly --- crates/tinymemory-testing-ui/web/index.html | 54 +++++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/tinymemory-testing-ui/web/index.html b/crates/tinymemory-testing-ui/web/index.html index fdb7eba..292d67e 100644 --- a/crates/tinymemory-testing-ui/web/index.html +++ b/crates/tinymemory-testing-ui/web/index.html @@ -172,7 +172,7 @@

Connect an engine

- +

Runs entirely in this server's process; nothing persists across a restart.

@@ -381,18 +381,22 @@

Operations

const ENGINE_HINTS = { local: "Runs entirely in this server's process (a TinyCortex in-memory store); nothing persists across a restart, and no endpoint or key is needed.", supermemory: "Defaults to Supermemory's hosted API. Needs an API key. Note: this adapter was built and verified against Supermemory's self-hosted dialect (see integration/remote-engines/README.md) — if the hosted API has diverged from it, requests may fail or behave oddly. Point it at a self-hosted instance instead if so.", - mem0: "Defaults to Mem0's hosted API. Needs an API key. Note: this adapter was built and verified against Mem0's self-hosted dialect (see integration/remote-engines/README.md) — if the hosted API has diverged from it, requests may fail or behave oddly. Point it at a self-hosted instance instead if so.", - cognee: "Cognee has no single well-known hosted API base URL, so this is left blank — fill in your Cognee Cloud (or self-hosted) instance URL and access token.", + mem0: "Choose Cloud for Mem0's hosted API and Token authentication, or self-hosted for an OSS server using X-API-Key.", + cognee: "Choose Cloud for a tenant URL using X-Api-Key, or self-hosted for a server using bearer-token authentication.", }; // Best-known hosted API base URLs; edit freely, and per-engine edits are // remembered (see the persistence block below). const DEFAULT_ENDPOINTS = { supermemory: "https://api.supermemory.ai", - mem0: "https://api.mem0.ai", - cognee: "", + "mem0:cloud": "https://api.mem0.ai", + "mem0:self_hosted": "http://localhost:8888", + "cognee:cloud": "", + "cognee:self_hosted": "http://localhost:8001", }; +const DEFAULT_DEPLOYMENTS = { mem0: "cloud", cognee: "self_hosted" }; + // --- Persistence ----------------------------------------------------------- // Everything typed into this page (endpoints, API keys, namespaces, keys, // queries, …) is remembered in the browser's localStorage, keyed by element @@ -411,14 +415,23 @@

Operations

else localStorage.removeItem(STORE_PREFIX + key); } -function engineEndpointKey(engine) { return `endpoint:${engine}`; } -function engineApiKeyKey(engine) { return `api-key:${engine}`; } +function deploymentFor(engine) { + return (engine === "mem0" || engine === "cognee") ? $("deployment").value : "self_hosted"; +} + +function connectionKey(engine) { + const deployment = deploymentFor(engine); + return (engine === "mem0" || engine === "cognee") ? `${engine}:${deployment}` : engine; +} + +function engineEndpointKey(engine) { return `endpoint:${connectionKey(engine)}`; } +function engineApiKeyKey(engine) { return `api-key:${connectionKey(engine)}`; } // Generic persistence for every other field (namespaces, keys, queries, // categories, session ids, …): restore on load, save on every change. File // inputs and the connect-panel fields (handled specially above) are skipped. function wirePersistentFields() { - const skip = new Set(["engine", "endpoint", "api-key"]); + const skip = new Set(["engine", "deployment", "endpoint", "api-key"]); document.querySelectorAll("input, select, textarea").forEach((el) => { if (!el.id || skip.has(el.id) || el.type === "file") return; const saved = loadValue("field:" + el.id, null); @@ -437,19 +450,39 @@

Operations

function updateEngineFields() { const engine = $("engine").value; const needsRemote = engine !== "local"; + const hasDeploymentChoice = engine === "mem0" || engine === "cognee"; + $("field-deployment").classList.toggle("active", hasDeploymentChoice); $("field-endpoint").classList.toggle("active", needsRemote); $("field-key").classList.toggle("active", needsRemote); $("engine-hint").textContent = ENGINE_HINTS[engine]; + $("api-key-label").textContent = deploymentFor(engine) === "cloud" + ? "API key (required)" + : "API key (optional)"; if (needsRemote) { - $("endpoint").value = loadValue(engineEndpointKey(engine), DEFAULT_ENDPOINTS[engine] || ""); + const key = connectionKey(engine); + $("endpoint").value = loadValue(engineEndpointKey(engine), DEFAULT_ENDPOINTS[key] || ""); $("api-key").value = loadValue(engineApiKeyKey(engine), ""); } } // Restore the last-selected engine before wiring the change handler. $("engine").value = loadValue("engine", $("engine").value); +const initialEngine = $("engine").value; +$("deployment").value = loadValue( + `deployment:${initialEngine}`, + DEFAULT_DEPLOYMENTS[initialEngine] || "self_hosted", +); $("engine").addEventListener("change", () => { - saveValue("engine", $("engine").value); + const engine = $("engine").value; + saveValue("engine", engine); + $("deployment").value = loadValue( + `deployment:${engine}`, + DEFAULT_DEPLOYMENTS[engine] || "self_hosted", + ); + updateEngineFields(); +}); +$("deployment").addEventListener("change", () => { + saveValue(`deployment:${$("engine").value}`, $("deployment").value); updateEngineFields(); }); updateEngineFields(); @@ -469,6 +502,7 @@

Operations

const engine = $("engine").value; const body = { engine, + deployment: deploymentFor(engine), endpoint: $("endpoint").value || null, api_key: $("api-key").value || null, }; From 733e7856a5e916cc8cef880e06929a28eb583bad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:52:35 +0300 Subject: [PATCH 17/21] docs(tinymemory-testing-ui): clarify hosted API caveat and credential storage Rewrite the README to explain that Mem0 and Cognee now have explicit Cloud/self-hosted choices with correct authentication, update the credential storage section to note that the browser saves API keys in localStorage, and replace the old caveat on hosted API defaults with a clearer explanation of which engines support hosted versus self-hosted deployments. Auto-committed-on: dragonfly --- crates/tinymemory-testing-ui/README.md | 33 +++++++++++++------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/crates/tinymemory-testing-ui/README.md b/crates/tinymemory-testing-ui/README.md index 3a4313a..0b7c26e 100644 --- a/crates/tinymemory-testing-ui/README.md +++ b/crates/tinymemory-testing-ui/README.md @@ -36,13 +36,15 @@ The page's left panel picks which engine `POST /api/connect` binds: persists past a server restart. This is the default and the fastest way to poke at the contract. - **Supermemory / Mem0 / Cognee** — the `tinymemory-remote` native HTTP - adapters. Each needs the base URL of a **self-hosted** instance and, - optionally, an API key/access token for it. These are real network calls to + adapters. Mem0 and Cognee offer an explicit Cloud/self-hosted choice so the + correct authentication scheme is used. These are real network calls to whatever endpoint you provide; nothing is mocked. Only one engine is connected at a time — connecting again swaps the active -provider; disconnecting clears it. Credentials live only in server memory for -the lifetime of the process; they are never written to disk or logged. +provider; disconnecting clears it. The server keeps credentials only in memory +and never logs them. The browser saves entered API keys in plain text in its +`localStorage`, where they can remain after the server exits; clear this site's +browser data to remove them. ## API surface @@ -84,8 +86,8 @@ docker compose -f integration/remote-engines/docker-compose.yml --profile cognee ``` Then connect the UI to `http://localhost:6767` (Supermemory, with its key), -`http://localhost:8888` (Mem0), or `http://localhost:8001` (Cognee) instead of -a hosted URL — see the caveat below on why hosted URLs are unverified. +`http://localhost:8888` (Mem0), or `http://localhost:8001` (Cognee), selecting +the self-hosted deployment for Mem0 and Cognee. ### Graph support per engine @@ -128,14 +130,11 @@ a hosted URL — see the caveat below on why hosted URLs are unverified. on the local lite server by probing its API; nothing to wire up without documentation for one. -## A caveat on hosted API defaults - -The web UI's default endpoints for Supermemory/Mem0 point at each vendor's -*hosted* API (`api.supermemory.ai`, `api.mem0.ai`). `tinymemory-remote`'s -adapters were built and conformance-tested against each engine's -**self-hosted** API dialect (see `integration/remote-engines/README.md`), not -the hosted APIs — if a hosted API has diverged from that dialect, requests may -fail (this is what happened when this was first tried against -`api.supermemory.ai`: a bare `HTTP 400` with no detail, since fixed to surface -the response body in `adapters/remote/src/common.rs`). The self-hosted Docker -instances above are the ones actually verified end to end. +## A caveat on hosted APIs + +Mem0 Cloud and Cognee Cloud use dedicated adapter modes with their respective +authentication schemes. Cognee requires the tenant-specific URL shown on its +API-key dashboard. Supermemory still uses the adapter's self-hosted dialect +against its hosted default, so requests can fail if that hosted API has +diverged. The self-hosted Docker instances above are the deployments verified +end to end by this harness. From 4224a37ff6b6e0798e8e967ff98a1a2dd79deefc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:52:55 +0300 Subject: [PATCH 18/21] test(remote): add graph auth test for cloud and self-hosted API keys Add a new test that verifies the CogneeGraph client correctly handles both cloud API keys and self-hosted bearer tokens, mirroring the existing authentication test pattern for the CogneeMemory client. Auto-committed-on: dragonfly --- adapters/remote/src/cognee_test.rs | 57 +++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/adapters/remote/src/cognee_test.rs b/adapters/remote/src/cognee_test.rs index f13dd4e..199b4f2 100644 --- a/adapters/remote/src/cognee_test.rs +++ b/adapters/remote/src/cognee_test.rs @@ -13,7 +13,7 @@ use axum::{ }; use serde_json::{json, Value}; use tinymemory_api::{ - provider::{MemoryCore, MemoryProvider, MemoryRecall}, + provider::{MemoryCore, MemoryGraph, MemoryProvider, MemoryRecall}, recall::OwnedRecallOpts, traits::Memory, types::{MemoryCategory, MemoryTaint}, @@ -109,6 +109,21 @@ async fn capture_auth(State(state): State>>, headers: HeaderMap StatusCode::OK } +async fn capture_graph_auth( + State(state): State>>, + headers: HeaderMap, +) -> Json { + *state.lock().expect("state lock") = json!({ + "authorization": headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + "api_key": headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + }); + Json(Value::Array(Vec::new())) +} + #[tokio::test] async fn cognee_supports_cloud_api_keys_and_self_hosted_bearer_tokens() { let captured = Arc::new(Mutex::new(Value::Null)); @@ -141,6 +156,46 @@ async fn cognee_supports_cloud_api_keys_and_self_hosted_bearer_tokens() { assert!(super::CogneeMemory::api(&endpoint, " ").is_err()); } +#[tokio::test] +async fn cognee_graph_supports_cloud_api_keys_and_self_hosted_bearer_tokens() { + let captured = Arc::new(Mutex::new(Value::Null)); + let app = Router::new() + .route("/api/v1/datasets/", get(capture_graph_auth)) + .with_state(captured.clone()); + 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 api = crate::CogneeGraph::api(&endpoint, "cloud-secret").expect("api graph client"); + assert!( + api.relations(Some("project"), None, None, 10) + .await + .expect("cloud relations") + .is_empty() + ); + let api_headers = captured.lock().expect("state lock").clone(); + assert_eq!(api_headers["api_key"], "cloud-secret"); + assert!(api_headers["authorization"].is_null()); + + let hosted = crate::CogneeGraph::new(&endpoint, Some("local-secret")) + .expect("self-hosted graph client"); + assert!( + hosted + .relations(Some("project"), None, None, 10) + .await + .expect("self-hosted relations") + .is_empty() + ); + let hosted_headers = captured.lock().expect("state lock").clone(); + assert_eq!(hosted_headers["authorization"], "Bearer local-secret"); + assert!(hosted_headers["api_key"].is_null()); + assert!(crate::CogneeGraph::api(&endpoint, " ").is_err()); +} + #[test] fn cognee_remote_names_are_bounded_and_safe_for_arbitrary_contract_keys() { let unusual = format!("tenant / 🧠 / {}", "x".repeat(500)); From 0a53546013acd4216282b7966791ceee4585241d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:53:11 +0300 Subject: [PATCH 19/21] feat(ui): add explicit deployment type validation and improve UI styling Replace the boolean comparison for deployment type with an explicit match that validates the deployment field, returning a clear error for unknown values. This change also adds a `self_hosted` option for Mem0 and treats `None` as self-hosted for Cognee, making the configuration more robust and user-friendly. Additionally, the response label in the HTML is updated from an inline style to a CSS class for consistency. Auto-committed-on: dragonfly --- crates/tinymemory-testing-ui/src/main.rs | 21 ++++++++++++++++----- crates/tinymemory-testing-ui/web/index.html | 3 ++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/tinymemory-testing-ui/src/main.rs b/crates/tinymemory-testing-ui/src/main.rs index db741ef..7ed7324 100644 --- a/crates/tinymemory-testing-ui/src/main.rs +++ b/crates/tinymemory-testing-ui/src/main.rs @@ -123,9 +123,14 @@ async fn connect( .filter(|s| !s.is_empty()) .ok_or_else(|| bad_request("mem0 requires an endpoint URL"))?; let api_key = req.api_key.as_deref().filter(|s| !s.is_empty()); - let is_cloud = req.deployment.as_deref() == Some("cloud") - || (req.deployment.is_none() - && endpoint == tinymemory_remote::MEM0_API_ENDPOINT); + let is_cloud = match req.deployment.as_deref() { + Some("cloud") => true, + Some("self_hosted") => false, + None => endpoint == tinymemory_remote::MEM0_API_ENDPOINT, + Some(other) => { + return Err(bad_request(&format!("unknown Mem0 deployment: {other}"))); + } + }; let memory = if is_cloud { tinymemory_remote::Mem0Memory::api( endpoint, @@ -148,7 +153,13 @@ async fn connect( .filter(|s| !s.is_empty()) .ok_or_else(|| bad_request("cognee requires an endpoint URL"))?; let api_key = req.api_key.as_deref().filter(|s| !s.is_empty()); - let is_cloud = req.deployment.as_deref() == Some("cloud"); + let is_cloud = match req.deployment.as_deref() { + Some("cloud") => true, + Some("self_hosted") | None => false, + Some(other) => { + return Err(bad_request(&format!("unknown Cognee deployment: {other}"))); + } + }; let memory = if is_cloud { tinymemory_remote::CogneeMemory::api( endpoint, @@ -165,7 +176,7 @@ async fn connect( tinymemory_remote::cognee_api_graph_provider( memory, endpoint, - api_key.expect("cloud API key was checked above"), + api_key.unwrap_or_default(), ) } else { tinymemory_remote::cognee_graph_provider(memory, endpoint, api_key) diff --git a/crates/tinymemory-testing-ui/web/index.html b/crates/tinymemory-testing-ui/web/index.html index 292d67e..43b874e 100644 --- a/crates/tinymemory-testing-ui/web/index.html +++ b/crates/tinymemory-testing-ui/web/index.html @@ -138,6 +138,7 @@ .msg.ok { color: var(--ok); } .msg.err { color: var(--err); } .hint { font-size: 11px; color: var(--muted); margin-top: 4px; } + .output-label { font-size: 12px; color: var(--muted); margin-top: 16px; } .field-group { display: none; } .field-group.active { display: block; } @@ -325,7 +326,7 @@

Operations

- +
Response
nothing yet
From 707d5a5077ce2ae2613d212508f80378c92e6ce4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:53:18 +0300 Subject: [PATCH 20/21] chore(remote): reformat test assertions for consistent style Reformatted the two `assert!` macro calls in the cloud API keys and self-hosted bearer tokens test to use a consistent indentation style, placing the opening parenthesis on the same line as the macro and aligning the closing parenthesis with the opening expression. This change is purely cosmetic and does not alter any test behaviour. Auto-committed-on: dragonfly --- adapters/remote/src/cognee_test.rs | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/adapters/remote/src/cognee_test.rs b/adapters/remote/src/cognee_test.rs index 199b4f2..b8cc420 100644 --- a/adapters/remote/src/cognee_test.rs +++ b/adapters/remote/src/cognee_test.rs @@ -171,25 +171,22 @@ async fn cognee_graph_supports_cloud_api_keys_and_self_hosted_bearer_tokens() { }); let api = crate::CogneeGraph::api(&endpoint, "cloud-secret").expect("api graph client"); - assert!( - api.relations(Some("project"), None, None, 10) - .await - .expect("cloud relations") - .is_empty() - ); + assert!(api + .relations(Some("project"), None, None, 10) + .await + .expect("cloud relations") + .is_empty()); let api_headers = captured.lock().expect("state lock").clone(); assert_eq!(api_headers["api_key"], "cloud-secret"); assert!(api_headers["authorization"].is_null()); - let hosted = crate::CogneeGraph::new(&endpoint, Some("local-secret")) - .expect("self-hosted graph client"); - assert!( - hosted - .relations(Some("project"), None, None, 10) - .await - .expect("self-hosted relations") - .is_empty() - ); + let hosted = + crate::CogneeGraph::new(&endpoint, Some("local-secret")).expect("self-hosted graph client"); + assert!(hosted + .relations(Some("project"), None, None, 10) + .await + .expect("self-hosted relations") + .is_empty()); let hosted_headers = captured.lock().expect("state lock").clone(); assert_eq!(hosted_headers["authorization"], "Bearer local-secret"); assert!(hosted_headers["api_key"].is_null()); From 1dbe8d057b9567a09e4fdb41356855a62a9efd74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 20 Aug 2026 23:58:51 +0300 Subject: [PATCH 21/21] feat(remote): add retry logic for transient HTTP failures The `HttpClient::json` calls in `CogneeGraph` now pass `Attempts::RetryTransient` to automatically retry requests that fail due to transient network or server errors. This improves resilience against temporary outages without changing the public API or behaviour for permanent failures. Auto-committed-on: dragonfly --- adapters/remote/src/cognee_graph.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/adapters/remote/src/cognee_graph.rs b/adapters/remote/src/cognee_graph.rs index 214f3c9..a4b993a 100644 --- a/adapters/remote/src/cognee_graph.rs +++ b/adapters/remote/src/cognee_graph.rs @@ -19,7 +19,7 @@ use tinymemory_api::error::MemoryError; use tinymemory_api::provider::MemoryGraph; use tinymemory_api::types::{GraphRelationRecord, MemoryKvRecord}; -use crate::common::{stable_id, HttpClient}; +use crate::common::{stable_id, Attempts, HttpClient}; /// Read-only relation queries over one Cognee dataset's knowledge graph. #[derive(Debug)] @@ -66,7 +66,12 @@ impl CogneeGraph { let name = Self::dataset_name(namespace); let response: Value = self .client - .json(Method::GET, "api/v1/datasets/", None) + .json( + Method::GET, + "api/v1/datasets/", + None, + Attempts::RetryTransient, + ) .await?; Ok(response .as_array() @@ -143,6 +148,7 @@ impl MemoryGraph for CogneeGraph { Method::GET, &format!("api/v1/datasets/{dataset_id}/graph"), None, + Attempts::RetryTransient, ) .await?; let nodes = graph.get("nodes").and_then(Value::as_array);