diff --git a/Cargo.lock b/Cargo.lock index b8a40a6..25ef2bd 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", 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. diff --git a/adapters/remote/src/cognee_graph.rs b/adapters/remote/src/cognee_graph.rs new file mode 100644 index 0000000..a4b993a --- /dev/null +++ b/adapters/remote/src/cognee_graph.rs @@ -0,0 +1,197 @@ +//! [`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, Attempts, 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)?, + }) + } + + /// 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. + 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, + Attempts::RetryTransient, + ) + .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, + Attempts::RetryTransient, + ) + .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))) + } +} diff --git a/adapters/remote/src/cognee_test.rs b/adapters/remote/src/cognee_test.rs index f13dd4e..b8cc420 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,43 @@ 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)); diff --git a/adapters/remote/src/graph_provider.rs b/adapters/remote/src/graph_provider.rs new file mode 100644 index 0000000..07eed6b --- /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::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, +}; +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()) + } +} diff --git a/adapters/remote/src/lib.rs b/adapters/remote/src/lib.rs index f3c14ae..80d290d 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; @@ -39,6 +45,55 @@ 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 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. +/// 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; 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))) + } +} diff --git a/crates/tinymemory-testing-ui/Cargo.toml b/crates/tinymemory-testing-ui/Cargo.toml new file mode 100644 index 0000000..4c7df22 --- /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). 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" } + +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +axum = { version = "0.8" } +tower-http = { version = "0.6", features = ["fs"] } +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..0b7c26e --- /dev/null +++ b/crates/tinymemory-testing-ui/README.md @@ -0,0 +1,140 @@ +# 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. 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. 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 + +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), selecting +the self-hosted deployment for Mem0 and Cognee. + +### 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 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. diff --git a/crates/tinymemory-testing-ui/src/main.rs b/crates/tinymemory-testing-ui/src/main.rs new file mode 100644 index 0000000..7ed7324 --- /dev/null +++ b/crates/tinymemory-testing-ui/src/main.rs @@ -0,0 +1,453 @@ +//! 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::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)] + deployment: Option, + #[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 api_key = req.api_key.as_deref().filter(|s| !s.is_empty()); + 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, + 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 + // (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 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, + 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 = if is_cloud { + tinymemory_remote::cognee_api_graph_provider( + memory, + endpoint, + api_key.unwrap_or_default(), + ) + } else { + 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)); + + 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"); +} diff --git a/crates/tinymemory-testing-ui/web/index.html b/crates/tinymemory-testing-ui/web/index.html new file mode 100644 index 0000000..43b874e --- /dev/null +++ b/crates/tinymemory-testing-ui/web/index.html @@ -0,0 +1,673 @@ + + + + +TinyMemory Testing UI + + + +
+

TinyMemory Testing UI

+ not connected +
+ +
+
+

Connect an engine

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

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

+ + + +
+
+ +
+

Operations

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

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. 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.

+
+
+
+
+
+
+
+
+ +
+ +
Response
+
nothing yet
+
+
+ + + +