From 9c9a61e2aa1bf0bb6d43ba41b01dc32e6209b4f1 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 15:56:30 +0530 Subject: [PATCH 01/14] Add workspace-level integration tests against the public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §E3. `AGENTS.md` mandates a `tests/` directory exercising only the public API; the repository had none at the root, and `core/tests/` holds fixtures with no test files. Four targets, matching the four §E3 names: `driver_selection.rs` pins admission: reserved ids resolve to fixed classes, an external driver with no entry is refused fail-closed, an untrusted external driver is refused even with one, a reserved id's class cannot be overridden by config, and a class typo is echoed back so the operator can find the line. `capability_negotiation.rs` covers both directions of the bind-time negotiation, including a deliberately lying provider that advertises a summary tree it has no accessor for — the failure `audit_provider` exists to catch, and which was previously asserted only in unit tests inside the contract crate. `taint_end_to_end.rs` drives provenance through store, get, list, recall, and the export/import round trip, at every driver this workspace ships. It also pins the fail-closed reading of an unknown persisted value, which is the one direction that cannot be undone. `null_provider.rs` asserts the compiled-out configuration is genuinely usable: every mandatory method answers rather than panicking, it reports Ready rather than a fault, and no optional family is either advertised or reachable. Two of the four are deliberately narrower than §E3 describes, and both say so in their module docs. `driver_selection.rs` cannot yet assert that a bound provider's `driver_id()` matches configuration, because nothing selects an engine from config — that is §A5. `taint_end_to_end.rs` cannot drive the sync path, because sync is welded to the engine until §B. Both are written against current behaviour, per the sequencing note in the issue, and each names where its missing leg joins. Refs #18 (§E3) --- Cargo.lock | 1 + Cargo.toml | 4 + tests/capability_negotiation.rs | 197 ++++++++++++++++++++++++++++++ tests/driver_selection.rs | 161 ++++++++++++++++++++++++ tests/null_provider.rs | 117 ++++++++++++++++++ tests/taint_end_to_end.rs | 208 ++++++++++++++++++++++++++++++++ 6 files changed, 688 insertions(+) create mode 100644 tests/capability_negotiation.rs create mode 100644 tests/driver_selection.rs create mode 100644 tests/null_provider.rs create mode 100644 tests/taint_end_to_end.rs diff --git a/Cargo.lock b/Cargo.lock index 401182c..b14eeb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1808,6 +1808,7 @@ dependencies = [ "serde", "serde_json", "tinymemory-api", + "tinymemory-conformance", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 8f9b8eb..a8d1bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,10 @@ serde = { version = "1", features = ["derive"] } [dev-dependencies] # The mandatory-family tests are async. tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +# The reference driver and the behavioural suite, for the workspace-level +# integration tests. A dev-dependency only: the facade must not carry a test +# harness into a consumer's dependency graph. +tinymemory-conformance = { path = "conformance" } [features] default = [] diff --git a/tests/capability_negotiation.rs b/tests/capability_negotiation.rs new file mode 100644 index 0000000..c43081f --- /dev/null +++ b/tests/capability_negotiation.rs @@ -0,0 +1,197 @@ +//! Capability negotiation: what a host may trust a driver's advertisement for, +//! and what happens when the advertisement is wrong. +//! +//! The contract's premise is that a host negotiates once at bind time and then +//! filters its own surface from the cached set. That is only safe if the set is +//! honest, which is what `audit_provider` is for — so these tests pin both the +//! honest path and the dishonest one. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::sync::Arc; + +use tinymemory::api::capabilities::{Capabilities, Capability}; +use tinymemory::api::health::MemoryHealth; +use tinymemory::api::null::NullMemoryProvider; +use tinymemory::api::provider::{audit_provider, MemoryProvider, MemoryTree}; +use tinymemory_conformance::InMemoryProvider; + +#[test] +fn the_reference_drivers_advertise_exactly_what_they_reach() { + for provider in [ + Arc::new(InMemoryProvider::new()) as Arc, + Arc::new(NullMemoryProvider::new()), + ] { + assert!( + audit_provider(provider.as_ref()).is_ok(), + "driver `{}` failed its audit", + provider.driver_id() + ); + } +} + +#[test] +fn a_host_can_filter_its_surface_from_the_cached_capability_set() { + // This is the whole point of negotiating once: a host reads the set at bind + // time and never asks again, so the set has to answer both directions. + let provider = InMemoryProvider::new(); + let caps = provider.capabilities(); + + for mandatory in Capability::MANDATORY { + assert!( + caps.contains(mandatory), + "{} must be advertised", + mandatory.as_str() + ); + assert!( + provider.provides(mandatory), + "{} must be reachable", + mandatory.as_str() + ); + } + + // An optional family this driver does not serve is absent from the set AND + // unreachable through its accessor. A host that registered an RPC method + // from the set alone would otherwise expose a method that answers errors. + assert!(!caps.contains(Capability::Tree)); + assert!(provider.as_tree().is_none()); + assert!(!provider.provides(Capability::Tree)); +} + +/// A driver that claims a family it cannot serve. +/// +/// Exists to prove the audit catches it. This is the failure mode the audit was +/// written for: the claim is cheap to make and, without a check, only surfaces +/// on the first call — which for a memory family may be days later, on a path +/// nobody is watching. +#[derive(Debug, Default)] +struct LyingProvider(InMemoryProvider); + +#[async_trait::async_trait] +impl tinymemory::api::provider::MemoryCore for LyingProvider { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: tinymemory::types::MemoryCategory, + session_id: Option<&str>, + taint: tinymemory::types::MemoryTaint, + ) -> Result<(), tinymemory::error::MemoryError> { + self.0 + .store(namespace, key, content, category, session_id, taint) + .await + } + async fn get( + &self, + namespace: &str, + key: &str, + ) -> Result, tinymemory::error::MemoryError> { + self.0.get(namespace, key).await + } + async fn forget( + &self, + namespace: &str, + key: &str, + ) -> Result { + self.0.forget(namespace, key).await + } + async fn list( + &self, + namespace: Option<&str>, + category: Option<&tinymemory::types::MemoryCategory>, + session_id: Option<&str>, + ) -> Result, tinymemory::error::MemoryError> { + self.0.list(namespace, category, session_id).await + } + async fn namespaces( + &self, + ) -> Result, tinymemory::error::MemoryError> { + self.0.namespaces().await + } +} + +#[async_trait::async_trait] +impl tinymemory::api::provider::MemoryRecall for LyingProvider { + async fn recall( + &self, + query: &str, + limit: usize, + opts: &tinymemory::recall::OwnedRecallOpts, + scope: Option<&tinymemory::api::provider::SourceScope>, + ) -> Result, tinymemory::error::MemoryError> { + self.0.recall(query, limit, opts, scope).await + } +} + +#[async_trait::async_trait] +impl tinymemory::api::provider::MemoryPortability for LyingProvider { + async fn export_page( + &self, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.0.export_page(cursor, limit).await + } + async fn import_records( + &self, + records: Vec, + ) -> Result { + self.0.import_records(records).await + } +} + +#[async_trait::async_trait] +impl MemoryProvider for LyingProvider { + fn driver_id(&self) -> &'static str { + "liar" + } + + fn capabilities(&self) -> Capabilities { + // Claims a summary tree it has no accessor for. + Capabilities::mandatory().with(Capability::Tree) + } + + async fn health(&self) -> MemoryHealth { + MemoryHealth::Ready + } + + // `as_tree` deliberately left at its `None` default. +} + +#[test] +fn a_driver_that_advertises_a_family_it_cannot_serve_fails_the_audit() { + let liar = LyingProvider::default(); + let audit = audit_provider(&liar).expect_err("the audit must catch an overstated capability"); + assert!( + audit.advertised_but_absent.contains(&Capability::Tree), + "the audit should name the family: {audit:?}" + ); + assert!( + audit.present_but_unadvertised.is_empty(), + "nothing was under-advertised here: {audit:?}" + ); +} + +#[test] +fn the_audit_failure_renders_something_an_operator_can_act_on() { + let audit = audit_provider(&LyingProvider::default()) + .expect_err("the audit must fail") + .to_string(); + assert!( + audit.contains("tree"), + "the message should name the family: {audit}" + ); +} + +/// Compile-time proof that `as_tree` returning `Some` is what "reachable" +/// means, so the audit is checking the accessor and not a second declaration. +#[test] +fn reachability_is_the_accessor_not_a_second_declaration() { + let provider = InMemoryProvider::new(); + let tree: Option<&dyn MemoryTree> = provider.as_tree(); + assert!(tree.is_none()); +} diff --git a/tests/driver_selection.rs b/tests/driver_selection.rs new file mode 100644 index 0000000..d73e8cc --- /dev/null +++ b/tests/driver_selection.rs @@ -0,0 +1,161 @@ +//! Driver admission: which ids exist, what class each binds as, and what is +//! refused. +//! +//! Exercises only the public surface of the `tinymemory` facade. +//! +//! # Scope note +//! +//! Issue #18 §E3 describes this file as also asserting that "the bound +//! provider's `driver_id()` matches" the configured id. That step needs +//! `MemoryHostConfig::memory_provider()` to actually select an engine, which is +//! §A5 and does not exist yet — `create_memory_client_with_local_ai` still +//! constructs TinyCortex unconditionally. The issue's own sequencing says to +//! write these tests "against the *current* behaviour first", so this file +//! pins what admission does today. The binding half joins it when §A5 lands, +//! and this file is where it goes. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use tinymemory::registry::{ + ConfigLabels, DriverClass, DriverEntry, DriverRegistry, COGNEE_DRIVER_ID, MEM0_DRIVER_ID, + SUPERMEMORY_DRIVER_ID, TINYCORTEX_DRIVER_ID, TRUSTED, +}; + +fn labels() -> ConfigLabels<'static> { + ConfigLabels { + section: "[memory]", + drivers: "[memory.drivers]", + driver_entry: "[memory.drivers.]", + } +} + +fn trusted_external() -> DriverEntry<'static> { + DriverEntry { + class: None, + trust_state: TRUSTED, + } +} + +#[test] +fn a_reserved_embedded_id_is_admitted_without_any_config_entry() { + // The embedded default's options live in the host's own config blocks, so + // it must not require a `drivers` entry to be selectable at all. + let admission = DriverRegistry::builtin() + .admit(TINYCORTEX_DRIVER_ID, None, labels()) + .expect("the built-in embedded engine is admitted"); + assert_eq!(admission.id, TINYCORTEX_DRIVER_ID); + assert_eq!(admission.class, DriverClass::Embedded); +} + +#[test] +fn the_null_driver_is_admitted_and_is_class_null() { + let admission = DriverRegistry::builtin() + .admit(tinymemory::registry::NULL_DRIVER_ID, None, labels()) + .expect("the null driver is admitted"); + assert_eq!(admission.class, DriverClass::Null); +} + +#[test] +fn every_reserved_external_id_resolves_to_the_external_class() { + let registry = DriverRegistry::builtin(); + for id in [SUPERMEMORY_DRIVER_ID, MEM0_DRIVER_ID, COGNEE_DRIVER_ID] { + let admission = registry + .admit(id, Some(trusted_external()), labels()) + .unwrap_or_else(|reason| panic!("{id} was refused: {}", reason.reason)); + assert_eq!(admission.class, DriverClass::External, "{id}"); + assert_eq!(admission.id, id); + } +} + +#[test] +fn an_external_driver_without_an_entry_is_refused_fail_closed() { + // The fail-closed half: an external engine needs endpoint, credential and + // trust configuration, so admitting it implicitly would bind an + // out-of-process backend nobody configured. + let reason = DriverRegistry::builtin() + .admit(SUPERMEMORY_DRIVER_ID, None, labels()) + .expect_err("an external driver with no entry must be refused"); + assert_eq!(reason.configured_driver, SUPERMEMORY_DRIVER_ID); + assert!( + reason.reason.contains("external"), + "the refusal should say why: {}", + reason.reason + ); +} + +#[test] +fn an_untrusted_external_driver_is_refused_even_with_an_entry() { + let entry = DriverEntry { + class: None, + trust_state: "untrusted", + }; + let reason = DriverRegistry::builtin() + .admit(SUPERMEMORY_DRIVER_ID, Some(entry), labels()) + .expect_err("trust must be raised explicitly before an external bind"); + assert!( + reason.reason.contains(TRUSTED), + "the refusal should name the value to set: {}", + reason.reason + ); +} + +#[test] +fn a_reserved_id_cannot_have_its_class_overridden_by_config() { + // A reserved id names a fixed implementation. An explicit `class` line may + // confirm it but never override it — otherwise config could run the + // embedded engine under the checks meant for an external one. + let entry = DriverEntry { + class: Some("external"), + trust_state: TRUSTED, + }; + let reason = DriverRegistry::builtin() + .admit(TINYCORTEX_DRIVER_ID, Some(entry), labels()) + .expect_err("a reserved id's class must not be overridable"); + assert!( + reason.reason.contains("built in"), + "the refusal should explain why: {}", + reason.reason + ); +} + +#[test] +fn an_unknown_driver_id_is_refused_rather_than_defaulted() { + let reason = DriverRegistry::builtin() + .admit("not-an-engine", None, labels()) + .expect_err("an unreserved id with no entry must be refused"); + assert_eq!(reason.configured_driver, "not-an-engine"); +} + +#[test] +fn an_empty_driver_id_is_refused() { + let reason = DriverRegistry::builtin() + .admit(" ", None, labels()) + .expect_err("a blank driver id must be refused"); + assert!( + reason.reason.contains("empty"), + "the refusal should name the problem: {}", + reason.reason + ); +} + +#[test] +fn a_config_class_typo_is_echoed_back_to_the_operator() { + // The offending value comes from the host's own config file, not from a + // driver or the network, so echoing it discloses nothing the reader did not + // write — and without it the message cannot point at the line to fix. + let entry = DriverEntry { + class: Some("embeded"), + trust_state: TRUSTED, + }; + let reason = DriverRegistry::builtin() + .admit("some-driver", Some(entry), labels()) + .expect_err("an unparseable class must be refused"); + assert!( + reason.reason.contains("embeded"), + "the refusal should quote the typo: {}", + reason.reason + ); +} diff --git a/tests/null_provider.rs b/tests/null_provider.rs new file mode 100644 index 0000000..83e3a8c --- /dev/null +++ b/tests/null_provider.rs @@ -0,0 +1,117 @@ +//! The `null` driver: the configuration a compiled-out or unconfigured memory +//! subsystem binds to. +//! +//! It has to be genuinely usable, not a placeholder that panics. A host whose +//! memory is switched off still calls the ports, and the difference between +//! "returns empty" and "aborts the process" is the difference between a +//! degraded deployment and an outage. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::sync::Arc; + +use tinymemory::api::capabilities::{Capabilities, Capability}; +use tinymemory::api::null::{NullMemoryProvider, NULL_DRIVER_ID}; +use tinymemory::api::provider::{audit_provider, MemoryProvider}; +use tinymemory::types::{MemoryCategory, MemoryTaint}; + +const NS: &str = "null-provider"; + +#[test] +fn it_identifies_itself_and_passes_its_own_audit() { + let provider = NullMemoryProvider::new(); + assert_eq!(provider.driver_id(), NULL_DRIVER_ID); + assert!(audit_provider(&provider).is_ok()); + assert_eq!(provider.capabilities(), Capabilities::mandatory()); +} + +#[tokio::test] +async fn every_mandatory_method_answers_rather_than_panicking() { + let provider: Arc = Arc::new(NullMemoryProvider::new()); + + provider + .store( + NS, + "k", + "v", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("store is accepted and discarded, not refused"); + assert!(provider.get(NS, "k").await.expect("get answers").is_none()); + assert!(!provider.forget(NS, "k").await.expect("forget answers")); + assert!(provider + .list(None, None, None) + .await + .expect("list answers") + .is_empty()); + assert!(provider + .namespaces() + .await + .expect("namespaces answers") + .is_empty()); + + let opts = tinymemory::recall::OwnedRecallOpts::default(); + assert!(provider + .recall("anything", 10, &opts, None) + .await + .expect("recall answers") + .is_empty()); + + let page = provider + .export_page(None, 10) + .await + .expect("export answers"); + assert!(page.records.is_empty()); + assert!(page.next_cursor.is_none(), "an empty export must terminate"); + + let outcome = provider + .import_records(Vec::new()) + .await + .expect("import answers"); + assert_eq!(outcome.imported, 0); + assert_eq!(outcome.failed, 0); +} + +#[tokio::test] +async fn it_is_healthy_rather_than_reporting_a_fault() { + // "Memory is switched off" is a configuration, not a failure. Reporting + // unhealthy would make an intentional deployment look like a broken one. + let provider = NullMemoryProvider::new(); + assert_eq!( + provider.health().await, + tinymemory::health::MemoryHealth::Ready + ); +} + +#[test] +fn no_optional_family_is_reachable_and_none_is_advertised() { + let provider = NullMemoryProvider::new(); + for capability in Capability::ALL { + if Capability::MANDATORY.contains(&capability) { + continue; + } + assert!( + !provider.provides(capability), + "`{}` must not be reachable on the null driver", + capability.as_str() + ); + assert!( + !provider.capabilities().contains(capability), + "`{}` must not be advertised on the null driver", + capability.as_str() + ); + } +} + +#[tokio::test] +async fn it_conforms_to_the_behavioural_suite() { + // The contract-shape half of the suite applies to a discard driver exactly + // as it does to a retaining one; the suite skips only the storage half. + tinymemory_conformance::assert_provider(Arc::new(NullMemoryProvider::new())).await; +} diff --git a/tests/taint_end_to_end.rs b/tests/taint_end_to_end.rs new file mode 100644 index 0000000..1841fb4 --- /dev/null +++ b/tests/taint_end_to_end.rs @@ -0,0 +1,208 @@ +//! Provenance, end to end through the public surface. +//! +//! `MemoryTaint` decides whether downstream policy treats content as something +//! the user authored or as something that arrived from outside. A driver that +//! loses it does not fail loudly — it silently reclassifies external content as +//! internal-trust, and every gate keyed on taint is then wrong about everything +//! that passed through. +//! +//! # Scope note +//! +//! Issue #18 §E3 describes this file as asserting that "external content stored +//! through the **sync path** arrives with `ExternalSync` at every engine". The +//! sync layer is welded to the engine today (§1.4) and its rewrite onto the +//! memory API is §B, so there is no engine-neutral sync path to drive yet. +//! +//! What is assertable now is the seam sync will hand to: taint through store, +//! read-back, list, recall, and the export/import round trip. When §B lands, +//! the sync leg is added here rather than in a new file. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::sync::Arc; + +use tinymemory::api::null::NullMemoryProvider; +use tinymemory::api::provider::{MemoryCore, MemoryPortability, MemoryProvider, MemoryRecall}; +use tinymemory::types::{MemoryCategory, MemoryTaint}; +use tinymemory_conformance::InMemoryProvider; + +const NS: &str = "taint-e2e"; + +/// Every driver this workspace ships, so the assertion is "at every engine" +/// rather than "at the one we happened to test". +fn drivers() -> Vec> { + vec![ + Arc::new(InMemoryProvider::new()), + Arc::new(NullMemoryProvider::new()), + ] +} + +#[tokio::test] +async fn external_content_reads_back_as_external_at_every_driver() { + for provider in drivers() { + let who = provider.driver_id(); + provider + .store( + NS, + "from-the-web", + "scraped from a page", + MemoryCategory::Conversation, + None, + MemoryTaint::ExternalSync, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + // A driver that retains nothing has nothing to reclassify; one that + // retains must hand back what it was given. + if let Some(entry) = provider.get(NS, "from-the-web").await.unwrap_or(None) { + assert_eq!( + entry.taint, + MemoryTaint::ExternalSync, + "{who}: external content was laundered into internal-trust content" + ); + } + let _ = provider.forget(NS, "from-the-web").await; + } +} + +#[tokio::test] +async fn internal_content_is_not_marked_external_by_accident() { + // The inverse error is just as bad in the other direction: over-marking + // makes the gate refuse the company's own material. + for provider in drivers() { + let who = provider.driver_id(); + provider + .store( + NS, + "our-own", + "we decided this", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + if let Some(entry) = provider.get(NS, "our-own").await.unwrap_or(None) { + assert_eq!( + entry.taint, + MemoryTaint::Internal, + "{who}: internal content was over-marked" + ); + } + let _ = provider.forget(NS, "our-own").await; + } +} + +#[tokio::test] +async fn taint_survives_list_and_recall_not_just_get() { + // `get` is the easy path. A driver that rebuilds entries on the list and + // recall paths can drop provenance on exactly those, which is where a + // policy gate actually reads it. + let provider = InMemoryProvider::new(); + provider + .store( + NS, + "k", + "needle from outside", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + + let listed = provider.list(Some(NS), None, None).await.expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!( + listed[0].taint, + MemoryTaint::ExternalSync, + "list dropped provenance" + ); + + let opts = tinymemory::recall::OwnedRecallOpts { + namespace: Some(NS.to_string()), + ..Default::default() + }; + let hits = provider + .recall("needle", 10, &opts, None) + .await + .expect("recall"); + assert_eq!(hits.len(), 1); + assert_eq!( + hits[0].taint, + MemoryTaint::ExternalSync, + "recall dropped provenance" + ); +} + +#[tokio::test] +async fn taint_survives_export_and_re_import() { + // The migration case. An export that drops taint, or an import that + // re-stamps it, turns every restored external record into internal-trust + // content — and a restore is exactly when nobody is watching. + let provider = InMemoryProvider::new(); + provider + .store( + NS, + "moved", + "carried across", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + + let page = provider.export_page(None, 64).await.expect("export"); + let record = page + .records + .iter() + .find(|r| r.namespace.as_deref() == Some(NS)) + .expect("the stored record was exported"); + assert_eq!( + record.taint, + MemoryTaint::ExternalSync, + "export dropped provenance" + ); + + let fresh = InMemoryProvider::new(); + let outcome = fresh + .import_records(vec![record.clone()]) + .await + .expect("import"); + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.failed, 0, "{:?}", outcome.errors); + + let restored = fresh + .get(NS, "moved") + .await + .expect("get") + .expect("restored"); + assert_eq!( + restored.taint, + MemoryTaint::ExternalSync, + "import re-stamped provenance instead of persisting what it was given" + ); +} + +#[test] +fn unknown_persisted_taint_values_fail_closed() { + // A corrupt or future column value must read as the *more* restrictive + // state. Failing open here would let an unrecognised row be treated as + // user-authored, which is the one direction that cannot be undone. + assert_eq!(MemoryTaint::from_db_str(""), MemoryTaint::ExternalSync); + assert_eq!( + MemoryTaint::from_db_str("future-value"), + MemoryTaint::ExternalSync + ); + assert_eq!( + MemoryTaint::from_db_str("INTERNAL"), + MemoryTaint::ExternalSync + ); + // Only the exact known spelling reads as internal. + assert_eq!(MemoryTaint::from_db_str("internal"), MemoryTaint::Internal); +} From 26629c62ffea545a006a7a9d550bac3903629de5 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 16:15:13 +0530 Subject: [PATCH 02/14] Lift the optional capability families into the TinyCortex adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §C3. The adapter advertised Core, Recall and Portability and nothing else, which is the reason anything wanting a summary tree, entities, or a diff ledger reached past the contract to the engine directly — the families existed, but not through `MemoryProvider`. `crates/tinymemory-module` had grown all of them, because it needed them and nowhere else had them. They were never module-specific. Every one delegates to `tinymemory-core` on a blocking thread, and the two types they hold — `MemoryClient` and the host config — are core and contract types respectively. So the whole `ModuleMemoryProvider` moves down to `tinymemory-tinycortex` as `engine::TinycortexProvider`, and the module crate keeps only the thing that genuinely is its own: turning a `ModuleConfig` into the engine's runtime configuration. Its `provider.rs` goes from 2189 lines to 38. Nineteen family implementations move: documents, ingest, graph, goals, tool-memory, tree, entities, diff, sources, maintenance, people, chunks, retrieval, profile, and episodic, alongside the mandatory three. The diff family gets a `memory-git` feature rather than riding along unconditionally. It is what drags `git2` / `libgit2-sys` / `libz-sys` — a native build — into the graph, and this adapter had no such dependency before today; making it unconditional would hand every consumer a libgit2 build they never asked for. `cargo tree --no-default-features` confirms none is linked. The gate reaches `capabilities()`, not just the accessor. A build without `memory-git` neither advertises nor reaches `Diff`, so `audit_provider` still passes — which is the whole reason that audit exists. That rule is extracted as `engine::advertised_capabilities` so it can be tested directly: constructing a provider needs a `MemoryClient`, which needs the host's process-global seams installed, and a test that installs a process global is order-dependent. The new module is `engine`, not `provider`: the crate already has a `provider` function returning the mandatory-only driver, and both are worth keeping — a host with no workspace, config, or client still has the lighter one. Refs #18 (§C3) --- Cargo.lock | 6 + adapters/tinycortex/Cargo.toml | 35 + adapters/tinycortex/src/engine/mod.rs | 2236 +++++++++++++++++++ adapters/tinycortex/src/engine/test.rs | 54 + adapters/tinycortex/src/lib.rs | 40 +- crates/tinymemory-module/Cargo.lock | 7 + crates/tinymemory-module/Cargo.toml | 2 +- crates/tinymemory-module/src/lib.rs | 2 +- crates/tinymemory-module/src/provider.rs | 2180 +----------------- crates/tinymemory-module/src/service/mod.rs | 2 +- 10 files changed, 2384 insertions(+), 2180 deletions(-) create mode 100644 adapters/tinycortex/src/engine/mod.rs create mode 100644 adapters/tinycortex/src/engine/test.rs diff --git a/Cargo.lock b/Cargo.lock index b14eeb0..8b1112e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1895,10 +1895,16 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", + "log", + "serde", + "serde_json", "tinycortex", "tinymemory", "tinymemory-api", + "tinymemory-core", "tokio", + "uuid", ] [[package]] diff --git a/adapters/tinycortex/Cargo.toml b/adapters/tinycortex/Cargo.toml index 01bf042..57f28a1 100644 --- a/adapters/tinycortex/Cargo.toml +++ b/adapters/tinycortex/Cargo.toml @@ -22,6 +22,28 @@ tinymemory-api = { path = "../../api" } # would defeat that and give a host two TinyCortex crates with two incompatible # `Memory` traits. tinycortex = { version = "0.1", default-features = false } +# The optional capability families lifted here in issue #18 §C3 delegate to +# `tinymemory-core` on a blocking thread — that is where the summary tree, +# chunk store, entities, graph and diff ledger actually live. Depending on it +# makes this adapter heavier than the mandatory-only version it replaces; §D +# feature-gates that weight once §A3 has removed the direct engine call sites +# core still has. +tinymemory-core = { path = "../../core" } +# `spawn_blocking`: every family method runs synchronous engine work off the +# async executor rather than blocking it. +tokio = { version = "1", features = ["rt"] } +# Timestamps on ingest and diff records. +chrono = { version = "0.4", features = ["serde"] } +# The provider crosses a few value types by round-tripping them through JSON +# where the engine and the contract describe the same shape under two names — +# the duplication issue #18 §A1 exists to delete. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Person ids are UUIDs on the engine side. +uuid = { version = "1", features = ["v4"] } +# The engine reports a failed profile write through the `log` facade rather +# than returning it, so the host can decide what to do about it. +log = "0.4" # `Memory` is an object-safe async trait. async-trait = "0.1" @@ -43,3 +65,16 @@ unwrap_used = "warn" expect_used = "warn" panic = "warn" missing_errors_doc = "warn" + +[features] +default = [] +# Git-backed diff snapshots, forwarded to `tinymemory-core`'s own gate. Off by +# default because it is what drags `git2` / `libgit2-sys` / `libz-sys` — a +# native build — into the graph. This adapter had no such dependency before +# issue #18 §C3 lifted the diff family here, and making it unconditional would +# hand every consumer a libgit2 build they never asked for. +# +# The gate reaches `capabilities()`: with the feature off the `Diff` family is +# neither advertised nor reachable, so `audit_provider` still passes. Advertising +# a family the build cannot serve is exactly what that audit exists to catch. +memory-git = ["tinymemory-core/memory-git"] diff --git a/adapters/tinycortex/src/engine/mod.rs b/adapters/tinycortex/src/engine/mod.rs new file mode 100644 index 0000000..ebfe0eb --- /dev/null +++ b/adapters/tinycortex/src/engine/mod.rs @@ -0,0 +1,2236 @@ +//! The full TinyCortex provider: every capability family the engine can serve. +//! +//! `MemoryTraitProvider` composes the three mandatory families over the +//! `Memory` storage trait and stops there, which is honest but is also why +//! anything wanting a summary tree, entities, or a diff ledger had to reach +//! past the contract to the engine directly. This module closes that gap: the +//! optional families are implemented here, against the contract, so a host +//! filtering its surface from a negotiated capability set gets the whole engine +//! rather than a third of it. +//! +//! Lifted wholesale from `crates/tinymemory-module` (issue #18 §C3), which had +//! grown these implementations because it needed them and nowhere else had +//! them. They were never module-specific — every one delegates to +//! `tinymemory-core` on a blocking thread — so the module crate keeps only its +//! bus transport and the conversion from its own config. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::TinycortexMemory; +use async_trait::async_trait; +use chrono::Utc; +use tinymemory::mandatory::MemoryTraitProvider; +use tinymemory_api::capabilities::Capabilities; +use tinymemory_api::chunks::Chunk; +use tinymemory_api::error::MemoryError; +use tinymemory_api::goals::GoalsDoc; +use tinymemory_api::health::MemoryHealth; +use tinymemory_api::host::{ + CloudProviderCreds, ComposioMode, LocalAiConfig, MemoryConfig, MemoryHostConfig, + MemoryTreeConfig, SchedulerGateConfig, +}; +use tinymemory_api::provider::types::{ + EntityHit, EntityRef, ExportPage, ExportRecord, ImportOutcome, IngestItem, IngestOutcome, + MaintenanceReport, SourceItem, SourceScope, +}; +// Diff-family value types, used only by the `MemoryDiff` impl below — which is +// compiled out without the git-backed snapshot store. +#[cfg(feature = "memory-git")] +use tinymemory_api::provider::types::{ChangeKind, DiffReport, SnapshotRef, SourceChange}; +use tinymemory_api::provider::{ + AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, ConversationSegment, + CoverWindowQuery, EntityMatch, EpisodicTurn, FacetType, FastRetrieveQuery, MemoryChunks, + MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryEpisodic, MemoryGoals, + MemoryGraph, MemoryIngest, MemoryMaintenance, MemoryPeople, MemoryPortability, MemoryProfile, + MemoryProvider, MemoryRecall, MemoryRetrieval, MemorySourceSink, MemoryToolMemory, MemoryTree, + PersonHandle, PersonInteraction, PersonRecord, PersonScore, ProfileFacet, RankedPerson, + ResolvedPerson, RetrievalHit, RetrievalResponse, SourceRetrievalQuery, UserState, +}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::tool_memory::ToolMemoryRule; +use tinymemory_api::tree::{IngestRequest, QueryResult, TreeStatus}; +use tinymemory_api::types::{ + GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, + NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, NamespaceSummary, + StoredMemoryDocument, +}; +use tinymemory_core::store::{MemoryClient, MemoryClientRef}; + +/// The concrete, credential-free host configuration available inside a module. +#[derive(Debug, Clone)] +pub struct EngineRuntimeConfig { + /// Root of the memory workspace on disk. + pub workspace_dir: PathBuf, + /// The `config.toml` inside [`Self::workspace_dir`]. + pub config_path: PathBuf, + /// Memory engine settings. + pub memory: MemoryConfig, + /// Summary-tree settings. + pub memory_tree: MemoryTreeConfig, + /// Whether background work may run, and under what budget. + pub scheduler_gate: SchedulerGateConfig, + /// Local inference settings. + pub local_ai: LocalAiConfig, + /// Embeddings provider id, when one is configured. + pub embeddings_provider: Option, + /// Memory driver id, when the host names one. + pub memory_provider: Option, + /// Default chat model id, when one is configured. + pub default_model: Option, + /// Default sampling temperature. + pub default_temperature: f64, + /// Preferred output language, when the host sets one. + pub output_language: Option, + /// Opaque source configuration, passed through verbatim. + pub memory_sources: serde_json::Value, +} + +#[async_trait] +impl MemoryHostConfig for EngineRuntimeConfig { + fn workspace_dir(&self) -> &PathBuf { + &self.workspace_dir + } + fn config_path(&self) -> &PathBuf { + &self.config_path + } + fn memory_tree_content_root(&self) -> PathBuf { + self.memory_tree + .content_dir + .clone() + .unwrap_or_else(|| self.workspace_dir.join("memory_tree/content")) + } + fn memory(&self) -> &MemoryConfig { + &self.memory + } + fn memory_tree(&self) -> &MemoryTreeConfig { + &self.memory_tree + } + fn scheduler_gate(&self) -> &SchedulerGateConfig { + &self.scheduler_gate + } + fn local_ai(&self) -> &LocalAiConfig { + &self.local_ai + } + fn cloud_providers(&self) -> &Vec { + static NONE: Vec = Vec::new(); + &NONE + } + fn embeddings_provider(&self) -> Option<&str> { + self.embeddings_provider.as_deref() + } + fn memory_provider(&self) -> Option<&str> { + self.memory_provider.as_deref() + } + fn workload_local_model(&self, workload: &str) -> Option { + let route = match workload { + "memory" => self.memory_provider.as_deref(), + "embeddings" => self.embeddings_provider.as_deref(), + _ => None, + }?; + route + .strip_prefix("ollama:") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + } + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn to_arc(&self) -> Arc { + Arc::new(self.clone()) + } + fn api_url(&self) -> Option<&str> { + None + } + fn effective_backend_api_url(&self) -> String { + String::new() + } + fn session_token(&self) -> Result, String> { + Ok(None) + } + fn default_model(&self) -> Option<&str> { + self.default_model.as_deref() + } + fn default_temperature(&self) -> f64 { + self.default_temperature + } + fn output_language(&self) -> Option<&str> { + self.output_language.as_deref() + } + fn memory_sync_interval_secs(&self) -> Option { + Some(0) + } + fn onboarding_completed(&self) -> bool { + true + } + fn secrets_encrypt(&self) -> bool { + false + } + fn composio(&self) -> ComposioMode { + ComposioMode::default() + } + fn memory_sources_json(&self) -> anyhow::Result { + Ok(self.memory_sources.clone()) + } + fn set_memory_sources_json(&mut self, value: serde_json::Value) -> anyhow::Result<()> { + self.memory_sources = value; + Ok(()) + } + fn composio_source_caps_migration_version(&self) -> u32 { + 0 + } + fn set_composio_source_caps_migration_version(&mut self, _version: u32) {} + fn apply_env_overrides(&mut self) {} + async fn save(&self) -> anyhow::Result<()> { + Ok(()) + } +} + +/// The module-owned implementation of every TinyMemory capability family. +pub struct TinycortexProvider { + driver_id: String, + mandatory: MemoryTraitProvider, + client: MemoryClientRef, + config: EngineRuntimeConfig, +} + +impl TinycortexProvider { + /// Binds the engine as a provider. + /// + /// `driver_id` is the id the host admitted this driver under, not something + /// the adapter chooses — see the class note in `tinymemory::registry`. + pub fn new(driver_id: String, config: EngineRuntimeConfig, client: Arc) -> Self { + let memory = client.memory_handle(); + let mandatory = + MemoryTraitProvider::new(Arc::new(TinycortexMemory::new(memory)), driver_id.clone()); + Self { + driver_id, + mandatory, + client, + config, + } + } + + fn other(context: &'static str, error: impl std::fmt::Display) -> MemoryError { + MemoryError::Other(anyhow::anyhow!("{context}: {error}")) + } + + fn cross( + value: &A, + context: &'static str, + ) -> Result { + let value = serde_json::to_value(value).map_err(|error| Self::other(context, error))?; + serde_json::from_value(value).map_err(|error| Self::other(context, error)) + } +} + +fn validate_ingest_item(item: &IngestItem) -> Result<(), MemoryError> { + if item.taint != MemoryTaint::default() { + return Err(MemoryError::Invalid( + "ingest cannot preserve a non-default taint in the chunk tier".to_string(), + )); + } + if item.content.trim().is_empty() { + return Err(MemoryError::Invalid( + "ingest content must not be empty".to_string(), + )); + } + if let Some(mime) = item.mime.as_deref() { + let mime = mime.trim().to_ascii_lowercase(); + let base = mime.split(';').next().unwrap_or("").trim(); + if !(base.starts_with("text/") + || base.ends_with("+json") + || base.ends_with("+xml") + || matches!( + base, + "application/json" | "application/xml" | "application/x-ndjson" + )) + { + return Err(MemoryError::Invalid(format!( + "unsupported MIME '{mime}': ingest accepts decoded text only" + ))); + } + } + Ok(()) +} + +async fn blocking( + config: EngineRuntimeConfig, + context: &'static str, + run: F, +) -> Result +where + T: Send + 'static, + F: FnOnce(&EngineRuntimeConfig) -> anyhow::Result + Send + 'static, +{ + tokio::task::spawn_blocking(move || run(&config)) + .await + .map_err(|error| TinycortexProvider::other(context, error))? + .map_err(|error| TinycortexProvider::other(context, error)) +} + +/// The capability families this build can actually serve. +/// +/// A free function rather than only a method because it is the rule that has to +/// stay true, and it is testable on its own — constructing a provider requires +/// a `MemoryClient`, which requires the host's process-global seams to be +/// installed, and a test that installs those is order-dependent. +/// +/// The `Diff` family is compiled out without `memory-git`, so a build without +/// it must not advertise `Diff`: `audit_provider` compares this set against the +/// reachable `as_*` accessors, and a mismatch is the failure that audit exists +/// to catch. The `#[cfg]` here and the one on [`MemoryProvider::as_diff`] are +/// the same condition on purpose. +#[must_use] +pub fn advertised_capabilities() -> Capabilities { + #[cfg(feature = "memory-git")] + { + Capabilities::all() + } + #[cfg(not(feature = "memory-git"))] + { + Capabilities::all().without(tinymemory_api::capabilities::Capability::Diff) + } +} + +#[async_trait] +impl MemoryCore for TinycortexProvider { + 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 TinycortexProvider { + 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 TinycortexProvider { + 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 MemoryDocuments for TinycortexProvider { + async fn put_document(&self, input: NamespaceDocumentInput) -> Result { + let input = Self::cross(&input, "convert document input")?; + self.client + .put_doc(input) + .await + .map_err(|error| Self::other("put_document", error)) + } + async fn get_document( + &self, + namespace: &str, + key: &str, + ) -> Result, MemoryError> { + let document = self + .client + .get_document(namespace, key) + .await + .map_err(|error| Self::other("get_document", error))?; + document + .map(|document| Self::cross(&document, "convert stored document")) + .transpose() + } + + async fn list_documents( + &self, + namespace: Option<&str>, + ) -> Result { + self.client + .list_documents(namespace) + .await + .map_err(|error| Self::other("list_documents", error)) + } + + async fn list_namespaces(&self) -> Result, MemoryError> { + self.client + .list_namespaces() + .await + .map_err(|error| Self::other("list_namespaces", error)) + } + + async fn delete_document( + &self, + namespace: &str, + document_id: &str, + ) -> Result { + self.client + .delete_document(namespace, document_id) + .await + .map_err(|error| Self::other("delete_document", error)) + } + + async fn clear_namespace(&self, namespace: &str) -> Result<(), MemoryError> { + self.client + .clear_namespace(namespace) + .await + .map_err(|error| Self::other("clear_namespace", error)) + } + async fn query_documents( + &self, + namespace: &str, + query: &str, + limit: usize, + ) -> Result { + let limit = u32::try_from(limit).unwrap_or(u32::MAX); + let context = self + .client + .query_namespace_context_data(namespace, query, limit) + .await + .map_err(|error| Self::other("query_documents", error))?; + Self::cross(&context, "convert document query result") + } + + async fn recall_documents( + &self, + namespace: &str, + limit: usize, + ) -> Result { + let limit = u32::try_from(limit).unwrap_or(u32::MAX); + let context = self + .client + .recall_namespace_context_data(namespace, limit) + .await + .map_err(|error| Self::other("recall_documents", error))?; + Self::cross(&context, "convert document recall result") + } +} + +#[async_trait] +impl MemoryIngest for TinycortexProvider { + async fn ingest_document(&self, item: IngestItem) -> Result { + validate_ingest_item(&item)?; + let document = tinycortex::memory::ingest::canonicalize::document::DocumentInput { + provider: item.source.as_str().to_string(), + title: String::new(), + body: item.content, + modified_at: item.timestamp.unwrap_or_else(Utc::now), + source_ref: item.source_ref.map(|source_ref| source_ref.value), + }; + let result = tinymemory_core::ingest_pipeline::ingest_document_with_scope( + &self.config, + &item.source_id, + &item.owner, + item.tags, + document, + item.path_scope, + ) + .await + .map_err(|error| Self::other("ingest document", error))?; + Ok(IngestOutcome { + written: u32::try_from(result.chunks_written).unwrap_or(u32::MAX), + skipped: if result.already_ingested { + 1 + } else { + u32::try_from(result.chunks_dropped).unwrap_or(u32::MAX) + }, + ids: result.chunk_ids, + }) + } + + async fn ingest_chat(&self, messages: Vec) -> Result { + let Some(first) = messages.first() else { + return Ok(IngestOutcome::default()); + }; + let source_id = first.source_id.clone(); + let owner = first.owner.clone(); + let tags = first.tags.clone(); + let platform = first.source.as_str().to_string(); + for item in &messages { + validate_ingest_item(item)?; + if item.source_id != source_id { + return Err(MemoryError::Invalid( + "ingest_chat batches must contain one conversation".to_string(), + )); + } + } + let batch = tinycortex::memory::ingest::canonicalize::chat::ChatBatch { + platform, + channel_label: source_id.clone(), + messages: messages + .into_iter() + .map( + |item| tinycortex::memory::ingest::canonicalize::chat::ChatMessage { + author: item.owner, + timestamp: item.timestamp.unwrap_or_else(Utc::now), + text: item.content, + source_ref: item.source_ref.map(|source_ref| source_ref.value), + }, + ) + .collect(), + }; + let result = tinymemory_core::ingest_pipeline::ingest_chat( + &self.config, + &source_id, + &owner, + tags, + batch, + ) + .await + .map_err(|error| Self::other("ingest chat", error))?; + Ok(IngestOutcome { + written: u32::try_from(result.chunks_written).unwrap_or(u32::MAX), + skipped: if result.already_ingested { + 1 + } else { + u32::try_from(result.chunks_dropped).unwrap_or(u32::MAX) + }, + ids: result.chunk_ids, + }) + } +} + +#[async_trait] +impl MemoryGraph for TinycortexProvider { + async fn kv_get( + &self, + namespace: Option<&str>, + key: &str, + ) -> Result, MemoryError> { + let record = self + .client + .kv_records(namespace) + .await + .map_err(|error| Self::other("kv_get", error))? + .into_iter() + .find(|record| record.key == key); + record + .map(|record| Self::cross(&record, "convert key/value record")) + .transpose() + } + async fn kv_put( + &self, + namespace: Option<&str>, + key: &str, + value: serde_json::Value, + ) -> Result<(), MemoryError> { + self.client + .kv_set(namespace, key, &value) + .await + .map_err(|error| Self::other("kv_put", error)) + } + + async fn kv_delete(&self, namespace: Option<&str>, key: &str) -> Result { + self.client + .kv_delete(namespace, key) + .await + .map_err(|error| Self::other("kv_delete", error)) + } + async fn kv_list( + &self, + namespace: Option<&str>, + prefix: Option<&str>, + limit: usize, + ) -> Result, MemoryError> { + let mut records = self + .client + .kv_records(namespace) + .await + .map_err(|error| Self::other("kv_list", error))?; + if let Some(prefix) = prefix { + records.retain(|record| record.key.starts_with(prefix)); + } + records.truncate(limit); + Self::cross(&records, "convert key/value records") + } + async fn relations( + &self, + namespace: Option<&str>, + subject: Option<&str>, + predicate: Option<&str>, + limit: usize, + ) -> Result, MemoryError> { + let mut records = self + .client + .graph_relations(namespace, subject, predicate) + .await + .map_err(|error| Self::other("relations", error))?; + records.truncate(limit); + Self::cross(&records, "convert graph relations") + } + async fn put_relation(&self, relation: GraphRelationRecord) -> Result<(), MemoryError> { + self.client + .graph_upsert( + relation.namespace.as_deref(), + &relation.subject, + &relation.predicate, + &relation.object, + &relation.attrs, + ) + .await + .map_err(|error| Self::other("put_relation", error)) + } +} + +#[async_trait] +impl MemoryGoals for TinycortexProvider { + async fn goals(&self) -> Result { + let workspace = self.config.workspace_dir.clone(); + let document = + tokio::task::spawn_blocking(move || tinycortex::memory::goals::store::load(&workspace)) + .await + .map_err(|error| Self::other("join goals read", error))? + .map_err(|error| Self::other("read goals", error))?; + Self::cross(&document, "convert goals") + } + + async fn set_goals(&self, goals: GoalsDoc) -> Result<(), MemoryError> { + let workspace = self.config.workspace_dir.clone(); + let mut goals = Self::cross(&goals, "convert goals")?; + tokio::task::spawn_blocking(move || { + tinycortex::memory::goals::store::save(&workspace, &mut goals) + }) + .await + .map_err(|error| Self::other("join goals write", error))? + .map_err(|error| Self::other("write goals", error)) + } +} + +#[async_trait] +impl MemoryToolMemory for TinycortexProvider { + async fn tool_rules(&self, tool_name: &str) -> Result, MemoryError> { + let rules = tinymemory_core::tool_memory::tool_memory_store(self.client.memory_handle()) + .list_rules(tool_name) + .await + .map_err(|error| Self::other("list tool rules", error))?; + Self::cross(&rules, "convert tool rules") + } + + async fn put_tool_rule(&self, rule: ToolMemoryRule) -> Result<(), MemoryError> { + let rule = Self::cross(&rule, "convert tool rule")?; + tinymemory_core::tool_memory::tool_memory_store(self.client.memory_handle()) + .put_rule(rule) + .await + .map(|_| ()) + .map_err(|error| Self::other("put tool rule", error)) + } + + async fn delete_tool_rule(&self, tool_name: &str, rule_id: &str) -> Result { + tinymemory_core::tool_memory::tool_memory_store(self.client.memory_handle()) + .delete_rule(tool_name, rule_id) + .await + .map_err(|error| Self::other("delete tool rule", error)) + } +} + +#[async_trait] +impl MemoryTree for TinycortexProvider { + async fn append(&self, request: IngestRequest) -> Result<(), MemoryError> { + tinycortex::memory::tree::runtime::store::validate_namespace(&request.namespace) + .map_err(MemoryError::Invalid)?; + if request.content.trim().is_empty() { + return Err(MemoryError::Invalid( + "content must not be empty".to_string(), + )); + } + let namespace = request.namespace.trim().to_string(); + let content = request.content; + let timestamp = request.timestamp.unwrap_or_else(Utc::now); + let metadata = request.metadata; + blocking(self.config.clone(), "append tree content", move |config| { + tinymemory_core::tree::tree_runtime::store::buffer_write( + config, + &namespace, + &content, + ×tamp, + metadata.as_ref(), + ) + .map(|_| ()) + }) + .await + } + + async fn query_source( + &self, + namespace: &str, + source_id: &str, + limit: usize, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + tinycortex::memory::tree::runtime::store::validate_namespace(namespace) + .map_err(MemoryError::Invalid)?; + let query = tinymemory_core::store::chunks::ListChunksQuery { + source_id: Some(source_id.to_string()), + source_scope: scope.map(|scope| scope.allow.iter().cloned().collect::>()), + limit: Some(limit), + exclude_dropped: true, + ..Default::default() + }; + let chunks = blocking(self.config.clone(), "query source", move |config| { + tinymemory_core::store::chunks::list_chunks(config, &query) + }) + .await?; + Self::cross(&chunks, "convert source chunks") + } + + async fn drill_down(&self, namespace: &str, node_id: &str) -> Result { + tinycortex::memory::tree::runtime::store::validate_namespace(namespace) + .map_err(MemoryError::Invalid)?; + tinycortex::memory::tree::runtime::store::validate_node_id(node_id) + .map_err(MemoryError::Invalid)?; + let namespace = namespace.trim().to_string(); + let node_id = node_id.to_string(); + let lookup_namespace = namespace.clone(); + let lookup_node = node_id.clone(); + let result = blocking(self.config.clone(), "drill down", move |config| { + let Some(node) = tinymemory_core::tree::tree_runtime::store::read_node( + config, + &lookup_namespace, + &lookup_node, + )? + else { + return Ok(None); + }; + let children = tinymemory_core::tree::tree_runtime::store::read_children( + config, + &lookup_namespace, + &lookup_node, + )?; + Ok(Some((node, children))) + }) + .await? + .ok_or_else(|| { + MemoryError::NotFound(format!("tree node '{node_id}' not found in '{namespace}'")) + })?; + Self::cross(&result, "convert tree drill-down") + .map(|(node, children)| QueryResult { node, children }) + } + + async fn seal(&self, namespace: &str) -> Result { + tinycortex::memory::tree::runtime::store::validate_namespace(namespace) + .map_err(MemoryError::Invalid)?; + let namespace = namespace.trim().to_string(); + let read_namespace = namespace.clone(); + let buffered = blocking(self.config.clone(), "read tree buffer", move |config| { + tinymemory_core::tree::tree_runtime::store::buffer_read(config, &read_namespace) + }) + .await?; + if !buffered.is_empty() { + let (model, _) = tinymemory_core::chat_host::create_chat_model_with_model_id( + "summarization", + &self.config, + self.config.default_temperature, + ) + .map_err(|error| Self::other("create summarizer", error))?; + tinymemory_core::tree::tree_runtime::engine::run_summarization( + &self.config, + model.as_ref(), + &namespace, + Utc::now(), + ) + .await + .map_err(|error| Self::other("seal tree", error))?; + } + let status = blocking(self.config.clone(), "read tree status", move |config| { + tinymemory_core::tree::tree_runtime::store::get_tree_status(config, &namespace) + }) + .await?; + Self::cross(&status, "convert tree status") + } + + async fn cascade(&self, namespace: &str) -> Result { + tinycortex::memory::tree::runtime::store::validate_namespace(namespace) + .map_err(MemoryError::Invalid)?; + let namespace = namespace.trim().to_string(); + let read_namespace = namespace.clone(); + let status = blocking(self.config.clone(), "read tree status", move |config| { + tinymemory_core::tree::tree_runtime::store::get_tree_status(config, &read_namespace) + }) + .await?; + if status.total_nodes == 0 { + return Self::cross(&status, "convert tree status"); + } + let (model, _) = tinymemory_core::chat_host::create_chat_model_with_model_id( + "summarization", + &self.config, + self.config.default_temperature, + ) + .map_err(|error| Self::other("create summarizer", error))?; + let status = tinymemory_core::tree::tree_runtime::engine::rebuild_tree( + &self.config, + model.as_ref(), + &namespace, + ) + .await + .map_err(|error| Self::other("cascade tree", error))?; + Self::cross(&status, "convert tree status") + } +} + +#[async_trait] +impl MemoryEntities for TinycortexProvider { + async fn entities( + &self, + namespace: &str, + query: Option<&str>, + limit: usize, + ) -> Result, MemoryError> { + let namespace = namespace.to_string(); + let query_namespace = namespace.clone(); + let query = query.map(str::to_string); + let rows = blocking( + self.config.clone(), + "list namespace entities", + move |config| { + tinymemory_core::store::entities::namespace_entities( + config, + &query_namespace, + query.as_deref(), + limit, + ) + }, + ) + .await? + .into_iter() + .map(|hit| (hit.id, hit.kind, hit.name, hit.mentions)) + .collect::>(); + + let config = self.config.clone(); + blocking(config, "attach entity hotness", move |config| { + Ok(rows + .into_iter() + .map(|(id, kind, name, mentions)| { + let hotness_key = format!("{namespace}:{id}"); + let hotness = tinymemory_core::store::trees::hotness::get(config, &hotness_key) + .ok() + .flatten() + .map_or(0.0, |counters| { + f64::from( + tinymemory_core::tree_policy::TreePolicy::topic().topic_hotness( + &id, + &counters.stats(), + Utc::now().timestamp_millis(), + ), + ) + }); + EntityHit { + entity: EntityRef { id, kind, name }, + hotness, + mentions, + } + }) + .collect()) + }) + .await + } + + async fn entity_edges( + &self, + namespace: &str, + entity_id: &str, + limit: usize, + ) -> Result, MemoryError> { + let subject = entity_id.to_string(); + let lookup = subject.clone(); + let namespace = namespace.to_string(); + let query_namespace = namespace.clone(); + let neighbours = blocking(self.config.clone(), "read entity edges", move |config| { + tinymemory_core::store::entities::namespace_entity_edges( + config, + &query_namespace, + &lookup, + limit, + ) + }) + .await?; + Ok(neighbours + .into_iter() + .map(|(object, weight)| GraphRelationRecord { + namespace: Some(namespace.clone()), + subject: subject.clone(), + predicate: "co_occurs_with".to_string(), + object, + attrs: serde_json::Value::Null, + updated_at: 0.0, + evidence_count: weight, + order_index: None, + document_ids: Vec::new(), + chunk_ids: Vec::new(), + }) + .collect()) + } + + async fn touch_entities( + &self, + namespace: &str, + entity_ids: &[String], + ) -> Result<(), MemoryError> { + let entity_ids = entity_ids.to_vec(); + let namespace = namespace.to_string(); + blocking(self.config.clone(), "touch entities", move |config| { + let now = Utc::now().timestamp_millis(); + for entity_id in entity_ids { + let entity_id = format!("{namespace}:{entity_id}"); + let mut counters = + tinymemory_core::store::trees::hotness::get_or_fresh(config, &entity_id)?; + counters.mention_count_30d = counters.mention_count_30d.saturating_add(1); + counters.last_seen_ms = Some(now); + counters.last_updated_ms = now; + tinymemory_core::store::trees::hotness::upsert(config, &counters)?; + } + Ok(()) + }) + .await + } +} + +#[cfg(feature = "memory-git")] +#[async_trait] +impl MemoryDiff for TinycortexProvider { + async fn capture_snapshot(&self, source_id: &str) -> Result { + let source = tinymemory_core::sources::registry::decode_memory_sources(&self.config) + .into_iter() + .find(|source| source.id == source_id) + .ok_or_else(|| MemoryError::NotFound(source_id.to_string()))?; + let snapshot = tinymemory_core::diff::ops::take_snapshot( + &source, + &self.config, + tinymemory_core::diff::SnapshotTrigger::Manual, + ) + .await + .map_err(|error| Self::other("capture snapshot", error))?; + Ok(SnapshotRef { + id: snapshot.id, + source_id: snapshot.source_id, + label: snapshot.label, + item_count: snapshot.item_count, + taken_at_ms: snapshot.taken_at_ms, + }) + } + + async fn snapshots( + &self, + source_id: &str, + limit: usize, + ) -> Result, MemoryError> { + let snapshots = tinymemory_core::diff::ops::list_snapshots( + &self.config, + Some(source_id), + u32::try_from(limit).unwrap_or(u32::MAX), + ) + .await + .map_err(|error| Self::other("list snapshots", error))?; + Ok(snapshots + .into_iter() + .map(|snapshot| SnapshotRef { + id: snapshot.id, + source_id: snapshot.source_id, + label: snapshot.label, + item_count: snapshot.item_count, + taken_at_ms: snapshot.taken_at_ms, + }) + .collect()) + } + + async fn diff( + &self, + source_id: &str, + from: Option<&str>, + to: &str, + ) -> Result { + let result = tinymemory_core::diff::ops::compute_diff(&self.config, from, to, false) + .await + .map_err(|error| Self::other("compute diff", error))?; + if result.source_id != source_id { + return Err(MemoryError::Invalid(format!( + "snapshot '{to}' belongs to a different source" + ))); + } + let changes = result + .changes + .into_iter() + .map(|change| SourceChange { + item_id: change.item_id, + title: change.title, + kind: match change.kind { + tinymemory_core::diff::ChangeKind::Added => ChangeKind::Added, + tinymemory_core::diff::ChangeKind::Removed => ChangeKind::Removed, + tinymemory_core::diff::ChangeKind::Modified => ChangeKind::Modified, + }, + old_content_hash: change.old_content_hash, + new_content_hash: change.new_content_hash, + }) + .collect(); + Ok(DiffReport { + source_id: result.source_id, + from_snapshot_id: result.from_snapshot_id, + to_snapshot_id: result.to_snapshot_id, + added: result.summary.added, + removed: result.summary.removed, + modified: result.summary.modified, + unchanged: result.summary.unchanged, + changes, + }) + } +} + +#[async_trait] +impl MemorySourceSink for TinycortexProvider { + async fn accept_source_items( + &self, + source_id: &str, + source_kind: &str, + items: Vec, + taint: MemoryTaint, + ) -> Result { + let namespace = format!("source:{source_id}"); + let mut outcome = IngestOutcome::default(); + for item in items { + if item.item_id.trim().is_empty() { + return Err(MemoryError::Invalid( + "source item_id must not be empty".to_string(), + )); + } + let title = if item.title.trim().is_empty() { + item.item_id.clone() + } else { + item.title.clone() + }; + let input = NamespaceDocumentInput { + namespace: namespace.clone(), + key: item.item_id, + title, + content: item.content, + source_type: source_kind.to_string(), + priority: "medium".to_string(), + tags: item.tags, + metadata: serde_json::json!({ + "sourceId": source_id, + "sourceKind": source_kind, + "url": item.url, + "mime": item.mime, + "updatedAtMs": item.updated_at_ms, + }), + category: "core".to_string(), + session_id: None, + document_id: None, + taint, + }; + let input = Self::cross(&input, "convert source document")?; + match self.client.put_doc(input).await { + Ok(id) => { + outcome.written = outcome.written.saturating_add(1); + outcome.ids.push(id); + } + Err(_) => { + outcome.skipped = outcome.skipped.saturating_add(1); + } + } + } + Ok(outcome) + } + + async fn forget_source(&self, source_id: &str) -> Result { + let namespace = format!("source:{source_id}"); + let listed = self + .client + .list_documents(Some(&namespace)) + .await + .map_err(|error| Self::other("list source documents", error))?; + let documents = listed + .get("documents") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + if documents > 0 { + self.client + .clear_namespace(&namespace) + .await + .map_err(|error| Self::other("clear source documents", error))?; + } + let source_id = source_id.to_string(); + let chunks = blocking(self.config.clone(), "clear source chunks", move |config| { + use tinymemory_core::store::chunks::{ + delete_chunks_by_source, delete_orphaned_source_tree, SourceKind, + }; + let removed = delete_chunks_by_source(config, SourceKind::Document, &source_id)?; + delete_orphaned_source_tree(config, SourceKind::Document, &source_id)?; + Ok(removed) + }) + .await?; + Ok(u64::try_from(documents.saturating_add(chunks)).unwrap_or(u64::MAX)) + } +} + +#[async_trait] +impl MemoryMaintenance for TinycortexProvider { + async fn reembed(&self) -> Result { + let (examined, changed) = + blocking(self.config.clone(), "enqueue re-embedding", move |config| { + let total = tinymemory_core::queue::count_total(config).unwrap_or(0); + let before = tinymemory_core::queue::count_by_status( + config, + tinymemory_core::queue::JobStatus::Ready, + ) + .unwrap_or(0); + tinymemory_core::queue::ensure_reembed_backfill(config); + let after = tinymemory_core::queue::count_by_status( + config, + tinymemory_core::queue::JobStatus::Ready, + ) + .unwrap_or(0); + Ok((total, after.saturating_sub(before))) + }) + .await?; + Ok(MaintenanceReport { + operation: "reembed".to_string(), + examined, + changed, + findings: vec![format!("enqueued {changed} re-embedding job(s)")], + }) + } + + async fn compact(&self) -> Result { + let (examined, changed) = + blocking(self.config.clone(), "compact memory queue", move |config| { + Ok(( + tinymemory_core::queue::count_total(config).unwrap_or(0), + u64::try_from(tinymemory_core::queue::recover_stale_locks(config).unwrap_or(0)) + .unwrap_or(u64::MAX), + )) + }) + .await?; + Ok(MaintenanceReport { + operation: "compact".to_string(), + examined, + changed, + findings: vec![format!("released {changed} stale queue lock(s)")], + }) + } + + async fn consolidate(&self) -> Result { + let (examined, enqueued) = blocking( + self.config.clone(), + "enqueue consolidation", + move |config| { + Ok(( + tinymemory_core::queue::count_total(config).unwrap_or(0), + tinymemory_core::queue::scheduler::enqueue_flush_stale_job(config) + .map_err(anyhow::Error::msg)?, + )) + }, + ) + .await?; + Ok(MaintenanceReport { + operation: "consolidate".to_string(), + examined, + changed: u64::from(enqueued), + findings: vec![if enqueued { + "enqueued a stale-buffer flush".to_string() + } else { + "a stale-buffer flush is already queued".to_string() + }], + }) + } + + async fn doctor(&self) -> Result { + let report = tinymemory_core::tree::health::async_run_doctor(&self.config).await; + Ok(MaintenanceReport { + operation: "doctor".to_string(), + examined: report.counters.total_chunks, + changed: 0, + findings: report + .stages + .into_iter() + .filter(|stage| !stage.ok) + .map(|stage| format!("{}: {}", stage.stage, stage.note)) + .collect(), + }) + } +} + +#[async_trait] +impl MemoryProvider for TinycortexProvider { + fn driver_id(&self) -> &str { + &self.driver_id + } + fn capabilities(&self) -> Capabilities { + advertised_capabilities() + } + async fn health(&self) -> MemoryHealth { + if self.client.memory_handle().health_check().await { + MemoryHealth::Ready + } else { + MemoryHealth::down("memory store is unavailable") + } + } + fn as_documents(&self) -> Option<&dyn MemoryDocuments> { + Some(self) + } + fn as_ingest(&self) -> Option<&dyn MemoryIngest> { + Some(self) + } + fn as_graph(&self) -> Option<&dyn MemoryGraph> { + Some(self) + } + fn as_goals(&self) -> Option<&dyn MemoryGoals> { + Some(self) + } + fn as_tool_memory(&self) -> Option<&dyn MemoryToolMemory> { + Some(self) + } + fn as_tree(&self) -> Option<&dyn MemoryTree> { + Some(self) + } + fn as_entities(&self) -> Option<&dyn MemoryEntities> { + Some(self) + } + fn as_diff(&self) -> Option<&dyn MemoryDiff> { + // Reachable only when the git-backed snapshot store is compiled in; + // see the `memory-git` feature in this crate's manifest. + #[cfg(feature = "memory-git")] + { + Some(self) + } + #[cfg(not(feature = "memory-git"))] + { + None + } + } + fn as_sources(&self) -> Option<&dyn MemorySourceSink> { + Some(self) + } + fn as_maintenance(&self) -> Option<&dyn MemoryMaintenance> { + Some(self) + } + fn as_people(&self) -> Option<&dyn MemoryPeople> { + Some(self) + } + fn as_chunks(&self) -> Option<&dyn MemoryChunks> { + Some(self) + } + fn as_retrieval(&self) -> Option<&dyn MemoryRetrieval> { + Some(self) + } + fn as_profile(&self) -> Option<&dyn MemoryProfile> { + Some(self) + } + fn as_episodic(&self) -> Option<&dyn MemoryEpisodic> { + Some(self) + } +} + +// ── People ─────────────────────────────────────────────────────────────────── +// +// The conversions below destructure both sides exhaustively rather than +// round-tripping through `Self::cross`. That is deliberate. `cross` is a serde +// value round-trip, so it agrees only while the two crates' field *names* agree +// — and they already do not: the engine's `Interaction` names its timestamp +// `ts` where the contract names it `at`. A round-trip would compile and then +// fail at runtime on the first call. +// +// Destructuring makes the opposite trade: a field added or renamed on either +// side is a compile error here, which is the same rule +// `tinymemory-tinycortex::convert` follows and the same reasoning that governs +// the two copies of the contract itself. + +/// The engine's people store for this module's workspace. +/// +/// `for_workspace` caches per workspace directory, so this is a map lookup +/// after the first call rather than a database open. +fn people_store( + workspace: &std::path::Path, +) -> Result, MemoryError> { + tinycortex::memory::people::store::for_workspace(workspace) + .map_err(|error| MemoryError::Other(anyhow::anyhow!("open people store: {error}"))) +} + +fn handle_to_engine(handle: &PersonHandle) -> tinycortex::memory::people::types::Handle { + use tinycortex::memory::people::types::Handle as EngineHandle; + match handle { + PersonHandle::IMessage(value) => EngineHandle::IMessage(value.clone()), + PersonHandle::Email(value) => EngineHandle::Email(value.clone()), + PersonHandle::DisplayName(value) => EngineHandle::DisplayName(value.clone()), + } +} + +fn handle_to_contract(handle: tinycortex::memory::people::types::Handle) -> PersonHandle { + use tinycortex::memory::people::types::Handle as EngineHandle; + match handle { + EngineHandle::IMessage(value) => PersonHandle::IMessage(value), + EngineHandle::Email(value) => PersonHandle::Email(value), + EngineHandle::DisplayName(value) => PersonHandle::DisplayName(value), + } +} + +fn person_to_contract(person: tinycortex::memory::people::types::Person) -> PersonRecord { + let tinycortex::memory::people::types::Person { + id, + display_name, + primary_email, + primary_phone, + handles, + created_at, + updated_at, + } = person; + PersonRecord { + id: id.to_string(), + display_name, + primary_email, + primary_phone, + handles: handles.into_iter().map(handle_to_contract).collect(), + created_at: created_at.to_rfc3339(), + updated_at: updated_at.to_rfc3339(), + } +} + +fn score_to_contract( + score: tinycortex::memory::people::types::ScoreComponents, + interaction_count: usize, +) -> PersonScore { + let tinycortex::memory::people::types::ScoreComponents { + recency, + frequency, + reciprocity, + depth, + score, + } = score; + PersonScore { + recency, + frequency, + reciprocity, + depth, + score, + interaction_count, + } +} + +/// Parse a caller-supplied person id. +/// +/// `PersonRef` is opaque to the caller by contract, so an unparseable one is a +/// caller mistake — `Invalid`, not `NotFound`. Reporting `NotFound` would tell +/// a caller the id was well-formed but absent, which would send them looking +/// for a deleted person rather than at the id they built. +fn parse_person_id( + person_id: &str, +) -> Result { + person_id + .parse::() + .map(tinycortex::memory::people::types::PersonId) + .map_err(|_| MemoryError::Invalid(format!("malformed person id: {person_id}"))) +} + +#[async_trait] +impl MemoryPeople for TinycortexProvider { + async fn list_people(&self, limit: Option) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let people = store + .list() + .await + .map_err(|error| Self::other("list people", error))?; + + let ids: Vec<_> = people.iter().map(|person| person.id).collect(); + let interactions = store + .batch_interactions_for(&ids) + .await + .map_err(|error| Self::other("load interactions", error))?; + + let now = Utc::now(); + let mut ranked: Vec = people + .into_iter() + .map(|person| { + let observed = interactions.get(&person.id).map_or(&[][..], Vec::as_slice); + let closeness = tinycortex::memory::people::scorer::score(observed, now); + RankedPerson { + person: person_to_contract(person), + score: score_to_contract(closeness, observed.len()), + } + }) + .collect(); + + // Descending by composite score. `total_cmp` rather than `partial_cmp`: + // a NaN from a degenerate score would make `partial_cmp` return `None`, + // and an ordering that is not total is undefined behaviour's + // well-behaved cousin — `sort_by` may panic or produce garbage order. + ranked.sort_by(|a, b| b.score.score.total_cmp(&a.score.score)); + if let Some(limit) = limit { + ranked.truncate(limit); + } + Ok(ranked) + } + + async fn get_person(&self, person_id: &str) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let id = parse_person_id(person_id)?; + Ok(store + .get(id) + .await + .map_err(|error| Self::other("get person", error))? + .map(person_to_contract)) + } + + async fn resolve_handle( + &self, + handle: &PersonHandle, + create_if_missing: bool, + ) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let resolver = tinycortex::memory::people::resolver::HandleResolver::new(&store); + let engine_handle = handle_to_engine(handle); + + if create_if_missing { + let (id, created) = resolver + .resolve_or_create_with_status(&engine_handle) + .await + .map_err(|error| Self::other("resolve or create handle", error))?; + return Ok(Some(ResolvedPerson { + id: id.to_string(), + created, + })); + } + + Ok(resolver + .resolve(&engine_handle) + .await + .map_err(|error| Self::other("resolve handle", error))? + .map(|id| ResolvedPerson { + id: id.to_string(), + created: false, + })) + } + + async fn add_handle_alias( + &self, + person_id: &str, + handle: &PersonHandle, + ) -> Result<(), MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let id = parse_person_id(person_id)?; + if store + .get(id) + .await + .map_err(|error| Self::other("look up person", error))? + .is_none() + { + return Err(MemoryError::NotFound(format!("person {person_id}"))); + } + store + .add_alias(id, handle_to_engine(handle).canonicalize()) + .await + .map_err(|error| Self::other("add handle alias", error)) + } + + async fn score_person(&self, person_id: &str) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let id = parse_person_id(person_id)?; + if store + .get(id) + .await + .map_err(|error| Self::other("look up person", error))? + .is_none() + { + return Ok(None); + } + let interactions = store + .interactions_for(id) + .await + .map_err(|error| Self::other("load interactions", error))?; + Ok(Some(score_to_contract( + tinycortex::memory::people::scorer::score(&interactions, Utc::now()), + interactions.len(), + ))) + } + + async fn record_interaction(&self, interaction: &PersonInteraction) -> Result<(), MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let PersonInteraction { + person_id, + at, + is_outbound, + length, + } = interaction; + let id = parse_person_id(person_id)?; + let ts = chrono::DateTime::parse_from_rfc3339(at) + .map_err(|error| MemoryError::Invalid(format!("malformed interaction time: {error}")))? + .with_timezone(&Utc); + if store + .get(id) + .await + .map_err(|error| Self::other("look up person", error))? + .is_none() + { + return Err(MemoryError::NotFound(format!("person {person_id}"))); + } + store + .record_interaction(tinycortex::memory::people::types::Interaction { + person_id: id, + ts, + is_outbound: *is_outbound, + length: *length, + }) + .await + .map_err(|error| Self::other("record interaction", error)) + } + + async fn seed_from_address_book(&self) -> Result { + let store = people_store(&self.config.workspace_dir)?; + let resolver = tinycortex::memory::people::resolver::HandleResolver::new(&store); + let source = tinycortex::memory::people::address_book::SystemContactsSource; + let (seeded, skipped) = resolver + .seed_from_address_book(&source) + .await + .map_err(|error| Self::other("seed from address book", error))?; + Ok(AddressBookSeedOutcome { seeded, skipped }) + } +} + +// ── Chunks and Retrieval ───────────────────────────────────────────────────── +// +// Both families take the source scope as an **argument** and never read the +// ambient one. `tinymemory_core`'s in-process entry points resolve it from a +// task-local, which the host sets on its own side of the bus — it is simply not +// present in this process. Reading it here would yield `None`, and `None` means +// *unrestricted*, so a per-profile source gate would fail open. That is why the +// `*_scoped` variants exist and why these call them. + +/// Convert a contract scope into the engine's allowlist form. +fn scope_to_engine(scope: Option<&SourceScope>) -> Option> { + scope.map(|scope| scope.allow.iter().cloned().collect()) +} + +#[async_trait] +impl MemoryChunks for TinycortexProvider { + async fn list_chunks( + &self, + query: &ChunkQuery, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + let ChunkQuery { + source_kind, + source_id, + owner, + since_ms, + until_ms, + limit, + offset, + exclude_dropped, + } = query.clone(); + let engine_query = tinymemory_core::store::chunks::ListChunksQuery { + source_kind: source_kind + .map(|kind| Self::cross(&kind, "convert source kind")) + .transpose()?, + source_id, + owner, + since_ms, + until_ms, + limit, + offset, + source_scope: scope_to_engine(scope), + exclude_dropped, + }; + let chunks = blocking(self.config.clone(), "list chunks", move |config| { + tinymemory_core::store::chunks::list_chunks(config, &engine_query) + }) + .await?; + Self::cross(&chunks, "convert chunks") + } + + async fn get_chunk(&self, chunk_id: &str) -> Result, MemoryError> { + let id = chunk_id.to_string(); + let chunk = blocking(self.config.clone(), "get chunk", move |config| { + tinymemory_core::store::chunks::get_chunk(config, &id) + }) + .await?; + match chunk { + Some(chunk) => Ok(Some(Self::cross(&chunk, "convert chunk")?)), + None => Ok(None), + } + } + + async fn chunk_detail(&self, chunk_id: &str) -> Result, MemoryError> { + let id = chunk_id.to_string(); + let detail = blocking(self.config.clone(), "chunk detail", move |config| { + let Some(chunk) = tinymemory_core::store::chunks::get_chunk(config, &id)? else { + return Ok(None); + }; + // The vault read is best-effort: a missing body is reported as + // `None` so the caller can fall back to the row's own content, + // rather than failing the whole detail view over a preview. + let body = tinymemory_core::store::content::read::read_chunk_body(config, &id).ok(); + let has_embedding = + tinymemory_core::store::chunks::get_chunk_embedding(config, &id)?.is_some(); + let lifecycle_status = + tinymemory_core::store::chunks::get_chunk_lifecycle_status(config, &id)?; + let content_path = tinymemory_core::store::chunks::get_chunk_content_path(config, &id)?; + Ok(Some(( + chunk, + body, + has_embedding, + lifecycle_status, + content_path, + ))) + }) + .await?; + + let Some((chunk, body, has_embedding, lifecycle_status, content_path)) = detail else { + return Ok(None); + }; + Ok(Some(ChunkDetail { + chunk: Self::cross(&chunk, "convert chunk")?, + body, + content_path, + lifecycle_status, + has_embedding, + })) + } + + async fn storage_kinds(&self) -> Result, MemoryError> { + Ok(tinymemory_core::store::MemoryKind::ALL + .iter() + .map(|kind| kind.as_str().to_string()) + .collect()) + } + + async fn chunk_embeddings( + &self, + chunk_ids: &[String], + model_signature: &str, + ) -> Result, MemoryError> { + let ids = chunk_ids.to_vec(); + let signature = model_signature.to_string(); + let vectors = blocking( + self.config.clone(), + "load chunk embeddings", + move |config| { + tinymemory_core::store::chunks::get_chunk_embeddings_for_signature_batch( + config, &ids, &signature, + ) + }, + ) + .await?; + // Sorted so the response is deterministic: the engine returns a + // `HashMap`, whose iteration order varies per process and would make an + // otherwise-identical call return a differently-ordered list. + let mut embeddings: Vec = vectors + .into_iter() + .map(|(chunk_id, vector)| ChunkEmbedding { chunk_id, vector }) + .collect(); + embeddings.sort_by(|a, b| a.chunk_id.cmp(&b.chunk_id)); + Ok(embeddings) + } +} + +#[async_trait] +impl MemoryRetrieval for TinycortexProvider { + async fn fast_retrieve( + &self, + query: &str, + options: FastRetrieveQuery, + scope: Option<&SourceScope>, + ) -> Result { + if query.trim().is_empty() { + return Err(MemoryError::Invalid("query must not be empty".to_string())); + } + let engine_options = tinymemory_core::tree::retrieval::FastRetrieveOptions { + limit: options.limit, + max_hops: options.max_hops, + time_window_days: options.time_window_days, + }; + let response = tinymemory_core::tree::retrieval::fast_retrieve_scoped( + &self.config, + query, + engine_options, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("fast retrieve", error))?; + Self::cross(&response, "convert retrieval response") + } + + async fn cover_window( + &self, + window: &CoverWindowQuery, + scope: Option<&SourceScope>, + ) -> Result { + let CoverWindowQuery { + since_ms, + until_ms, + source_id, + source_kind, + limit, + } = window.clone(); + let engine_kind = source_kind + .map(|kind| Self::cross(&kind, "convert source kind")) + .transpose()?; + let response = tinymemory_core::tree::retrieval::cover_window_scoped( + &self.config, + since_ms, + until_ms, + source_id.as_deref(), + engine_kind, + // 0 is the engine's "no caller preference" sentinel, not a request + // for zero rows: `cover_window_scoped` substitutes its own + // DEFAULT_LIMIT for it. Mapping `None` to 0 therefore asks for the + // default, which is what an absent limit means. + limit.unwrap_or(0), + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("cover window", error))?; + Self::cross(&response, "convert retrieval response") + } + + async fn retrieve_source( + &self, + query: &SourceRetrievalQuery, + scope: Option<&SourceScope>, + ) -> Result { + let SourceRetrievalQuery { + source_id, + source_kind, + time_window_days, + query: text, + limit, + } = query.clone(); + let engine_kind = source_kind + .map(|kind| Self::cross(&kind, "convert source kind")) + .transpose()?; + let response = tinymemory_core::tree::retrieval::source::query_source_scoped( + &self.config, + tinymemory_core::tree::retrieval::source::SourceQuery { + source_id: source_id.as_deref(), + source_kind: engine_kind, + time_window_days, + query: text.as_deref(), + limit, + }, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("retrieve source", error))?; + Self::cross(&response, "convert retrieval response") + } + + async fn retrieve_children( + &self, + node_id: &str, + max_depth: u32, + query: Option<&str>, + limit: Option, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + let hits = tinymemory_core::tree::retrieval::drill_down::drill_down_scoped( + &self.config, + node_id, + max_depth, + query, + limit, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("drill down", error))?; + Self::cross(&hits, "convert retrieval hits") + } + + async fn retrieve_leaves( + &self, + chunk_ids: &[String], + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + let hits = tinymemory_core::tree::retrieval::fetch::fetch_leaves_scoped( + &self.config, + chunk_ids, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("fetch leaves", error))?; + Self::cross(&hits, "convert retrieval hits") + } + + async fn recall_namespace_scored( + &self, + namespace: &str, + query: &str, + limit: usize, + exclude_session_id: Option<&str>, + ) -> Result, MemoryError> { + let hits = self + .client + .unified_handle() + .query_namespace_hits_excluding_session( + namespace, + query, + u32::try_from(limit).unwrap_or(u32::MAX), + exclude_session_id, + ) + .await + .map_err(|error| Self::other("recall namespace scored", error))?; + Self::cross(&hits, "convert namespace hits") + } + + async fn search_entities( + &self, + query: &str, + kinds: Option<&[String]>, + limit: usize, + ) -> Result, MemoryError> { + // Request kinds are validated, unlike response kinds which pass through + // as an open vocabulary. An unknown filter that silently matched nothing + // would be indistinguishable from a genuine empty result. + let engine_kinds = match kinds { + Some(kinds) => Some( + kinds + .iter() + .map(|kind| { + tinymemory_core::tree::score::extract::EntityKind::parse(kind).map_err( + |_| MemoryError::Invalid(format!("unknown entity kind: {kind}")), + ) + }) + .collect::, MemoryError>>()?, + ), + None => None, + }; + let matches = tinymemory_core::tree::retrieval::search_entities( + &self.config, + query, + engine_kinds, + limit, + ) + .await + .map_err(|error| Self::other("search entities", error))?; + Self::cross(&matches, "convert entity matches") + } +} + +// ── Profile ────────────────────────────────────────────────────────────────── +// +// `ProfileStore`'s methods are synchronous and hold a `parking_lot::Mutex` +// across a SQLite call, so each one goes through `spawn_blocking` rather than +// being awaited on the runtime thread. The store is cheap to obtain — it is a +// handle over the client's connection, not an open — so it is fetched inside +// the blocking closure rather than held across an await. + +fn facet_type_to_engine( + facet_type: FacetType, +) -> tinymemory_core::store::namespace_store::profile::FacetType { + use tinymemory_core::store::namespace_store::profile::FacetType as Engine; + match facet_type { + FacetType::Preference => Engine::Preference, + FacetType::Workflow => Engine::Workflow, + FacetType::Role => Engine::Role, + FacetType::Personality => Engine::Personality, + FacetType::Context => Engine::Context, + } +} + +#[async_trait] +impl MemoryProfile for TinycortexProvider { + async fn list_active_facets(&self) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let facets = tokio::task::spawn_blocking(move || client.profile_store().list_active()) + .await + .map_err(|e| Self::other("join list_active_facets", e))? + .map_err(|e| Self::other("list_active_facets", e))?; + Self::cross(&facets, "convert facets") + } + + async fn list_all_facets(&self) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let facets = tokio::task::spawn_blocking(move || client.profile_store().list_all()) + .await + .map_err(|e| Self::other("join list_all_facets", e))? + .map_err(|e| Self::other("list_all_facets", e))?; + Self::cross(&facets, "convert facets") + } + + async fn get_facet(&self, key: &str) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let key = key.to_string(); + let facet = tokio::task::spawn_blocking(move || client.profile_store().get(&key)) + .await + .map_err(|e| Self::other("join get_facet", e))? + .map_err(|e| Self::other("get_facet", e))?; + match facet { + Some(facet) => Ok(Some(Self::cross(&facet, "convert facet")?)), + None => Ok(None), + } + } + + async fn facets_by_type( + &self, + facet_type: FacetType, + ) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let engine = facet_type_to_engine(facet_type); + let facets = + tokio::task::spawn_blocking(move || client.profile_store().facets_by_type(&engine)) + .await + .map_err(|e| Self::other("join facets_by_type", e))? + .map_err(|e| Self::other("facets_by_type", e))?; + Self::cross(&facets, "convert facets") + } + + async fn upsert_facet(&self, facet: &ProfileFacet) -> Result<(), MemoryError> { + let client = Arc::clone(&self.client); + let engine: tinymemory_core::store::namespace_store::profile::ProfileFacet = + Self::cross(facet, "convert facet")?; + tokio::task::spawn_blocking(move || client.profile_store().upsert_full(&engine)) + .await + .map_err(|e| Self::other("join upsert_facet", e))? + .map_err(|e| Self::other("upsert_facet", e)) + } + + async fn upsert_provider_facet( + &self, + facet_id: &str, + facet_type: FacetType, + key: &str, + value: &str, + confidence: f64, + segment_id: Option<&str>, + observed_at: f64, + ) -> Result<(), MemoryError> { + let client = Arc::clone(&self.client); + let engine = facet_type_to_engine(facet_type); + let (facet_id, key, value) = (facet_id.to_string(), key.to_string(), value.to_string()); + let segment_id = segment_id.map(str::to_string); + tokio::task::spawn_blocking(move || { + client.profile_store().upsert_provider_facet( + &facet_id, + &engine, + &key, + &value, + confidence, + segment_id.as_deref(), + observed_at, + ) + }) + .await + .map_err(|e| Self::other("join upsert_provider_facet", e))? + .map_err(|e| Self::other("upsert_provider_facet", e)) + } + + async fn set_facet_user_state( + &self, + key: &str, + user_state: UserState, + ) -> Result { + use tinymemory_core::store::namespace_store::profile::UserState as Engine; + let client = Arc::clone(&self.client); + let key = key.to_string(); + let engine = match user_state { + UserState::Auto => Engine::Auto, + UserState::Pinned => Engine::Pinned, + UserState::Forgotten => Engine::Forgotten, + }; + tokio::task::spawn_blocking(move || client.profile_store().set_user_state(&key, engine)) + .await + .map_err(|e| Self::other("join set_facet_user_state", e))? + .map_err(|e| Self::other("set_facet_user_state", e)) + } + + async fn delete_facet(&self, key: &str) -> Result { + let client = Arc::clone(&self.client); + let key = key.to_string(); + tokio::task::spawn_blocking(move || client.profile_store().delete(&key)) + .await + .map_err(|e| Self::other("join delete_facet", e))? + .map_err(|e| Self::other("delete_facet", e)) + } + + async fn delete_facet_by_id(&self, facet_id: &str) -> Result { + let client = Arc::clone(&self.client); + let facet_id = facet_id.to_string(); + tokio::task::spawn_blocking(move || client.profile_store().delete_by_facet_id(&facet_id)) + .await + .map_err(|e| Self::other("join delete_facet_by_id", e))? + .map_err(|e| Self::other("delete_facet_by_id", e)) + } + + async fn drop_facets_below(&self, threshold: f64) -> Result { + let client = Arc::clone(&self.client); + tokio::task::spawn_blocking(move || client.profile_store().drop_below_threshold(threshold)) + .await + .map_err(|e| Self::other("join drop_facets_below", e))? + .map_err(|e| Self::other("drop_facets_below", e)) + } + + async fn workflow_identity_matches(&self, key_pattern: &str, canonical_value: &str) -> bool { + let client = Arc::clone(&self.client); + let (pattern, value) = (key_pattern.to_string(), canonical_value.to_string()); + tokio::task::spawn_blocking(move || { + client + .profile_store() + .skill_identity_matches(&pattern, &value) + }) + .await + // A join failure reads as "no", like every other error on this + // predicate — see the trait docs. But it is logged first: the two + // cases behind it are a cancelled task and a panic inside + // `skill_identity_matches`, and a panic is a defect. Answering a bare + // `false` would make that defect look exactly like a legitimate + // non-match, which is the one reading that guarantees nobody + // investigates it. + .inspect_err(|error| { + log::error!( + "[tinymemory:module] workflow_identity_matches join failed, answering false: \ + {error}" + ); + }) + .unwrap_or(false) + } +} + +/// Episodic capture: the turn-by-turn record and its segment lifecycle. +/// +/// Every method hops to `spawn_blocking` for the same reason the profile family +/// does — these are synchronous `rusqlite` calls behind a `parking_lot::Mutex`, +/// and blocking a tinybus executor thread on a database lock would stall every +/// other call the module is serving. +/// +/// The boundary-detection and summary-composition halves of the archivist are +/// **not** here: they touch no database and are host policy. See the family's +/// contract docs. +#[async_trait] +impl MemoryEpisodic for TinycortexProvider { + async fn insert_turn(&self, turn: &EpisodicTurn) -> Result { + let conn = self.client.profile_conn(); + let entry = tinymemory_core::store::fts5::EpisodicEntry { + id: None, + session_id: turn.session_id.clone(), + timestamp: turn.timestamp, + role: turn.role.clone(), + content: turn.content.clone(), + lesson: turn.lesson.clone(), + tool_calls_json: turn.tool_calls_json.clone(), + // The contract carries this signed because a cost is a plain number + // on the wire; the engine column is unsigned. A negative value is + // not meaningful, so it clamps rather than wrapping. + cost_microdollars: u64::try_from(turn.cost_microdollars).unwrap_or(0), + }; + tokio::task::spawn_blocking(move || { + tinymemory_core::store::fts5::episodic_insert(&conn, &entry) + }) + .await + .map_err(|e| Self::other("join insert_turn", e))? + .map_err(|e| Self::other("insert_turn", e)) + } + + async fn session_turns(&self, session_id: &str) -> Result, MemoryError> { + let conn = self.client.profile_conn(); + let session_id = session_id.to_string(); + let entries = tokio::task::spawn_blocking(move || { + tinymemory_core::store::fts5::episodic_session_entries(&conn, &session_id) + }) + .await + .map_err(|e| Self::other("join session_turns", e))? + .map_err(|e| Self::other("session_turns", e))?; + Ok(entries.into_iter().map(episodic_to_contract).collect()) + } + + async fn open_segment( + &self, + session_id: &str, + ) -> Result, MemoryError> { + let conn = self.client.profile_conn(); + let session_id = session_id.to_string(); + let segment = tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::open_segment_for_session(&conn, &session_id) + }) + .await + .map_err(|e| Self::other("join open_segment", e))? + .map_err(|e| Self::other("open_segment", e))?; + Ok(segment.map(segment_to_contract)) + } + + async fn create_segment( + &self, + segment_id: &str, + session_id: &str, + namespace: &str, + start_episodic_id: i64, + start_timestamp: f64, + now: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let (segment_id, session_id, namespace) = ( + segment_id.to_string(), + session_id.to_string(), + namespace.to_string(), + ); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_create( + &conn, + &segment_id, + &session_id, + &namespace, + start_episodic_id, + // Per-session seq numbering is the archivist store's, and it is + // not part of this contract; legacy rows carry `None` too. + None, + start_timestamp, + now, + ) + }) + .await + .map_err(|e| Self::other("join create_segment", e))? + .map_err(|e| Self::other("create_segment", e)) + } + + async fn append_turn( + &self, + segment_id: &str, + episodic_id: i64, + timestamp: f64, + now: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let segment_id = segment_id.to_string(); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_append_turn( + &conn, + &segment_id, + episodic_id, + None, + timestamp, + now, + ) + }) + .await + .map_err(|e| Self::other("join append_turn", e))? + .map_err(|e| Self::other("append_turn", e)) + } + + async fn close_segment(&self, segment_id: &str, now: f64) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let segment_id = segment_id.to_string(); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_close(&conn, &segment_id, now) + }) + .await + .map_err(|e| Self::other("join close_segment", e))? + .map_err(|e| Self::other("close_segment", e)) + } + + async fn set_segment_summary( + &self, + segment_id: &str, + summary: &str, + now: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let (segment_id, summary) = (segment_id.to_string(), summary.to_string()); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_set_summary(&conn, &segment_id, &summary, now) + }) + .await + .map_err(|e| Self::other("join set_segment_summary", e))? + .map_err(|e| Self::other("set_segment_summary", e)) + } + + async fn upsert_segment_embedding( + &self, + segment_id: &str, + model_signature: &str, + embedding: &[f32], + created_at: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let (segment_id, model_signature) = (segment_id.to_string(), model_signature.to_string()); + let embedding = embedding.to_vec(); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_embedding_upsert( + &conn, + &segment_id, + &model_signature, + &embedding, + created_at, + ) + }) + .await + .map_err(|e| Self::other("join upsert_segment_embedding", e))? + .map_err(|e| Self::other("upsert_segment_embedding", e)) + } +} + +/// Engine episodic row -> contract turn. +fn episodic_to_contract(entry: tinymemory_core::store::fts5::EpisodicEntry) -> EpisodicTurn { + EpisodicTurn { + id: entry.id, + session_id: entry.session_id, + timestamp: entry.timestamp, + role: entry.role, + content: entry.content, + lesson: entry.lesson, + tool_calls_json: entry.tool_calls_json, + cost_microdollars: i64::try_from(entry.cost_microdollars).unwrap_or(i64::MAX), + } +} + +/// Engine segment row -> contract segment. +/// +/// Written out rather than derived: the engine row carries several fields the +/// contract deliberately does not expose (`topic_keywords`, the seq numbers, +/// `created_at`), and a blanket conversion would quietly start shipping them if +/// the contract ever grew a matching name. +fn segment_to_contract( + segment: tinymemory_core::store::segments::ConversationSegment, +) -> ConversationSegment { + use tinymemory_core::store::segments::SegmentStatus; + ConversationSegment { + segment_id: segment.segment_id, + session_id: segment.session_id, + namespace: segment.namespace, + start_episodic_id: segment.start_episodic_id, + end_episodic_id: segment.end_episodic_id, + start_timestamp: segment.start_timestamp, + end_timestamp: segment.end_timestamp, + turn_count: segment.turn_count, + summary: segment.summary, + embedding: segment.embedding, + open: matches!(segment.status, SegmentStatus::Open), + } +} + +#[cfg(test)] +mod test; diff --git a/adapters/tinycortex/src/engine/test.rs b/adapters/tinycortex/src/engine/test.rs new file mode 100644 index 0000000..a53d9b4 --- /dev/null +++ b/adapters/tinycortex/src/engine/test.rs @@ -0,0 +1,54 @@ +//! Capability honesty for the full engine provider. +//! +//! The point of lifting the optional families here (issue #18 §C3) is that a +//! host filtering its surface from a negotiated capability set gets the whole +//! engine rather than the mandatory third of it. That is only safe if the set +//! is true. +//! +//! These assert the rule directly rather than through a constructed provider. +//! Construction needs a `MemoryClient`, which needs the host's process-global +//! seams (`set_embedding_host` and friends) installed — and a test that installs +//! a process global is order-dependent, which `AGENTS.md` rules out. The +//! provider-level check that `capabilities()` equals the reachable accessors is +//! `audit_provider`, and it runs against a real engine in the conformance suite +//! once a host has wired those seams. + +#![allow(clippy::expect_used, clippy::panic)] + +use tinymemory_api::capabilities::{Capabilities, Capability}; + +use super::advertised_capabilities; + +#[test] +fn the_mandatory_families_are_always_advertised() { + let caps = advertised_capabilities(); + for mandatory in Capability::MANDATORY { + assert!( + caps.contains(mandatory), + "`{}` must be advertised in every build", + mandatory.as_str() + ); + } +} + +#[cfg(feature = "memory-git")] +#[test] +fn the_full_engine_advertises_every_family_with_memory_git() { + // The lift's headline: this adapter used to advertise three families. + assert_eq!(advertised_capabilities(), Capabilities::all()); + assert!(advertised_capabilities().contains(Capability::Diff)); +} + +#[cfg(not(feature = "memory-git"))] +#[test] +fn diff_is_withheld_when_the_snapshot_store_is_compiled_out() { + // The gate has to reach the advertisement, not just the accessor. A build + // that advertised `Diff` here would fail `audit_provider` — which is how + // that audit earns its place. + let caps = advertised_capabilities(); + assert!(!caps.contains(Capability::Diff)); + // Everything else the engine serves is still advertised: withholding one + // family must not quietly withhold the rest. + assert_eq!(caps, Capabilities::all().without(Capability::Diff)); + assert_eq!(caps.len(), Capabilities::all().len() - 1); +} diff --git a/adapters/tinycortex/src/lib.rs b/adapters/tinycortex/src/lib.rs index 078e747..ea9ead8 100644 --- a/adapters/tinycortex/src/lib.rs +++ b/adapters/tinycortex/src/lib.rs @@ -13,26 +13,40 @@ //! - [`TinycortexMemory`] — wraps any TinyCortex [`tinycortex::memory::Memory`] //! backend as a TinyMemory //! [`Memory`](tinymemory_api::traits::Memory). -//! - [`provider`] — the one call that turns a TinyCortex backend into a bound -//! driver, by pairing [`TinycortexMemory`] with -//! [`MemoryTraitProvider`]. +//! - [`provider`] — the one call that turns a TinyCortex backend into a +//! mandatory-only driver, by pairing [`TinycortexMemory`] with +//! [`MemoryTraitProvider`]. Enough when a host wants store, recall and +//! export and nothing else. +//! - [`engine`] — [`TinycortexProvider`](engine::TinycortexProvider), the whole +//! engine behind the contract: trees, chunks, entities, the graph, goals, +//! tool-memory, ingestion, sources, maintenance, people, retrieval, profile, +//! episodic, and — with `memory-git` — the diff ledger. //! -//! ## Scope: the mandatory three, not the whole engine +//! ## Two drivers, and why both //! -//! A driver built here advertises Core, Recall and Portability. TinyCortex can -//! do far more — trees, chunks, entities, a diff ledger — but those families -//! are reached through engine entry points that need a host's configuration, -//! embedding compute and job queue, none of which this crate has. A host that -//! provides them implements the optional families itself and delegates only the -//! mandatory three here. +//! [`provider`] advertises Core, Recall and Portability. That used to be the +//! only thing here, and it was the reason anything wanting a summary tree or a +//! diff ledger reached past the contract to the engine directly: the families +//! existed, but not through `MemoryProvider`. Issue #18 §C3 lifted those +//! implementations here from `tinymemory-module`, which had grown them because +//! it needed them and nowhere else had them. //! -//! Advertising only what is reachable is deliberate, not a shortcut: a driver -//! whose capability set overstates its accessors fails +//! [`engine::TinycortexProvider`] needs what they need — a workspace, a host +//! configuration, and a `MemoryClient` — so it is the heavier of the two, and a +//! host that has none of that still has [`provider`]. +//! +//! ## Capability honesty +//! +//! Both advertise exactly what they reach. That is deliberate, not a shortcut: +//! a driver whose capability set overstates its accessors fails //! [`audit_provider`](tinymemory_api::provider::audit_provider), and a host that //! filtered its RPC surface from an overstated set would register methods that -//! answer errors. +//! answer errors. It is also why the `memory-git` feature reaches +//! [`engine::advertised_capabilities`] and not just the accessor — a build +//! without the git-backed snapshot store must not claim a diff ledger. pub mod convert; +pub mod engine; mod memory; pub use memory::TinycortexMemory; diff --git a/crates/tinymemory-module/Cargo.lock b/crates/tinymemory-module/Cargo.lock index 30ffb20..e5599eb 100644 --- a/crates/tinymemory-module/Cargo.lock +++ b/crates/tinymemory-module/Cargo.lock @@ -2027,9 +2027,16 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", + "log", + "serde", + "serde_json", "tinycortex", "tinymemory", "tinymemory-api", + "tinymemory-core", + "tokio", + "uuid", ] [[package]] diff --git a/crates/tinymemory-module/Cargo.toml b/crates/tinymemory-module/Cargo.toml index c8482f3..adbc845 100644 --- a/crates/tinymemory-module/Cargo.toml +++ b/crates/tinymemory-module/Cargo.toml @@ -32,7 +32,7 @@ tinymemory = { path = "../.." } # the module: they are 14.7s of the host's critical build path, and a host that # loads this binary compiles neither. tinymemory-core = { path = "../../core", features = ["memory-git"] } -tinymemory-tinycortex = { path = "../../adapters/tinycortex" } +tinymemory-tinycortex = { path = "../../adapters/tinycortex", features = ["memory-git"] } # `people` is enabled here rather than inherited: the module serves the # `MemoryPeople` family directly off the engine's people store, so it needs the # gate on even though `tinymemory-core` only re-exports the domain. diff --git a/crates/tinymemory-module/src/lib.rs b/crates/tinymemory-module/src/lib.rs index 9cad0eb..30e705f 100644 --- a/crates/tinymemory-module/src/lib.rs +++ b/crates/tinymemory-module/src/lib.rs @@ -153,7 +153,7 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() setup_error("create memory store") })?; - let provider = provider::ModuleMemoryProvider::new(&config, Arc::new(client)); + let provider = provider::provider(&config, Arc::new(client)); service::serve(&connection, Arc::new(provider), config).await } diff --git a/crates/tinymemory-module/src/provider.rs b/crates/tinymemory-module/src/provider.rs index 48e46a2..278a000 100644 --- a/crates/tinymemory-module/src/provider.rs +++ b/crates/tinymemory-module/src/provider.rs @@ -1,66 +1,18 @@ -//! Complete TinyMemory provider backed by the module-owned engine. +//! The module's own configuration, converted for the engine provider. +//! +//! The provider itself now lives in `tinymemory-tinycortex` (issue #18 §C3). +//! Everything that was here delegated to `tinymemory-core` on a blocking +//! thread and was never module-specific; what remains is the one thing that is +//! — turning a `ModuleConfig` into the engine's runtime configuration. -use std::collections::HashSet; -use std::path::PathBuf; use std::sync::Arc; -use async_trait::async_trait; -use chrono::Utc; -use tinymemory::mandatory::MemoryTraitProvider; -use tinymemory_api::capabilities::Capabilities; -use tinymemory_api::chunks::Chunk; -use tinymemory_api::error::MemoryError; -use tinymemory_api::goals::GoalsDoc; -use tinymemory_api::health::MemoryHealth; -use tinymemory_api::host::{ - CloudProviderCreds, ComposioMode, LocalAiConfig, MemoryConfig, MemoryHostConfig, - MemoryTreeConfig, SchedulerGateConfig, -}; -use tinymemory_api::provider::types::{ - ChangeKind, DiffReport, EntityHit, EntityRef, ExportPage, ExportRecord, ImportOutcome, - IngestItem, IngestOutcome, MaintenanceReport, SnapshotRef, SourceChange, SourceItem, - SourceScope, -}; -use tinymemory_api::provider::{ - AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, ConversationSegment, - CoverWindowQuery, EntityMatch, EpisodicTurn, FacetType, FastRetrieveQuery, MemoryChunks, - MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryEpisodic, MemoryGoals, - MemoryGraph, MemoryIngest, MemoryMaintenance, MemoryPeople, MemoryPortability, MemoryProfile, - MemoryProvider, MemoryRecall, MemoryRetrieval, MemorySourceSink, MemoryToolMemory, MemoryTree, - PersonHandle, PersonInteraction, PersonRecord, PersonScore, ProfileFacet, RankedPerson, - ResolvedPerson, RetrievalHit, RetrievalResponse, SourceRetrievalQuery, UserState, -}; -use tinymemory_api::recall::OwnedRecallOpts; -use tinymemory_api::tool_memory::ToolMemoryRule; -use tinymemory_api::tree::{IngestRequest, QueryResult, TreeStatus}; -use tinymemory_api::types::{ - GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, - NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, NamespaceSummary, - StoredMemoryDocument, -}; -use tinymemory_core::store::{MemoryClient, MemoryClientRef}; -use tinymemory_tinycortex::TinycortexMemory; +use tinymemory_core::store::MemoryClient; +use tinymemory_tinycortex::engine::{EngineRuntimeConfig, TinycortexProvider}; use crate::ModuleConfig; -/// The concrete, credential-free host configuration available inside a module. -#[derive(Debug, Clone)] -struct ModuleRuntimeConfig { - workspace_dir: PathBuf, - config_path: PathBuf, - memory: MemoryConfig, - memory_tree: MemoryTreeConfig, - scheduler_gate: SchedulerGateConfig, - local_ai: LocalAiConfig, - embeddings_provider: Option, - memory_provider: Option, - default_model: Option, - default_temperature: f64, - output_language: Option, - memory_sources: serde_json::Value, -} - -impl From<&ModuleConfig> for ModuleRuntimeConfig { +impl From<&ModuleConfig> for EngineRuntimeConfig { fn from(config: &ModuleConfig) -> Self { Self { workspace_dir: config.workspace_dir.clone(), @@ -79,2111 +31,11 @@ impl From<&ModuleConfig> for ModuleRuntimeConfig { } } -#[async_trait] -impl MemoryHostConfig for ModuleRuntimeConfig { - fn workspace_dir(&self) -> &PathBuf { - &self.workspace_dir - } - fn config_path(&self) -> &PathBuf { - &self.config_path - } - fn memory_tree_content_root(&self) -> PathBuf { - self.memory_tree - .content_dir - .clone() - .unwrap_or_else(|| self.workspace_dir.join("memory_tree/content")) - } - fn memory(&self) -> &MemoryConfig { - &self.memory - } - fn memory_tree(&self) -> &MemoryTreeConfig { - &self.memory_tree - } - fn scheduler_gate(&self) -> &SchedulerGateConfig { - &self.scheduler_gate - } - fn local_ai(&self) -> &LocalAiConfig { - &self.local_ai - } - fn cloud_providers(&self) -> &Vec { - static NONE: Vec = Vec::new(); - &NONE - } - fn embeddings_provider(&self) -> Option<&str> { - self.embeddings_provider.as_deref() - } - fn memory_provider(&self) -> Option<&str> { - self.memory_provider.as_deref() - } - fn workload_local_model(&self, workload: &str) -> Option { - let route = match workload { - "memory" => self.memory_provider.as_deref(), - "embeddings" => self.embeddings_provider.as_deref(), - _ => None, - }?; - route - .strip_prefix("ollama:") - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - } - fn as_any(&self) -> &dyn std::any::Any { - self - } - fn to_arc(&self) -> Arc { - Arc::new(self.clone()) - } - fn api_url(&self) -> Option<&str> { - None - } - fn effective_backend_api_url(&self) -> String { - String::new() - } - fn session_token(&self) -> Result, String> { - Ok(None) - } - fn default_model(&self) -> Option<&str> { - self.default_model.as_deref() - } - fn default_temperature(&self) -> f64 { - self.default_temperature - } - fn output_language(&self) -> Option<&str> { - self.output_language.as_deref() - } - fn memory_sync_interval_secs(&self) -> Option { - Some(0) - } - fn onboarding_completed(&self) -> bool { - true - } - fn secrets_encrypt(&self) -> bool { - false - } - fn composio(&self) -> ComposioMode { - ComposioMode::default() - } - fn memory_sources_json(&self) -> anyhow::Result { - Ok(self.memory_sources.clone()) - } - fn set_memory_sources_json(&mut self, value: serde_json::Value) -> anyhow::Result<()> { - self.memory_sources = value; - Ok(()) - } - fn composio_source_caps_migration_version(&self) -> u32 { - 0 - } - fn set_composio_source_caps_migration_version(&mut self, _version: u32) {} - fn apply_env_overrides(&mut self) {} - async fn save(&self) -> anyhow::Result<()> { - Ok(()) - } -} - -/// The module-owned implementation of every TinyMemory capability family. -pub(crate) struct ModuleMemoryProvider { - driver_id: String, - mandatory: MemoryTraitProvider, - client: MemoryClientRef, - config: ModuleRuntimeConfig, -} - -impl ModuleMemoryProvider { - pub(crate) fn new(config: &ModuleConfig, client: Arc) -> Self { - let memory = client.memory_handle(); - let mandatory = MemoryTraitProvider::new( - Arc::new(TinycortexMemory::new(memory)), - config.driver_id.clone(), - ); - Self { - driver_id: config.driver_id.clone(), - mandatory, - client, - config: ModuleRuntimeConfig::from(config), - } - } - - fn other(context: &'static str, error: impl std::fmt::Display) -> MemoryError { - MemoryError::Other(anyhow::anyhow!("{context}: {error}")) - } - - fn cross( - value: &A, - context: &'static str, - ) -> Result { - let value = serde_json::to_value(value).map_err(|error| Self::other(context, error))?; - serde_json::from_value(value).map_err(|error| Self::other(context, error)) - } -} - -fn validate_ingest_item(item: &IngestItem) -> Result<(), MemoryError> { - if item.taint != MemoryTaint::default() { - return Err(MemoryError::Invalid( - "ingest cannot preserve a non-default taint in the chunk tier".to_string(), - )); - } - if item.content.trim().is_empty() { - return Err(MemoryError::Invalid( - "ingest content must not be empty".to_string(), - )); - } - if let Some(mime) = item.mime.as_deref() { - let mime = mime.trim().to_ascii_lowercase(); - let base = mime.split(';').next().unwrap_or("").trim(); - if !(base.starts_with("text/") - || base.ends_with("+json") - || base.ends_with("+xml") - || matches!( - base, - "application/json" | "application/xml" | "application/x-ndjson" - )) - { - return Err(MemoryError::Invalid(format!( - "unsupported MIME '{mime}': ingest accepts decoded text only" - ))); - } - } - Ok(()) -} - -async fn blocking( - config: ModuleRuntimeConfig, - context: &'static str, - run: F, -) -> Result -where - T: Send + 'static, - F: FnOnce(&ModuleRuntimeConfig) -> anyhow::Result + Send + 'static, -{ - tokio::task::spawn_blocking(move || run(&config)) - .await - .map_err(|error| ModuleMemoryProvider::other(context, error))? - .map_err(|error| ModuleMemoryProvider::other(context, error)) -} - -#[async_trait] -impl MemoryCore for ModuleMemoryProvider { - 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 ModuleMemoryProvider { - 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 ModuleMemoryProvider { - 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 MemoryDocuments for ModuleMemoryProvider { - async fn put_document(&self, input: NamespaceDocumentInput) -> Result { - let input = Self::cross(&input, "convert document input")?; - self.client - .put_doc(input) - .await - .map_err(|error| Self::other("put_document", error)) - } - async fn get_document( - &self, - namespace: &str, - key: &str, - ) -> Result, MemoryError> { - let document = self - .client - .get_document(namespace, key) - .await - .map_err(|error| Self::other("get_document", error))?; - document - .map(|document| Self::cross(&document, "convert stored document")) - .transpose() - } - - async fn list_documents( - &self, - namespace: Option<&str>, - ) -> Result { - self.client - .list_documents(namespace) - .await - .map_err(|error| Self::other("list_documents", error)) - } - - async fn list_namespaces(&self) -> Result, MemoryError> { - self.client - .list_namespaces() - .await - .map_err(|error| Self::other("list_namespaces", error)) - } - - async fn delete_document( - &self, - namespace: &str, - document_id: &str, - ) -> Result { - self.client - .delete_document(namespace, document_id) - .await - .map_err(|error| Self::other("delete_document", error)) - } - - async fn clear_namespace(&self, namespace: &str) -> Result<(), MemoryError> { - self.client - .clear_namespace(namespace) - .await - .map_err(|error| Self::other("clear_namespace", error)) - } - async fn query_documents( - &self, - namespace: &str, - query: &str, - limit: usize, - ) -> Result { - let limit = u32::try_from(limit).unwrap_or(u32::MAX); - let context = self - .client - .query_namespace_context_data(namespace, query, limit) - .await - .map_err(|error| Self::other("query_documents", error))?; - Self::cross(&context, "convert document query result") - } - - async fn recall_documents( - &self, - namespace: &str, - limit: usize, - ) -> Result { - let limit = u32::try_from(limit).unwrap_or(u32::MAX); - let context = self - .client - .recall_namespace_context_data(namespace, limit) - .await - .map_err(|error| Self::other("recall_documents", error))?; - Self::cross(&context, "convert document recall result") - } -} - -#[async_trait] -impl MemoryIngest for ModuleMemoryProvider { - async fn ingest_document(&self, item: IngestItem) -> Result { - validate_ingest_item(&item)?; - let document = tinycortex::memory::ingest::canonicalize::document::DocumentInput { - provider: item.source.as_str().to_string(), - title: String::new(), - body: item.content, - modified_at: item.timestamp.unwrap_or_else(Utc::now), - source_ref: item.source_ref.map(|source_ref| source_ref.value), - }; - let result = tinymemory_core::ingest_pipeline::ingest_document_with_scope( - &self.config, - &item.source_id, - &item.owner, - item.tags, - document, - item.path_scope, - ) - .await - .map_err(|error| Self::other("ingest document", error))?; - Ok(IngestOutcome { - written: u32::try_from(result.chunks_written).unwrap_or(u32::MAX), - skipped: if result.already_ingested { - 1 - } else { - u32::try_from(result.chunks_dropped).unwrap_or(u32::MAX) - }, - ids: result.chunk_ids, - }) - } - - async fn ingest_chat(&self, messages: Vec) -> Result { - let Some(first) = messages.first() else { - return Ok(IngestOutcome::default()); - }; - let source_id = first.source_id.clone(); - let owner = first.owner.clone(); - let tags = first.tags.clone(); - let platform = first.source.as_str().to_string(); - for item in &messages { - validate_ingest_item(item)?; - if item.source_id != source_id { - return Err(MemoryError::Invalid( - "ingest_chat batches must contain one conversation".to_string(), - )); - } - } - let batch = tinycortex::memory::ingest::canonicalize::chat::ChatBatch { - platform, - channel_label: source_id.clone(), - messages: messages - .into_iter() - .map( - |item| tinycortex::memory::ingest::canonicalize::chat::ChatMessage { - author: item.owner, - timestamp: item.timestamp.unwrap_or_else(Utc::now), - text: item.content, - source_ref: item.source_ref.map(|source_ref| source_ref.value), - }, - ) - .collect(), - }; - let result = tinymemory_core::ingest_pipeline::ingest_chat( - &self.config, - &source_id, - &owner, - tags, - batch, - ) - .await - .map_err(|error| Self::other("ingest chat", error))?; - Ok(IngestOutcome { - written: u32::try_from(result.chunks_written).unwrap_or(u32::MAX), - skipped: if result.already_ingested { - 1 - } else { - u32::try_from(result.chunks_dropped).unwrap_or(u32::MAX) - }, - ids: result.chunk_ids, - }) - } -} - -#[async_trait] -impl MemoryGraph for ModuleMemoryProvider { - async fn kv_get( - &self, - namespace: Option<&str>, - key: &str, - ) -> Result, MemoryError> { - let record = self - .client - .kv_records(namespace) - .await - .map_err(|error| Self::other("kv_get", error))? - .into_iter() - .find(|record| record.key == key); - record - .map(|record| Self::cross(&record, "convert key/value record")) - .transpose() - } - async fn kv_put( - &self, - namespace: Option<&str>, - key: &str, - value: serde_json::Value, - ) -> Result<(), MemoryError> { - self.client - .kv_set(namespace, key, &value) - .await - .map_err(|error| Self::other("kv_put", error)) - } - - async fn kv_delete(&self, namespace: Option<&str>, key: &str) -> Result { - self.client - .kv_delete(namespace, key) - .await - .map_err(|error| Self::other("kv_delete", error)) - } - async fn kv_list( - &self, - namespace: Option<&str>, - prefix: Option<&str>, - limit: usize, - ) -> Result, MemoryError> { - let mut records = self - .client - .kv_records(namespace) - .await - .map_err(|error| Self::other("kv_list", error))?; - if let Some(prefix) = prefix { - records.retain(|record| record.key.starts_with(prefix)); - } - records.truncate(limit); - Self::cross(&records, "convert key/value records") - } - async fn relations( - &self, - namespace: Option<&str>, - subject: Option<&str>, - predicate: Option<&str>, - limit: usize, - ) -> Result, MemoryError> { - let mut records = self - .client - .graph_relations(namespace, subject, predicate) - .await - .map_err(|error| Self::other("relations", error))?; - records.truncate(limit); - Self::cross(&records, "convert graph relations") - } - async fn put_relation(&self, relation: GraphRelationRecord) -> Result<(), MemoryError> { - self.client - .graph_upsert( - relation.namespace.as_deref(), - &relation.subject, - &relation.predicate, - &relation.object, - &relation.attrs, - ) - .await - .map_err(|error| Self::other("put_relation", error)) - } -} - -#[async_trait] -impl MemoryGoals for ModuleMemoryProvider { - async fn goals(&self) -> Result { - let workspace = self.config.workspace_dir.clone(); - let document = - tokio::task::spawn_blocking(move || tinycortex::memory::goals::store::load(&workspace)) - .await - .map_err(|error| Self::other("join goals read", error))? - .map_err(|error| Self::other("read goals", error))?; - Self::cross(&document, "convert goals") - } - - async fn set_goals(&self, goals: GoalsDoc) -> Result<(), MemoryError> { - let workspace = self.config.workspace_dir.clone(); - let mut goals = Self::cross(&goals, "convert goals")?; - tokio::task::spawn_blocking(move || { - tinycortex::memory::goals::store::save(&workspace, &mut goals) - }) - .await - .map_err(|error| Self::other("join goals write", error))? - .map_err(|error| Self::other("write goals", error)) - } -} - -#[async_trait] -impl MemoryToolMemory for ModuleMemoryProvider { - async fn tool_rules(&self, tool_name: &str) -> Result, MemoryError> { - let rules = tinymemory_core::tool_memory::tool_memory_store(self.client.memory_handle()) - .list_rules(tool_name) - .await - .map_err(|error| Self::other("list tool rules", error))?; - Self::cross(&rules, "convert tool rules") - } - - async fn put_tool_rule(&self, rule: ToolMemoryRule) -> Result<(), MemoryError> { - let rule = Self::cross(&rule, "convert tool rule")?; - tinymemory_core::tool_memory::tool_memory_store(self.client.memory_handle()) - .put_rule(rule) - .await - .map(|_| ()) - .map_err(|error| Self::other("put tool rule", error)) - } - - async fn delete_tool_rule(&self, tool_name: &str, rule_id: &str) -> Result { - tinymemory_core::tool_memory::tool_memory_store(self.client.memory_handle()) - .delete_rule(tool_name, rule_id) - .await - .map_err(|error| Self::other("delete tool rule", error)) - } -} - -#[async_trait] -impl MemoryTree for ModuleMemoryProvider { - async fn append(&self, request: IngestRequest) -> Result<(), MemoryError> { - tinycortex::memory::tree::runtime::store::validate_namespace(&request.namespace) - .map_err(MemoryError::Invalid)?; - if request.content.trim().is_empty() { - return Err(MemoryError::Invalid( - "content must not be empty".to_string(), - )); - } - let namespace = request.namespace.trim().to_string(); - let content = request.content; - let timestamp = request.timestamp.unwrap_or_else(Utc::now); - let metadata = request.metadata; - blocking(self.config.clone(), "append tree content", move |config| { - tinymemory_core::tree::tree_runtime::store::buffer_write( - config, - &namespace, - &content, - ×tamp, - metadata.as_ref(), - ) - .map(|_| ()) - }) - .await - } - - async fn query_source( - &self, - namespace: &str, - source_id: &str, - limit: usize, - scope: Option<&SourceScope>, - ) -> Result, MemoryError> { - tinycortex::memory::tree::runtime::store::validate_namespace(namespace) - .map_err(MemoryError::Invalid)?; - let query = tinymemory_core::store::chunks::ListChunksQuery { - source_id: Some(source_id.to_string()), - source_scope: scope.map(|scope| scope.allow.iter().cloned().collect::>()), - limit: Some(limit), - exclude_dropped: true, - ..Default::default() - }; - let chunks = blocking(self.config.clone(), "query source", move |config| { - tinymemory_core::store::chunks::list_chunks(config, &query) - }) - .await?; - Self::cross(&chunks, "convert source chunks") - } - - async fn drill_down(&self, namespace: &str, node_id: &str) -> Result { - tinycortex::memory::tree::runtime::store::validate_namespace(namespace) - .map_err(MemoryError::Invalid)?; - tinycortex::memory::tree::runtime::store::validate_node_id(node_id) - .map_err(MemoryError::Invalid)?; - let namespace = namespace.trim().to_string(); - let node_id = node_id.to_string(); - let lookup_namespace = namespace.clone(); - let lookup_node = node_id.clone(); - let result = blocking(self.config.clone(), "drill down", move |config| { - let Some(node) = tinymemory_core::tree::tree_runtime::store::read_node( - config, - &lookup_namespace, - &lookup_node, - )? - else { - return Ok(None); - }; - let children = tinymemory_core::tree::tree_runtime::store::read_children( - config, - &lookup_namespace, - &lookup_node, - )?; - Ok(Some((node, children))) - }) - .await? - .ok_or_else(|| { - MemoryError::NotFound(format!("tree node '{node_id}' not found in '{namespace}'")) - })?; - Self::cross(&result, "convert tree drill-down") - .map(|(node, children)| QueryResult { node, children }) - } - - async fn seal(&self, namespace: &str) -> Result { - tinycortex::memory::tree::runtime::store::validate_namespace(namespace) - .map_err(MemoryError::Invalid)?; - let namespace = namespace.trim().to_string(); - let read_namespace = namespace.clone(); - let buffered = blocking(self.config.clone(), "read tree buffer", move |config| { - tinymemory_core::tree::tree_runtime::store::buffer_read(config, &read_namespace) - }) - .await?; - if !buffered.is_empty() { - let (model, _) = tinymemory_core::chat_host::create_chat_model_with_model_id( - "summarization", - &self.config, - self.config.default_temperature, - ) - .map_err(|error| Self::other("create summarizer", error))?; - tinymemory_core::tree::tree_runtime::engine::run_summarization( - &self.config, - model.as_ref(), - &namespace, - Utc::now(), - ) - .await - .map_err(|error| Self::other("seal tree", error))?; - } - let status = blocking(self.config.clone(), "read tree status", move |config| { - tinymemory_core::tree::tree_runtime::store::get_tree_status(config, &namespace) - }) - .await?; - Self::cross(&status, "convert tree status") - } - - async fn cascade(&self, namespace: &str) -> Result { - tinycortex::memory::tree::runtime::store::validate_namespace(namespace) - .map_err(MemoryError::Invalid)?; - let namespace = namespace.trim().to_string(); - let read_namespace = namespace.clone(); - let status = blocking(self.config.clone(), "read tree status", move |config| { - tinymemory_core::tree::tree_runtime::store::get_tree_status(config, &read_namespace) - }) - .await?; - if status.total_nodes == 0 { - return Self::cross(&status, "convert tree status"); - } - let (model, _) = tinymemory_core::chat_host::create_chat_model_with_model_id( - "summarization", - &self.config, - self.config.default_temperature, - ) - .map_err(|error| Self::other("create summarizer", error))?; - let status = tinymemory_core::tree::tree_runtime::engine::rebuild_tree( - &self.config, - model.as_ref(), - &namespace, - ) - .await - .map_err(|error| Self::other("cascade tree", error))?; - Self::cross(&status, "convert tree status") - } -} - -#[async_trait] -impl MemoryEntities for ModuleMemoryProvider { - async fn entities( - &self, - namespace: &str, - query: Option<&str>, - limit: usize, - ) -> Result, MemoryError> { - let namespace = namespace.to_string(); - let query_namespace = namespace.clone(); - let query = query.map(str::to_string); - let rows = blocking( - self.config.clone(), - "list namespace entities", - move |config| { - tinymemory_core::store::entities::namespace_entities( - config, - &query_namespace, - query.as_deref(), - limit, - ) - }, - ) - .await? - .into_iter() - .map(|hit| (hit.id, hit.kind, hit.name, hit.mentions)) - .collect::>(); - - let config = self.config.clone(); - blocking(config, "attach entity hotness", move |config| { - Ok(rows - .into_iter() - .map(|(id, kind, name, mentions)| { - let hotness_key = format!("{namespace}:{id}"); - let hotness = tinymemory_core::store::trees::hotness::get(config, &hotness_key) - .ok() - .flatten() - .map_or(0.0, |counters| { - f64::from( - tinymemory_core::tree_policy::TreePolicy::topic().topic_hotness( - &id, - &counters.stats(), - Utc::now().timestamp_millis(), - ), - ) - }); - EntityHit { - entity: EntityRef { id, kind, name }, - hotness, - mentions, - } - }) - .collect()) - }) - .await - } - - async fn entity_edges( - &self, - namespace: &str, - entity_id: &str, - limit: usize, - ) -> Result, MemoryError> { - let subject = entity_id.to_string(); - let lookup = subject.clone(); - let namespace = namespace.to_string(); - let query_namespace = namespace.clone(); - let neighbours = blocking(self.config.clone(), "read entity edges", move |config| { - tinymemory_core::store::entities::namespace_entity_edges( - config, - &query_namespace, - &lookup, - limit, - ) - }) - .await?; - Ok(neighbours - .into_iter() - .map(|(object, weight)| GraphRelationRecord { - namespace: Some(namespace.clone()), - subject: subject.clone(), - predicate: "co_occurs_with".to_string(), - object, - attrs: serde_json::Value::Null, - updated_at: 0.0, - evidence_count: weight, - order_index: None, - document_ids: Vec::new(), - chunk_ids: Vec::new(), - }) - .collect()) - } - - async fn touch_entities( - &self, - namespace: &str, - entity_ids: &[String], - ) -> Result<(), MemoryError> { - let entity_ids = entity_ids.to_vec(); - let namespace = namespace.to_string(); - blocking(self.config.clone(), "touch entities", move |config| { - let now = Utc::now().timestamp_millis(); - for entity_id in entity_ids { - let entity_id = format!("{namespace}:{entity_id}"); - let mut counters = - tinymemory_core::store::trees::hotness::get_or_fresh(config, &entity_id)?; - counters.mention_count_30d = counters.mention_count_30d.saturating_add(1); - counters.last_seen_ms = Some(now); - counters.last_updated_ms = now; - tinymemory_core::store::trees::hotness::upsert(config, &counters)?; - } - Ok(()) - }) - .await - } -} - -#[async_trait] -impl MemoryDiff for ModuleMemoryProvider { - async fn capture_snapshot(&self, source_id: &str) -> Result { - let source = tinymemory_core::sources::registry::decode_memory_sources(&self.config) - .into_iter() - .find(|source| source.id == source_id) - .ok_or_else(|| MemoryError::NotFound(source_id.to_string()))?; - let snapshot = tinymemory_core::diff::ops::take_snapshot( - &source, - &self.config, - tinymemory_core::diff::SnapshotTrigger::Manual, - ) - .await - .map_err(|error| Self::other("capture snapshot", error))?; - Ok(SnapshotRef { - id: snapshot.id, - source_id: snapshot.source_id, - label: snapshot.label, - item_count: snapshot.item_count, - taken_at_ms: snapshot.taken_at_ms, - }) - } - - async fn snapshots( - &self, - source_id: &str, - limit: usize, - ) -> Result, MemoryError> { - let snapshots = tinymemory_core::diff::ops::list_snapshots( - &self.config, - Some(source_id), - u32::try_from(limit).unwrap_or(u32::MAX), - ) - .await - .map_err(|error| Self::other("list snapshots", error))?; - Ok(snapshots - .into_iter() - .map(|snapshot| SnapshotRef { - id: snapshot.id, - source_id: snapshot.source_id, - label: snapshot.label, - item_count: snapshot.item_count, - taken_at_ms: snapshot.taken_at_ms, - }) - .collect()) - } - - async fn diff( - &self, - source_id: &str, - from: Option<&str>, - to: &str, - ) -> Result { - let result = tinymemory_core::diff::ops::compute_diff(&self.config, from, to, false) - .await - .map_err(|error| Self::other("compute diff", error))?; - if result.source_id != source_id { - return Err(MemoryError::Invalid(format!( - "snapshot '{to}' belongs to a different source" - ))); - } - let changes = result - .changes - .into_iter() - .map(|change| SourceChange { - item_id: change.item_id, - title: change.title, - kind: match change.kind { - tinymemory_core::diff::ChangeKind::Added => ChangeKind::Added, - tinymemory_core::diff::ChangeKind::Removed => ChangeKind::Removed, - tinymemory_core::diff::ChangeKind::Modified => ChangeKind::Modified, - }, - old_content_hash: change.old_content_hash, - new_content_hash: change.new_content_hash, - }) - .collect(); - Ok(DiffReport { - source_id: result.source_id, - from_snapshot_id: result.from_snapshot_id, - to_snapshot_id: result.to_snapshot_id, - added: result.summary.added, - removed: result.summary.removed, - modified: result.summary.modified, - unchanged: result.summary.unchanged, - changes, - }) - } -} - -#[async_trait] -impl MemorySourceSink for ModuleMemoryProvider { - async fn accept_source_items( - &self, - source_id: &str, - source_kind: &str, - items: Vec, - taint: MemoryTaint, - ) -> Result { - let namespace = format!("source:{source_id}"); - let mut outcome = IngestOutcome::default(); - for item in items { - if item.item_id.trim().is_empty() { - return Err(MemoryError::Invalid( - "source item_id must not be empty".to_string(), - )); - } - let title = if item.title.trim().is_empty() { - item.item_id.clone() - } else { - item.title.clone() - }; - let input = NamespaceDocumentInput { - namespace: namespace.clone(), - key: item.item_id, - title, - content: item.content, - source_type: source_kind.to_string(), - priority: "medium".to_string(), - tags: item.tags, - metadata: serde_json::json!({ - "sourceId": source_id, - "sourceKind": source_kind, - "url": item.url, - "mime": item.mime, - "updatedAtMs": item.updated_at_ms, - }), - category: "core".to_string(), - session_id: None, - document_id: None, - taint, - }; - let input = Self::cross(&input, "convert source document")?; - match self.client.put_doc(input).await { - Ok(id) => { - outcome.written = outcome.written.saturating_add(1); - outcome.ids.push(id); - } - Err(_) => { - outcome.skipped = outcome.skipped.saturating_add(1); - } - } - } - Ok(outcome) - } - - async fn forget_source(&self, source_id: &str) -> Result { - let namespace = format!("source:{source_id}"); - let listed = self - .client - .list_documents(Some(&namespace)) - .await - .map_err(|error| Self::other("list source documents", error))?; - let documents = listed - .get("documents") - .and_then(serde_json::Value::as_array) - .map_or(0, Vec::len); - if documents > 0 { - self.client - .clear_namespace(&namespace) - .await - .map_err(|error| Self::other("clear source documents", error))?; - } - let source_id = source_id.to_string(); - let chunks = blocking(self.config.clone(), "clear source chunks", move |config| { - use tinymemory_core::store::chunks::{ - delete_chunks_by_source, delete_orphaned_source_tree, SourceKind, - }; - let removed = delete_chunks_by_source(config, SourceKind::Document, &source_id)?; - delete_orphaned_source_tree(config, SourceKind::Document, &source_id)?; - Ok(removed) - }) - .await?; - Ok(u64::try_from(documents.saturating_add(chunks)).unwrap_or(u64::MAX)) - } -} - -#[async_trait] -impl MemoryMaintenance for ModuleMemoryProvider { - async fn reembed(&self) -> Result { - let (examined, changed) = - blocking(self.config.clone(), "enqueue re-embedding", move |config| { - let total = tinymemory_core::queue::count_total(config).unwrap_or(0); - let before = tinymemory_core::queue::count_by_status( - config, - tinymemory_core::queue::JobStatus::Ready, - ) - .unwrap_or(0); - tinymemory_core::queue::ensure_reembed_backfill(config); - let after = tinymemory_core::queue::count_by_status( - config, - tinymemory_core::queue::JobStatus::Ready, - ) - .unwrap_or(0); - Ok((total, after.saturating_sub(before))) - }) - .await?; - Ok(MaintenanceReport { - operation: "reembed".to_string(), - examined, - changed, - findings: vec![format!("enqueued {changed} re-embedding job(s)")], - }) - } - - async fn compact(&self) -> Result { - let (examined, changed) = - blocking(self.config.clone(), "compact memory queue", move |config| { - Ok(( - tinymemory_core::queue::count_total(config).unwrap_or(0), - u64::try_from(tinymemory_core::queue::recover_stale_locks(config).unwrap_or(0)) - .unwrap_or(u64::MAX), - )) - }) - .await?; - Ok(MaintenanceReport { - operation: "compact".to_string(), - examined, - changed, - findings: vec![format!("released {changed} stale queue lock(s)")], - }) - } - - async fn consolidate(&self) -> Result { - let (examined, enqueued) = blocking( - self.config.clone(), - "enqueue consolidation", - move |config| { - Ok(( - tinymemory_core::queue::count_total(config).unwrap_or(0), - tinymemory_core::queue::scheduler::enqueue_flush_stale_job(config) - .map_err(anyhow::Error::msg)?, - )) - }, - ) - .await?; - Ok(MaintenanceReport { - operation: "consolidate".to_string(), - examined, - changed: u64::from(enqueued), - findings: vec![if enqueued { - "enqueued a stale-buffer flush".to_string() - } else { - "a stale-buffer flush is already queued".to_string() - }], - }) - } - - async fn doctor(&self) -> Result { - let report = tinymemory_core::tree::health::async_run_doctor(&self.config).await; - Ok(MaintenanceReport { - operation: "doctor".to_string(), - examined: report.counters.total_chunks, - changed: 0, - findings: report - .stages - .into_iter() - .filter(|stage| !stage.ok) - .map(|stage| format!("{}: {}", stage.stage, stage.note)) - .collect(), - }) - } -} - -#[async_trait] -impl MemoryProvider for ModuleMemoryProvider { - fn driver_id(&self) -> &str { - &self.driver_id - } - fn capabilities(&self) -> Capabilities { - Capabilities::all() - } - async fn health(&self) -> MemoryHealth { - if self.client.memory_handle().health_check().await { - MemoryHealth::Ready - } else { - MemoryHealth::down("memory store is unavailable") - } - } - fn as_documents(&self) -> Option<&dyn MemoryDocuments> { - Some(self) - } - fn as_ingest(&self) -> Option<&dyn MemoryIngest> { - Some(self) - } - fn as_graph(&self) -> Option<&dyn MemoryGraph> { - Some(self) - } - fn as_goals(&self) -> Option<&dyn MemoryGoals> { - Some(self) - } - fn as_tool_memory(&self) -> Option<&dyn MemoryToolMemory> { - Some(self) - } - fn as_tree(&self) -> Option<&dyn MemoryTree> { - Some(self) - } - fn as_entities(&self) -> Option<&dyn MemoryEntities> { - Some(self) - } - fn as_diff(&self) -> Option<&dyn MemoryDiff> { - Some(self) - } - fn as_sources(&self) -> Option<&dyn MemorySourceSink> { - Some(self) - } - fn as_maintenance(&self) -> Option<&dyn MemoryMaintenance> { - Some(self) - } - fn as_people(&self) -> Option<&dyn MemoryPeople> { - Some(self) - } - fn as_chunks(&self) -> Option<&dyn MemoryChunks> { - Some(self) - } - fn as_retrieval(&self) -> Option<&dyn MemoryRetrieval> { - Some(self) - } - fn as_profile(&self) -> Option<&dyn MemoryProfile> { - Some(self) - } - fn as_episodic(&self) -> Option<&dyn MemoryEpisodic> { - Some(self) - } -} - -// ── People ─────────────────────────────────────────────────────────────────── -// -// The conversions below destructure both sides exhaustively rather than -// round-tripping through `Self::cross`. That is deliberate. `cross` is a serde -// value round-trip, so it agrees only while the two crates' field *names* agree -// — and they already do not: the engine's `Interaction` names its timestamp -// `ts` where the contract names it `at`. A round-trip would compile and then -// fail at runtime on the first call. -// -// Destructuring makes the opposite trade: a field added or renamed on either -// side is a compile error here, which is the same rule -// `tinymemory-tinycortex::convert` follows and the same reasoning that governs -// the two copies of the contract itself. - -/// The engine's people store for this module's workspace. -/// -/// `for_workspace` caches per workspace directory, so this is a map lookup -/// after the first call rather than a database open. -fn people_store( - workspace: &std::path::Path, -) -> Result, MemoryError> { - tinycortex::memory::people::store::for_workspace(workspace) - .map_err(|error| MemoryError::Other(anyhow::anyhow!("open people store: {error}"))) -} - -fn handle_to_engine(handle: &PersonHandle) -> tinycortex::memory::people::types::Handle { - use tinycortex::memory::people::types::Handle as EngineHandle; - match handle { - PersonHandle::IMessage(value) => EngineHandle::IMessage(value.clone()), - PersonHandle::Email(value) => EngineHandle::Email(value.clone()), - PersonHandle::DisplayName(value) => EngineHandle::DisplayName(value.clone()), - } -} - -fn handle_to_contract(handle: tinycortex::memory::people::types::Handle) -> PersonHandle { - use tinycortex::memory::people::types::Handle as EngineHandle; - match handle { - EngineHandle::IMessage(value) => PersonHandle::IMessage(value), - EngineHandle::Email(value) => PersonHandle::Email(value), - EngineHandle::DisplayName(value) => PersonHandle::DisplayName(value), - } -} - -fn person_to_contract(person: tinycortex::memory::people::types::Person) -> PersonRecord { - let tinycortex::memory::people::types::Person { - id, - display_name, - primary_email, - primary_phone, - handles, - created_at, - updated_at, - } = person; - PersonRecord { - id: id.to_string(), - display_name, - primary_email, - primary_phone, - handles: handles.into_iter().map(handle_to_contract).collect(), - created_at: created_at.to_rfc3339(), - updated_at: updated_at.to_rfc3339(), - } -} - -fn score_to_contract( - score: tinycortex::memory::people::types::ScoreComponents, - interaction_count: usize, -) -> PersonScore { - let tinycortex::memory::people::types::ScoreComponents { - recency, - frequency, - reciprocity, - depth, - score, - } = score; - PersonScore { - recency, - frequency, - reciprocity, - depth, - score, - interaction_count, - } -} - -/// Parse a caller-supplied person id. -/// -/// `PersonRef` is opaque to the caller by contract, so an unparseable one is a -/// caller mistake — `Invalid`, not `NotFound`. Reporting `NotFound` would tell -/// a caller the id was well-formed but absent, which would send them looking -/// for a deleted person rather than at the id they built. -fn parse_person_id( - person_id: &str, -) -> Result { - person_id - .parse::() - .map(tinycortex::memory::people::types::PersonId) - .map_err(|_| MemoryError::Invalid(format!("malformed person id: {person_id}"))) -} - -#[async_trait] -impl MemoryPeople for ModuleMemoryProvider { - async fn list_people(&self, limit: Option) -> Result, MemoryError> { - let store = people_store(&self.config.workspace_dir)?; - let people = store - .list() - .await - .map_err(|error| Self::other("list people", error))?; - - let ids: Vec<_> = people.iter().map(|person| person.id).collect(); - let interactions = store - .batch_interactions_for(&ids) - .await - .map_err(|error| Self::other("load interactions", error))?; - - let now = Utc::now(); - let mut ranked: Vec = people - .into_iter() - .map(|person| { - let observed = interactions.get(&person.id).map_or(&[][..], Vec::as_slice); - let closeness = tinycortex::memory::people::scorer::score(observed, now); - RankedPerson { - person: person_to_contract(person), - score: score_to_contract(closeness, observed.len()), - } - }) - .collect(); - - // Descending by composite score. `total_cmp` rather than `partial_cmp`: - // a NaN from a degenerate score would make `partial_cmp` return `None`, - // and an ordering that is not total is undefined behaviour's - // well-behaved cousin — `sort_by` may panic or produce garbage order. - ranked.sort_by(|a, b| b.score.score.total_cmp(&a.score.score)); - if let Some(limit) = limit { - ranked.truncate(limit); - } - Ok(ranked) - } - - async fn get_person(&self, person_id: &str) -> Result, MemoryError> { - let store = people_store(&self.config.workspace_dir)?; - let id = parse_person_id(person_id)?; - Ok(store - .get(id) - .await - .map_err(|error| Self::other("get person", error))? - .map(person_to_contract)) - } - - async fn resolve_handle( - &self, - handle: &PersonHandle, - create_if_missing: bool, - ) -> Result, MemoryError> { - let store = people_store(&self.config.workspace_dir)?; - let resolver = tinycortex::memory::people::resolver::HandleResolver::new(&store); - let engine_handle = handle_to_engine(handle); - - if create_if_missing { - let (id, created) = resolver - .resolve_or_create_with_status(&engine_handle) - .await - .map_err(|error| Self::other("resolve or create handle", error))?; - return Ok(Some(ResolvedPerson { - id: id.to_string(), - created, - })); - } - - Ok(resolver - .resolve(&engine_handle) - .await - .map_err(|error| Self::other("resolve handle", error))? - .map(|id| ResolvedPerson { - id: id.to_string(), - created: false, - })) - } - - async fn add_handle_alias( - &self, - person_id: &str, - handle: &PersonHandle, - ) -> Result<(), MemoryError> { - let store = people_store(&self.config.workspace_dir)?; - let id = parse_person_id(person_id)?; - if store - .get(id) - .await - .map_err(|error| Self::other("look up person", error))? - .is_none() - { - return Err(MemoryError::NotFound(format!("person {person_id}"))); - } - store - .add_alias(id, handle_to_engine(handle).canonicalize()) - .await - .map_err(|error| Self::other("add handle alias", error)) - } - - async fn score_person(&self, person_id: &str) -> Result, MemoryError> { - let store = people_store(&self.config.workspace_dir)?; - let id = parse_person_id(person_id)?; - if store - .get(id) - .await - .map_err(|error| Self::other("look up person", error))? - .is_none() - { - return Ok(None); - } - let interactions = store - .interactions_for(id) - .await - .map_err(|error| Self::other("load interactions", error))?; - Ok(Some(score_to_contract( - tinycortex::memory::people::scorer::score(&interactions, Utc::now()), - interactions.len(), - ))) - } - - async fn record_interaction(&self, interaction: &PersonInteraction) -> Result<(), MemoryError> { - let store = people_store(&self.config.workspace_dir)?; - let PersonInteraction { - person_id, - at, - is_outbound, - length, - } = interaction; - let id = parse_person_id(person_id)?; - let ts = chrono::DateTime::parse_from_rfc3339(at) - .map_err(|error| MemoryError::Invalid(format!("malformed interaction time: {error}")))? - .with_timezone(&Utc); - if store - .get(id) - .await - .map_err(|error| Self::other("look up person", error))? - .is_none() - { - return Err(MemoryError::NotFound(format!("person {person_id}"))); - } - store - .record_interaction(tinycortex::memory::people::types::Interaction { - person_id: id, - ts, - is_outbound: *is_outbound, - length: *length, - }) - .await - .map_err(|error| Self::other("record interaction", error)) - } - - async fn seed_from_address_book(&self) -> Result { - let store = people_store(&self.config.workspace_dir)?; - let resolver = tinycortex::memory::people::resolver::HandleResolver::new(&store); - let source = tinycortex::memory::people::address_book::SystemContactsSource; - let (seeded, skipped) = resolver - .seed_from_address_book(&source) - .await - .map_err(|error| Self::other("seed from address book", error))?; - Ok(AddressBookSeedOutcome { seeded, skipped }) - } -} - -// ── Chunks and Retrieval ───────────────────────────────────────────────────── -// -// Both families take the source scope as an **argument** and never read the -// ambient one. `tinymemory_core`'s in-process entry points resolve it from a -// task-local, which the host sets on its own side of the bus — it is simply not -// present in this process. Reading it here would yield `None`, and `None` means -// *unrestricted*, so a per-profile source gate would fail open. That is why the -// `*_scoped` variants exist and why these call them. - -/// Convert a contract scope into the engine's allowlist form. -fn scope_to_engine(scope: Option<&SourceScope>) -> Option> { - scope.map(|scope| scope.allow.iter().cloned().collect()) -} - -#[async_trait] -impl MemoryChunks for ModuleMemoryProvider { - async fn list_chunks( - &self, - query: &ChunkQuery, - scope: Option<&SourceScope>, - ) -> Result, MemoryError> { - let ChunkQuery { - source_kind, - source_id, - owner, - since_ms, - until_ms, - limit, - offset, - exclude_dropped, - } = query.clone(); - let engine_query = tinymemory_core::store::chunks::ListChunksQuery { - source_kind: source_kind - .map(|kind| Self::cross(&kind, "convert source kind")) - .transpose()?, - source_id, - owner, - since_ms, - until_ms, - limit, - offset, - source_scope: scope_to_engine(scope), - exclude_dropped, - }; - let chunks = blocking(self.config.clone(), "list chunks", move |config| { - tinymemory_core::store::chunks::list_chunks(config, &engine_query) - }) - .await?; - Self::cross(&chunks, "convert chunks") - } - - async fn get_chunk(&self, chunk_id: &str) -> Result, MemoryError> { - let id = chunk_id.to_string(); - let chunk = blocking(self.config.clone(), "get chunk", move |config| { - tinymemory_core::store::chunks::get_chunk(config, &id) - }) - .await?; - match chunk { - Some(chunk) => Ok(Some(Self::cross(&chunk, "convert chunk")?)), - None => Ok(None), - } - } - - async fn chunk_detail(&self, chunk_id: &str) -> Result, MemoryError> { - let id = chunk_id.to_string(); - let detail = blocking(self.config.clone(), "chunk detail", move |config| { - let Some(chunk) = tinymemory_core::store::chunks::get_chunk(config, &id)? else { - return Ok(None); - }; - // The vault read is best-effort: a missing body is reported as - // `None` so the caller can fall back to the row's own content, - // rather than failing the whole detail view over a preview. - let body = tinymemory_core::store::content::read::read_chunk_body(config, &id).ok(); - let has_embedding = - tinymemory_core::store::chunks::get_chunk_embedding(config, &id)?.is_some(); - let lifecycle_status = - tinymemory_core::store::chunks::get_chunk_lifecycle_status(config, &id)?; - let content_path = tinymemory_core::store::chunks::get_chunk_content_path(config, &id)?; - Ok(Some(( - chunk, - body, - has_embedding, - lifecycle_status, - content_path, - ))) - }) - .await?; - - let Some((chunk, body, has_embedding, lifecycle_status, content_path)) = detail else { - return Ok(None); - }; - Ok(Some(ChunkDetail { - chunk: Self::cross(&chunk, "convert chunk")?, - body, - content_path, - lifecycle_status, - has_embedding, - })) - } - - async fn storage_kinds(&self) -> Result, MemoryError> { - Ok(tinymemory_core::store::MemoryKind::ALL - .iter() - .map(|kind| kind.as_str().to_string()) - .collect()) - } - - async fn chunk_embeddings( - &self, - chunk_ids: &[String], - model_signature: &str, - ) -> Result, MemoryError> { - let ids = chunk_ids.to_vec(); - let signature = model_signature.to_string(); - let vectors = blocking( - self.config.clone(), - "load chunk embeddings", - move |config| { - tinymemory_core::store::chunks::get_chunk_embeddings_for_signature_batch( - config, &ids, &signature, - ) - }, - ) - .await?; - // Sorted so the response is deterministic: the engine returns a - // `HashMap`, whose iteration order varies per process and would make an - // otherwise-identical call return a differently-ordered list. - let mut embeddings: Vec = vectors - .into_iter() - .map(|(chunk_id, vector)| ChunkEmbedding { chunk_id, vector }) - .collect(); - embeddings.sort_by(|a, b| a.chunk_id.cmp(&b.chunk_id)); - Ok(embeddings) - } -} - -#[async_trait] -impl MemoryRetrieval for ModuleMemoryProvider { - async fn fast_retrieve( - &self, - query: &str, - options: FastRetrieveQuery, - scope: Option<&SourceScope>, - ) -> Result { - if query.trim().is_empty() { - return Err(MemoryError::Invalid("query must not be empty".to_string())); - } - let engine_options = tinymemory_core::tree::retrieval::FastRetrieveOptions { - limit: options.limit, - max_hops: options.max_hops, - time_window_days: options.time_window_days, - }; - let response = tinymemory_core::tree::retrieval::fast_retrieve_scoped( - &self.config, - query, - engine_options, - scope_to_engine(scope), - ) - .await - .map_err(|error| Self::other("fast retrieve", error))?; - Self::cross(&response, "convert retrieval response") - } - - async fn cover_window( - &self, - window: &CoverWindowQuery, - scope: Option<&SourceScope>, - ) -> Result { - let CoverWindowQuery { - since_ms, - until_ms, - source_id, - source_kind, - limit, - } = window.clone(); - let engine_kind = source_kind - .map(|kind| Self::cross(&kind, "convert source kind")) - .transpose()?; - let response = tinymemory_core::tree::retrieval::cover_window_scoped( - &self.config, - since_ms, - until_ms, - source_id.as_deref(), - engine_kind, - // 0 is the engine's "no caller preference" sentinel, not a request - // for zero rows: `cover_window_scoped` substitutes its own - // DEFAULT_LIMIT for it. Mapping `None` to 0 therefore asks for the - // default, which is what an absent limit means. - limit.unwrap_or(0), - scope_to_engine(scope), - ) - .await - .map_err(|error| Self::other("cover window", error))?; - Self::cross(&response, "convert retrieval response") - } - - async fn retrieve_source( - &self, - query: &SourceRetrievalQuery, - scope: Option<&SourceScope>, - ) -> Result { - let SourceRetrievalQuery { - source_id, - source_kind, - time_window_days, - query: text, - limit, - } = query.clone(); - let engine_kind = source_kind - .map(|kind| Self::cross(&kind, "convert source kind")) - .transpose()?; - let response = tinymemory_core::tree::retrieval::source::query_source_scoped( - &self.config, - tinymemory_core::tree::retrieval::source::SourceQuery { - source_id: source_id.as_deref(), - source_kind: engine_kind, - time_window_days, - query: text.as_deref(), - limit, - }, - scope_to_engine(scope), - ) - .await - .map_err(|error| Self::other("retrieve source", error))?; - Self::cross(&response, "convert retrieval response") - } - - async fn retrieve_children( - &self, - node_id: &str, - max_depth: u32, - query: Option<&str>, - limit: Option, - scope: Option<&SourceScope>, - ) -> Result, MemoryError> { - let hits = tinymemory_core::tree::retrieval::drill_down::drill_down_scoped( - &self.config, - node_id, - max_depth, - query, - limit, - scope_to_engine(scope), - ) - .await - .map_err(|error| Self::other("drill down", error))?; - Self::cross(&hits, "convert retrieval hits") - } - - async fn retrieve_leaves( - &self, - chunk_ids: &[String], - scope: Option<&SourceScope>, - ) -> Result, MemoryError> { - let hits = tinymemory_core::tree::retrieval::fetch::fetch_leaves_scoped( - &self.config, - chunk_ids, - scope_to_engine(scope), - ) - .await - .map_err(|error| Self::other("fetch leaves", error))?; - Self::cross(&hits, "convert retrieval hits") - } - - async fn recall_namespace_scored( - &self, - namespace: &str, - query: &str, - limit: usize, - exclude_session_id: Option<&str>, - ) -> Result, MemoryError> { - let hits = self - .client - .unified_handle() - .query_namespace_hits_excluding_session( - namespace, - query, - u32::try_from(limit).unwrap_or(u32::MAX), - exclude_session_id, - ) - .await - .map_err(|error| Self::other("recall namespace scored", error))?; - Self::cross(&hits, "convert namespace hits") - } - - async fn search_entities( - &self, - query: &str, - kinds: Option<&[String]>, - limit: usize, - ) -> Result, MemoryError> { - // Request kinds are validated, unlike response kinds which pass through - // as an open vocabulary. An unknown filter that silently matched nothing - // would be indistinguishable from a genuine empty result. - let engine_kinds = match kinds { - Some(kinds) => Some( - kinds - .iter() - .map(|kind| { - tinymemory_core::tree::score::extract::EntityKind::parse(kind).map_err( - |_| MemoryError::Invalid(format!("unknown entity kind: {kind}")), - ) - }) - .collect::, MemoryError>>()?, - ), - None => None, - }; - let matches = tinymemory_core::tree::retrieval::search_entities( - &self.config, - query, - engine_kinds, - limit, - ) - .await - .map_err(|error| Self::other("search entities", error))?; - Self::cross(&matches, "convert entity matches") - } -} - -// ── Profile ────────────────────────────────────────────────────────────────── -// -// `ProfileStore`'s methods are synchronous and hold a `parking_lot::Mutex` -// across a SQLite call, so each one goes through `spawn_blocking` rather than -// being awaited on the runtime thread. The store is cheap to obtain — it is a -// handle over the client's connection, not an open — so it is fetched inside -// the blocking closure rather than held across an await. - -fn facet_type_to_engine( - facet_type: FacetType, -) -> tinymemory_core::store::namespace_store::profile::FacetType { - use tinymemory_core::store::namespace_store::profile::FacetType as Engine; - match facet_type { - FacetType::Preference => Engine::Preference, - FacetType::Workflow => Engine::Workflow, - FacetType::Role => Engine::Role, - FacetType::Personality => Engine::Personality, - FacetType::Context => Engine::Context, - } -} - -#[async_trait] -impl MemoryProfile for ModuleMemoryProvider { - async fn list_active_facets(&self) -> Result, MemoryError> { - let client = Arc::clone(&self.client); - let facets = tokio::task::spawn_blocking(move || client.profile_store().list_active()) - .await - .map_err(|e| Self::other("join list_active_facets", e))? - .map_err(|e| Self::other("list_active_facets", e))?; - Self::cross(&facets, "convert facets") - } - - async fn list_all_facets(&self) -> Result, MemoryError> { - let client = Arc::clone(&self.client); - let facets = tokio::task::spawn_blocking(move || client.profile_store().list_all()) - .await - .map_err(|e| Self::other("join list_all_facets", e))? - .map_err(|e| Self::other("list_all_facets", e))?; - Self::cross(&facets, "convert facets") - } - - async fn get_facet(&self, key: &str) -> Result, MemoryError> { - let client = Arc::clone(&self.client); - let key = key.to_string(); - let facet = tokio::task::spawn_blocking(move || client.profile_store().get(&key)) - .await - .map_err(|e| Self::other("join get_facet", e))? - .map_err(|e| Self::other("get_facet", e))?; - match facet { - Some(facet) => Ok(Some(Self::cross(&facet, "convert facet")?)), - None => Ok(None), - } - } - - async fn facets_by_type( - &self, - facet_type: FacetType, - ) -> Result, MemoryError> { - let client = Arc::clone(&self.client); - let engine = facet_type_to_engine(facet_type); - let facets = - tokio::task::spawn_blocking(move || client.profile_store().facets_by_type(&engine)) - .await - .map_err(|e| Self::other("join facets_by_type", e))? - .map_err(|e| Self::other("facets_by_type", e))?; - Self::cross(&facets, "convert facets") - } - - async fn upsert_facet(&self, facet: &ProfileFacet) -> Result<(), MemoryError> { - let client = Arc::clone(&self.client); - let engine: tinymemory_core::store::namespace_store::profile::ProfileFacet = - Self::cross(facet, "convert facet")?; - tokio::task::spawn_blocking(move || client.profile_store().upsert_full(&engine)) - .await - .map_err(|e| Self::other("join upsert_facet", e))? - .map_err(|e| Self::other("upsert_facet", e)) - } - - async fn upsert_provider_facet( - &self, - facet_id: &str, - facet_type: FacetType, - key: &str, - value: &str, - confidence: f64, - segment_id: Option<&str>, - observed_at: f64, - ) -> Result<(), MemoryError> { - let client = Arc::clone(&self.client); - let engine = facet_type_to_engine(facet_type); - let (facet_id, key, value) = (facet_id.to_string(), key.to_string(), value.to_string()); - let segment_id = segment_id.map(str::to_string); - tokio::task::spawn_blocking(move || { - client.profile_store().upsert_provider_facet( - &facet_id, - &engine, - &key, - &value, - confidence, - segment_id.as_deref(), - observed_at, - ) - }) - .await - .map_err(|e| Self::other("join upsert_provider_facet", e))? - .map_err(|e| Self::other("upsert_provider_facet", e)) - } - - async fn set_facet_user_state( - &self, - key: &str, - user_state: UserState, - ) -> Result { - use tinymemory_core::store::namespace_store::profile::UserState as Engine; - let client = Arc::clone(&self.client); - let key = key.to_string(); - let engine = match user_state { - UserState::Auto => Engine::Auto, - UserState::Pinned => Engine::Pinned, - UserState::Forgotten => Engine::Forgotten, - }; - tokio::task::spawn_blocking(move || client.profile_store().set_user_state(&key, engine)) - .await - .map_err(|e| Self::other("join set_facet_user_state", e))? - .map_err(|e| Self::other("set_facet_user_state", e)) - } - - async fn delete_facet(&self, key: &str) -> Result { - let client = Arc::clone(&self.client); - let key = key.to_string(); - tokio::task::spawn_blocking(move || client.profile_store().delete(&key)) - .await - .map_err(|e| Self::other("join delete_facet", e))? - .map_err(|e| Self::other("delete_facet", e)) - } - - async fn delete_facet_by_id(&self, facet_id: &str) -> Result { - let client = Arc::clone(&self.client); - let facet_id = facet_id.to_string(); - tokio::task::spawn_blocking(move || client.profile_store().delete_by_facet_id(&facet_id)) - .await - .map_err(|e| Self::other("join delete_facet_by_id", e))? - .map_err(|e| Self::other("delete_facet_by_id", e)) - } - - async fn drop_facets_below(&self, threshold: f64) -> Result { - let client = Arc::clone(&self.client); - tokio::task::spawn_blocking(move || client.profile_store().drop_below_threshold(threshold)) - .await - .map_err(|e| Self::other("join drop_facets_below", e))? - .map_err(|e| Self::other("drop_facets_below", e)) - } - - async fn workflow_identity_matches(&self, key_pattern: &str, canonical_value: &str) -> bool { - let client = Arc::clone(&self.client); - let (pattern, value) = (key_pattern.to_string(), canonical_value.to_string()); - tokio::task::spawn_blocking(move || { - client - .profile_store() - .skill_identity_matches(&pattern, &value) - }) - .await - // A join failure reads as "no", like every other error on this - // predicate — see the trait docs. But it is logged first: the two - // cases behind it are a cancelled task and a panic inside - // `skill_identity_matches`, and a panic is a defect. Answering a bare - // `false` would make that defect look exactly like a legitimate - // non-match, which is the one reading that guarantees nobody - // investigates it. - .inspect_err(|error| { - log::error!( - "[tinymemory:module] workflow_identity_matches join failed, answering false: \ - {error}" - ); - }) - .unwrap_or(false) - } -} - -/// Episodic capture: the turn-by-turn record and its segment lifecycle. -/// -/// Every method hops to `spawn_blocking` for the same reason the profile family -/// does — these are synchronous `rusqlite` calls behind a `parking_lot::Mutex`, -/// and blocking a tinybus executor thread on a database lock would stall every -/// other call the module is serving. -/// -/// The boundary-detection and summary-composition halves of the archivist are -/// **not** here: they touch no database and are host policy. See the family's -/// contract docs. -#[async_trait] -impl MemoryEpisodic for ModuleMemoryProvider { - async fn insert_turn(&self, turn: &EpisodicTurn) -> Result { - let conn = self.client.profile_conn(); - let entry = tinymemory_core::store::fts5::EpisodicEntry { - id: None, - session_id: turn.session_id.clone(), - timestamp: turn.timestamp, - role: turn.role.clone(), - content: turn.content.clone(), - lesson: turn.lesson.clone(), - tool_calls_json: turn.tool_calls_json.clone(), - // The contract carries this signed because a cost is a plain number - // on the wire; the engine column is unsigned. A negative value is - // not meaningful, so it clamps rather than wrapping. - cost_microdollars: u64::try_from(turn.cost_microdollars).unwrap_or(0), - }; - tokio::task::spawn_blocking(move || { - tinymemory_core::store::fts5::episodic_insert(&conn, &entry) - }) - .await - .map_err(|e| Self::other("join insert_turn", e))? - .map_err(|e| Self::other("insert_turn", e)) - } - - async fn session_turns(&self, session_id: &str) -> Result, MemoryError> { - let conn = self.client.profile_conn(); - let session_id = session_id.to_string(); - let entries = tokio::task::spawn_blocking(move || { - tinymemory_core::store::fts5::episodic_session_entries(&conn, &session_id) - }) - .await - .map_err(|e| Self::other("join session_turns", e))? - .map_err(|e| Self::other("session_turns", e))?; - Ok(entries.into_iter().map(episodic_to_contract).collect()) - } - - async fn open_segment( - &self, - session_id: &str, - ) -> Result, MemoryError> { - let conn = self.client.profile_conn(); - let session_id = session_id.to_string(); - let segment = tokio::task::spawn_blocking(move || { - tinymemory_core::store::segments::open_segment_for_session(&conn, &session_id) - }) - .await - .map_err(|e| Self::other("join open_segment", e))? - .map_err(|e| Self::other("open_segment", e))?; - Ok(segment.map(segment_to_contract)) - } - - async fn create_segment( - &self, - segment_id: &str, - session_id: &str, - namespace: &str, - start_episodic_id: i64, - start_timestamp: f64, - now: f64, - ) -> Result<(), MemoryError> { - let conn = self.client.profile_conn(); - let (segment_id, session_id, namespace) = ( - segment_id.to_string(), - session_id.to_string(), - namespace.to_string(), - ); - tokio::task::spawn_blocking(move || { - tinymemory_core::store::segments::segment_create( - &conn, - &segment_id, - &session_id, - &namespace, - start_episodic_id, - // Per-session seq numbering is the archivist store's, and it is - // not part of this contract; legacy rows carry `None` too. - None, - start_timestamp, - now, - ) - }) - .await - .map_err(|e| Self::other("join create_segment", e))? - .map_err(|e| Self::other("create_segment", e)) - } - - async fn append_turn( - &self, - segment_id: &str, - episodic_id: i64, - timestamp: f64, - now: f64, - ) -> Result<(), MemoryError> { - let conn = self.client.profile_conn(); - let segment_id = segment_id.to_string(); - tokio::task::spawn_blocking(move || { - tinymemory_core::store::segments::segment_append_turn( - &conn, - &segment_id, - episodic_id, - None, - timestamp, - now, - ) - }) - .await - .map_err(|e| Self::other("join append_turn", e))? - .map_err(|e| Self::other("append_turn", e)) - } - - async fn close_segment(&self, segment_id: &str, now: f64) -> Result<(), MemoryError> { - let conn = self.client.profile_conn(); - let segment_id = segment_id.to_string(); - tokio::task::spawn_blocking(move || { - tinymemory_core::store::segments::segment_close(&conn, &segment_id, now) - }) - .await - .map_err(|e| Self::other("join close_segment", e))? - .map_err(|e| Self::other("close_segment", e)) - } - - async fn set_segment_summary( - &self, - segment_id: &str, - summary: &str, - now: f64, - ) -> Result<(), MemoryError> { - let conn = self.client.profile_conn(); - let (segment_id, summary) = (segment_id.to_string(), summary.to_string()); - tokio::task::spawn_blocking(move || { - tinymemory_core::store::segments::segment_set_summary(&conn, &segment_id, &summary, now) - }) - .await - .map_err(|e| Self::other("join set_segment_summary", e))? - .map_err(|e| Self::other("set_segment_summary", e)) - } - - async fn upsert_segment_embedding( - &self, - segment_id: &str, - model_signature: &str, - embedding: &[f32], - created_at: f64, - ) -> Result<(), MemoryError> { - let conn = self.client.profile_conn(); - let (segment_id, model_signature) = (segment_id.to_string(), model_signature.to_string()); - let embedding = embedding.to_vec(); - tokio::task::spawn_blocking(move || { - tinymemory_core::store::segments::segment_embedding_upsert( - &conn, - &segment_id, - &model_signature, - &embedding, - created_at, - ) - }) - .await - .map_err(|e| Self::other("join upsert_segment_embedding", e))? - .map_err(|e| Self::other("upsert_segment_embedding", e)) - } -} - -/// Engine episodic row -> contract turn. -fn episodic_to_contract(entry: tinymemory_core::store::fts5::EpisodicEntry) -> EpisodicTurn { - EpisodicTurn { - id: entry.id, - session_id: entry.session_id, - timestamp: entry.timestamp, - role: entry.role, - content: entry.content, - lesson: entry.lesson, - tool_calls_json: entry.tool_calls_json, - cost_microdollars: i64::try_from(entry.cost_microdollars).unwrap_or(i64::MAX), - } -} - -/// Engine segment row -> contract segment. -/// -/// Written out rather than derived: the engine row carries several fields the -/// contract deliberately does not expose (`topic_keywords`, the seq numbers, -/// `created_at`), and a blanket conversion would quietly start shipping them if -/// the contract ever grew a matching name. -fn segment_to_contract( - segment: tinymemory_core::store::segments::ConversationSegment, -) -> ConversationSegment { - use tinymemory_core::store::segments::SegmentStatus; - ConversationSegment { - segment_id: segment.segment_id, - session_id: segment.session_id, - namespace: segment.namespace, - start_episodic_id: segment.start_episodic_id, - end_episodic_id: segment.end_episodic_id, - start_timestamp: segment.start_timestamp, - end_timestamp: segment.end_timestamp, - turn_count: segment.turn_count, - summary: segment.summary, - embedding: segment.embedding, - open: matches!(segment.status, SegmentStatus::Open), - } +/// Builds the engine provider this module serves over the bus. +pub(crate) fn provider(config: &ModuleConfig, client: Arc) -> TinycortexProvider { + TinycortexProvider::new( + config.driver_id.clone(), + EngineRuntimeConfig::from(config), + client, + ) } diff --git a/crates/tinymemory-module/src/service/mod.rs b/crates/tinymemory-module/src/service/mod.rs index d959eaf..eba50ab 100644 --- a/crates/tinymemory-module/src/service/mod.rs +++ b/crates/tinymemory-module/src/service/mod.rs @@ -384,7 +384,7 @@ impl MemoryService { } })?; - let provider = crate::provider::ModuleMemoryProvider::new(&opener.config, Arc::new(client)); + let provider = crate::provider::provider(&opener.config, Arc::new(client)); opener .connection .serve_at( From 4ab4d55f5e216d346c6e8bda0aa6c445fea30e61 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 16:25:50 +0530 Subject: [PATCH 03/14] Let configuration select the memory engine, gated by the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §A5. The driver registry could answer "is this driver id real, and is it allowed to answer for memory", and nothing asked it: `DriverRegistry::admit` had no caller outside its own tests, `MemoryHostConfig::memory_provider()` had no reader, and the memory client factory constructed TinyCortex unconditionally. Configuration could not choose an engine. `DriverRegistry::select` closes that: it reads the engine from the host's configuration and puts it through `admit`, so both surfaces are now live. Two corrections to the issue, both load-bearing. §A5 names `memory_provider()` as the selector. That method is a `provider:model` routing string for the memory *workload* — which language model does summarisation and entity extraction — not the store the memory lives in. Reading it would have let a model change repoint a company's storage. Selection reads a new `memory_driver()` instead, defaulted to `None` so it breaks no existing implementation, and a test pins that the two fields stay independent. §A5 also asks that `create_memory_*` return a bound `Arc`. It cannot, and the reason is structural rather than unfinished: since §C3 `adapters/tinycortex` depends on `tinymemory-core`, so a core factory returning a constructed adapter provider is a dependency cycle. Selection therefore resolves the decision and the host constructs — which is what `src/registry`'s module docs have said all along: "It resolves the class, not the instance." A configuration naming no engine gets the reserved embedded default, so adding selection does not turn "I configured nothing" into a host that fails to start. Going through `select` does not loosen admission either: an external engine named in config is still refused without endpoint, credential and trust. Refs #18 (§A5) --- Cargo.toml | 3 ++ api/src/host/config.rs | 19 ++++++++ api/src/host/test_support.rs | 6 +++ src/registry/mod.rs | 39 ++++++++++++++++ src/registry/test.rs | 74 ++++++++++++++++++++++++++++++ tests/driver_selection.rs | 87 ++++++++++++++++++++++++++++++++---- 6 files changed, 219 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a8d1bd6..2915028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,9 @@ serde = { version = "1", features = ["derive"] } [dev-dependencies] # The mandatory-family tests are async. tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +# `TestHostConfig`, for the driver-selection tests. A dev-dependency: the +# facade must not carry a test double into a consumer's graph. +tinymemory-api = { path = "api", features = ["test-support"] } # The reference driver and the behavioural suite, for the workspace-level # integration tests. A dev-dependency only: the facade must not carry a test # harness into a consumer's dependency graph. diff --git a/api/src/host/config.rs b/api/src/host/config.rs index af3d79f..22920a0 100644 --- a/api/src/host/config.rs +++ b/api/src/host/config.rs @@ -125,6 +125,25 @@ pub trait MemoryHostConfig: Send + Sync + std::fmt::Debug { /// `provider:model` routing string for the memory workload, if pinned. fn memory_provider(&self) -> Option<&str>; + /// The memory **engine** this host selects, when its configuration names + /// one — `tinycortex`, `supermemory`, `mem0`, `cognee`, `null`. + /// + /// Deliberately distinct from [`Self::memory_provider`], which despite the + /// name is a `provider:model` routing string for the memory *workload* — + /// which language model does summarisation and entity extraction. That is a + /// different axis from which store the memory lives in, and conflating them + /// would let a model change repoint a company's storage. + /// + /// `None` means "the host's default", which the host resolves rather than + /// this trait: the driver registry admits a reserved embedded id with no + /// configuration entry precisely so an unconfigured host still binds + /// something instead of failing to start. + /// + /// Defaulted so adding it breaks no existing implementation. + fn memory_driver(&self) -> Option<&str> { + None + } + /// The local model id for a workload, when that workload is routed to /// Ollama (`"ollama:"`). `None` for cloud or unset workloads. /// diff --git a/api/src/host/test_support.rs b/api/src/host/test_support.rs index 486798f..a7e68f0 100644 --- a/api/src/host/test_support.rs +++ b/api/src/host/test_support.rs @@ -45,6 +45,8 @@ pub struct TestHostConfig { pub embeddings_provider: Option, /// See [`MemoryHostConfig::memory_provider`]. pub memory_provider: Option, + /// See [`MemoryHostConfig::memory_driver`]. `None` selects the host default. + pub memory_driver: Option, /// See [`MemoryHostConfig::api_url`]. pub api_url: Option, /// See [`MemoryHostConfig::default_model`]. @@ -113,6 +115,10 @@ impl MemoryHostConfig for TestHostConfig { self.memory_provider.as_deref() } + fn memory_driver(&self) -> Option<&str> { + self.memory_driver.as_deref() + } + fn workload_local_model(&self, workload: &str) -> Option { let raw = match workload { "memory" => self.memory_provider.as_deref(), diff --git a/src/registry/mod.rs b/src/registry/mod.rs index c41bd82..7cdad93 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -40,6 +40,8 @@ use std::collections::BTreeMap; use std::fmt; +use tinymemory_api::host::MemoryHostConfig; + mod class; pub use class::{DriverClass, DriverClassParseError}; @@ -284,6 +286,43 @@ impl DriverRegistry { Ok(admission) } + /// Selects and admits the memory driver this host's configuration names. + /// + /// The half of driver binding that was specified but never wired: the + /// registry could answer "is this driver id real and allowed", and nothing + /// asked it. This reads the id from the host's own configuration and puts + /// it through [`Self::admit`], so configuration decides the engine instead + /// of a factory hardcoding one (issue #18 §A5). + /// + /// It reads [`MemoryHostConfig::memory_driver`], **not** + /// `memory_provider` — despite the name, the latter is a `provider:model` + /// routing string choosing which language model does summarisation, which + /// is a different axis from which store the memory lives in. + /// + /// A configuration that names no driver gets [`TINYCORTEX_DRIVER_ID`], the + /// reserved embedded default. That is what keeps an unconfigured host + /// booting: an embedded id is admitted without a `drivers` entry, while an + /// external one is refused without endpoint, credential and trust + /// configuration. + /// + /// This resolves the *decision*, not the instance. Constructing the + /// provider, caching it per workspace, and wrapping it in a policy guard + /// stay with the host — see the module docs for why. + /// + /// # Errors + /// + /// Returns the [`FallbackReason`] to record and publish when the configured + /// driver is refused, exactly as [`Self::admit`] does. + pub fn select( + &self, + config: &dyn MemoryHostConfig, + entry: Option>, + labels: ConfigLabels<'_>, + ) -> Result { + let driver = config.memory_driver().unwrap_or(TINYCORTEX_DRIVER_ID); + self.admit(driver, entry, labels) + } + /// The class an id implies when nothing says otherwise. /// /// `context` names which part of the config was missing; the refusal echoes diff --git a/src/registry/test.rs b/src/registry/test.rs index 6d193e9..ab0186d 100644 --- a/src/registry/test.rs +++ b/src/registry/test.rs @@ -259,3 +259,77 @@ fn driver_class_serde_matches_the_config_spelling() { assert_eq!(json, format!("\"{}\"", class.as_str())); } } + +// ── Selection from configuration (issue #18 §A5) ───────────────────────────── +// +// Before this, the registry could answer "is this driver id real and allowed" +// and nothing asked it: `admit` had no production caller, and the memory client +// factory constructed TinyCortex unconditionally. These pin the wiring. + +use tinymemory_api::host::test_support::TestHostConfig; + +fn config_naming(driver: Option<&str>) -> TestHostConfig { + // `TestHostConfig` is `#[non_exhaustive]`, so it is built and then mutated + // rather than named field-by-field — which is what its own docs ask for. + let mut config = TestHostConfig::default(); + config.memory_driver = driver.map(str::to_owned); + config +} + +#[test] +fn a_configuration_naming_no_driver_gets_the_embedded_default() { + // The property that keeps an unconfigured host booting: a reserved embedded + // id is admitted without any `drivers` entry. + let admission = DriverRegistry::builtin() + .select(&config_naming(None), None, labels()) + .expect("an unconfigured host still binds"); + assert_eq!(admission.id, TINYCORTEX_DRIVER_ID); + assert_eq!(admission.class, DriverClass::Embedded); +} + +#[test] +fn a_configuration_naming_an_engine_selects_that_engine() { + let admission = DriverRegistry::builtin() + .select(&config_naming(Some(NULL_DRIVER_ID)), None, labels()) + .expect("the null driver is admitted without an entry"); + assert_eq!(admission.id, NULL_DRIVER_ID); + assert_eq!(admission.class, DriverClass::Null); +} + +#[test] +fn selecting_a_hosted_engine_still_requires_its_entry() { + // Selection does not loosen admission: an external driver named in config + // but left unconfigured is refused fail-closed, exactly as `admit` refuses + // it directly. + let reason = DriverRegistry::builtin() + .select(&config_naming(Some("supermemory")), None, labels()) + .expect_err("an external driver with no entry must be refused"); + assert_eq!(reason.configured_driver, "supermemory"); +} + +#[test] +fn selecting_a_hosted_engine_succeeds_once_it_is_configured_and_trusted() { + let entry = DriverEntry { + class: None, + trust_state: TRUSTED, + }; + let admission = DriverRegistry::builtin() + .select(&config_naming(Some("supermemory")), Some(entry), labels()) + .expect("a configured, trusted external driver is admitted"); + assert_eq!(admission.class, DriverClass::External); +} + +#[test] +fn selection_reads_the_engine_field_and_not_the_model_routing_one() { + // `memory_provider` is a `provider:model` routing string choosing which + // language model does summarisation. Reading it here would let a model + // change repoint a company's storage, which is why selection has its own + // field. + let mut config = TestHostConfig::default(); + config.memory_provider = Some("ollama:llama3".to_owned()); + config.memory_driver = None; + let admission = DriverRegistry::builtin() + .select(&config, None, labels()) + .expect("model routing must not affect engine selection"); + assert_eq!(admission.id, TINYCORTEX_DRIVER_ID); +} diff --git a/tests/driver_selection.rs b/tests/driver_selection.rs index d73e8cc..ea3a36d 100644 --- a/tests/driver_selection.rs +++ b/tests/driver_selection.rs @@ -3,22 +3,29 @@ //! //! Exercises only the public surface of the `tinymemory` facade. //! -//! # Scope note +//! # Selection //! -//! Issue #18 §E3 describes this file as also asserting that "the bound -//! provider's `driver_id()` matches" the configured id. That step needs -//! `MemoryHostConfig::memory_provider()` to actually select an engine, which is -//! §A5 and does not exist yet — `create_memory_client_with_local_ai` still -//! constructs TinyCortex unconditionally. The issue's own sequencing says to -//! write these tests "against the *current* behaviour first", so this file -//! pins what admission does today. The binding half joins it when §A5 lands, -//! and this file is where it goes. +//! Configuration now chooses the engine (§A5). One correction to the issue is +//! worth recording here, because it would otherwise have wired the wrong thing: +//! §A5 names `MemoryHostConfig::memory_provider()` as the selector, but that +//! method is a `provider:model` routing string for the memory *workload* — +//! which language model summarises — not the engine the memory lives in. +//! Selection reads `memory_driver()` instead, added for the purpose. +//! +//! What still does not exist is the last clause of §A5, that `create_memory_*` +//! return a bound `Arc`. It cannot, and the reason is +//! structural rather than unfinished: `adapters/tinycortex` depends on +//! `tinymemory-core` since §C3, so a core factory returning a constructed +//! adapter provider would be a dependency cycle. Selection resolves the +//! *decision*; the host constructs — which is what `src/registry`'s own module +//! docs have always said. // A failing assertion in a test *is* a panic; the crate-wide `expect_used` / // `unwrap_used` / `panic` lints exist to keep the library from panicking, not // the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +use tinymemory::api::host::test_support::TestHostConfig; use tinymemory::registry::{ ConfigLabels, DriverClass, DriverEntry, DriverRegistry, COGNEE_DRIVER_ID, MEM0_DRIVER_ID, SUPERMEMORY_DRIVER_ID, TINYCORTEX_DRIVER_ID, TRUSTED, @@ -159,3 +166,65 @@ fn a_config_class_typo_is_echoed_back_to_the_operator() { reason.reason ); } + +// ── The selection half, through the public facade ──────────────────────────── + +fn config_naming(driver: Option<&str>) -> TestHostConfig { + let mut config = TestHostConfig::default(); + config.memory_driver = driver.map(str::to_owned); + config +} + +#[test] +fn configuration_chooses_the_engine_and_admission_gates_it() { + let admission = DriverRegistry::builtin() + .select( + &config_naming(Some(COGNEE_DRIVER_ID)), + Some(trusted_external()), + labels(), + ) + .expect("a configured, trusted external engine binds"); + assert_eq!(admission.id, COGNEE_DRIVER_ID); + assert_eq!(admission.class, DriverClass::External); +} + +#[test] +fn an_unconfigured_host_still_binds_the_embedded_default() { + // The property that matters most operationally: adding engine selection + // must not turn "I configured nothing" into a host that fails to start. + let admission = DriverRegistry::builtin() + .select(&config_naming(None), None, labels()) + .expect("an unconfigured host binds the embedded default"); + assert_eq!(admission.id, TINYCORTEX_DRIVER_ID); + assert_eq!(admission.class, DriverClass::Embedded); +} + +#[test] +fn selection_does_not_loosen_the_fail_closed_external_gate() { + // Going through `select` rather than `admit` must not become a way around + // the trust requirement. + let untrusted = DriverEntry { + class: None, + trust_state: "untrusted", + }; + let reason = DriverRegistry::builtin() + .select( + &config_naming(Some(MEM0_DRIVER_ID)), + Some(untrusted), + labels(), + ) + .expect_err("an untrusted external engine is refused however it was chosen"); + assert!(reason.reason.contains(TRUSTED), "{}", reason.reason); +} + +#[test] +fn the_model_routing_field_cannot_repoint_the_store() { + // `memory_provider` chooses a language model; `memory_driver` chooses the + // store. Conflating them would let a model change move a company's memory. + let mut config = TestHostConfig::default(); + config.memory_provider = Some("ollama:llama3".to_owned()); + let admission = DriverRegistry::builtin() + .select(&config, None, labels()) + .expect("model routing leaves engine selection alone"); + assert_eq!(admission.id, TINYCORTEX_DRIVER_ID); +} From f1a3c135b51cb99b0674f5ece680eb02e0e0fe65 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 16:28:30 +0530 Subject: [PATCH 04/14] Run the adapter's feature-gated configuration in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `memory-git` feature added alongside the lifted diff family gates that family, and the test asserting it is *withheld* without the feature is `#[cfg(not(feature = "memory-git"))]`. Both existing jobs — `--all-features` and default — compile that test out, so it was checked by nothing: a feature-gated test whose default fate is to be built by one job and executed by none. Adds the configuration as its own step, and asserts the property the feature exists for: `cargo tree --no-default-features` must link no `git2` or `libgit2-sys`. A guard rather than a comment, because "this feature keeps the native build out of the graph" is a claim that silently stops being true the first time a dependency picks it up transitively. Verified: `diff_is_withheld_when_the_snapshot_store_is_compiled_out` appears in `--no-default-features -- --list` and is absent from the `--all-features` listing, which is what "running nowhere" looked like. Refs #18 (§C3, §E2) --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0548b4a..c5ebddb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,30 @@ jobs: - name: Test default features run: cargo test + # The adapter's `memory-git` feature gates the diff family, and the test + # that the family is *withheld* without it is `#[cfg(not(feature = + # "memory-git"))]`. Both the `--all-features` and default runs above + # compile that test out, so without this step it would be checked by + # nothing — a feature-gated test whose default fate is to be built by one + # job and executed by none. + # + # This is also the configuration that keeps the promise the feature + # exists for: no `git2` / `libgit2-sys` in the graph. + - name: Lint and test the adapter without its optional engine features + run: | + cargo clippy -p tinymemory-tinycortex --all-targets --no-default-features -- -D warnings + cargo test -p tinymemory-tinycortex --no-default-features + + - name: Assert the default adapter build links no native git + run: | + linked="$(cargo tree -p tinymemory-tinycortex --no-default-features \ + -e normal --prefix none | grep -cE '^(git2|libgit2-sys)' || true)" + if [ "$linked" -ne 0 ]; then + echo "the default adapter build linked $linked native-git crate(s);" >&2 + echo "the memory-git feature exists to keep them out" >&2 + exit 1 + fi + # The module crate is its own workspace root (see the `exclude` note in the # root Cargo.toml), so NONE of the steps above touch it: `--all-targets`, # `--all-features` and `--workspace` all stop at the workspace boundary and From ae495d42be3c611472caefe3ed4b5bd4b01b6e1d Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 16:57:07 +0530 Subject: [PATCH 05/14] Enforce the contract crate's dependency rule, and the minimal build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §D4, with §D3 alongside it. `api/Cargo.toml` spells out a `cargo tree` command in a comment and asks that the contract crate never link a storage engine, a native library, an HTTP client, or an async runtime. It was left as a comment, so nothing ran it. A forbidden dependency does not arrive by someone typing it into the manifest; it arrives transitively, through a feature enabled two crates away, which is exactly the way nobody notices. The forward form is the one that works, and the manifest already explains why: `cargo tree -i -p tinymemory-api` discards the `-p` scope, prints the whole-workspace inverse tree, and exits 0 looking clean even when this crate is the one at fault. This runs what the comment says to run. Verified in both directions. The rule holds today — no match against `rusqlite|libsqlite|git2|reqwest|regex|tokio`. And injecting `regex = "1"` into the manifest makes the guard fire on `regex`, `regex-automata` and `regex-syntax`, then reverting makes it pass again. A guard nobody has watched fail is not yet a guard. §D3 asks that `--no-default-features` still compile and bind `NullMemoryProvider`. It does, so this pins it rather than changing anything: the minimal configuration builds, and #21's `null_provider` integration test runs against it — so "usable" is asserted, not just "compiles". That test is the one that would catch a null driver which panics instead of answering, or reports a fault instead of `Ready`. Refs #18 (§D3, §D4) --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5ebddb..15d8ae2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,38 @@ jobs: # # This is also the configuration that keeps the promise the feature # exists for: no `git2` / `libgit2-sys` in the graph. + # `api/Cargo.toml` spells out this exact command in a comment and asks + # that the contract crate never link a storage engine, a native library, + # an HTTP client, or an async runtime. It was left as a comment, so + # nothing checked it — and a forbidden dependency arrives transitively, + # through a feature someone enabled two crates away, which is precisely + # the way nobody notices. + # + # The FORWARD form is required. `cargo tree -i -p tinymemory-api` + # discards the `-p` scope, prints the whole-workspace inverse tree, and + # exits 0 looking clean even when this crate is the one at fault. The + # manifest says so; this runs what it says. + - name: Assert the contract crate stays free of heavy dependencies + run: | + forbidden="$(cargo tree -p tinymemory-api -e normal,build --prefix none \ + | grep -Ei 'rusqlite|libsqlite|git2|reqwest|regex|tokio' || true)" + if [ -n "$forbidden" ]; then + echo "tinymemory-api pulled in a dependency its manifest forbids:" >&2 + echo "$forbidden" >&2 + echo >&2 + echo "The contract is what hosts compile against. It must stay free of" >&2 + echo "storage engines, native libraries, HTTP clients and async runtimes." >&2 + exit 1 + fi + + # The minimal build has to stay genuinely usable, not merely compile: + # a host that wants the ports wired and nothing retained must be able to + # bind the null driver without pulling an engine in behind it. + - name: Build and bind the minimal configuration + run: | + cargo build -p tinymemory --no-default-features + cargo test -p tinymemory --no-default-features --test null_provider + - name: Lint and test the adapter without its optional engine features run: | cargo clippy -p tinymemory-tinycortex --all-targets --no-default-features -- -D warnings From 21e0a5e832a23591f535cb5a94eb767362ffa894 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 17:36:28 +0530 Subject: [PATCH 06/14] Cover the hosted adapters' failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §E6: "backend error, timeout, and partial-page responses on every remote adapter — currently zero coverage". The three existing per-adapter test files all drive a backend that answers correctly, which is the half that was never in doubt. Six tests, each running against all three adapters over a real TCP socket, using the axum-double harness the happy-path tests already use: - a 500 on write is reported rather than swallowed; - a 500 on read is not laundered into `Ok(None)`; - a 401 is not presented as an empty store, on either `list` or `recall`; - a `200 OK` carrying unparseable JSON is an error and not a panic; - an unreachable backend is reported rather than hanging; - a paginated export terminates instead of looping. The read case is the one that matters. `Ok(None)` after a 500 says "this memory does not exist" when the truth is "I could not ask", and a caller cannot tell those apart: it writes the memory again, or tells a user their memory is gone, or a sync job treats the empty read as authoritative and prunes. Nothing surfaces until much later. All three adapters already behave correctly — this pins behaviour rather than fixing it. That is worth stating plainly, because six tests passing on the first run is exactly what a test that never exercises its subject also looks like. So the read assertion additionally requires the backend's status to survive into the error message; without that it would pass just as happily if the adapter had failed on URL construction and never reached the network. Confirmed against the live errors: `memory API v3/container-tags/list returned HTTP 500`, `memory API memories?top_k=1000 returned HTTP 500`, and `memory API api/v1/datasets returned HTTP 500`. The assertions are deliberately about whether a failure comes back at all, not about which error it is. The adapters' HTTP layer `bail!`s into `anyhow`, so every one of these arrives as `MemoryError::Other` and "unsupported" is not yet distinguishable from "failed" — that is §A4, and it is not what this change is. The unreachable-backend test binds a port, reads its number, and drops the listener, so the address is reliably closed rather than merely unlikely to be in use. The export test is bounded by a timeout so a non-terminating implementation fails the test instead of hanging the suite. Refs #18 (§E6) --- adapters/remote/src/failure_test.rs | 249 ++++++++++++++++++++++++++++ adapters/remote/src/lib.rs | 3 + 2 files changed, 252 insertions(+) create mode 100644 adapters/remote/src/failure_test.rs diff --git a/adapters/remote/src/failure_test.rs b/adapters/remote/src/failure_test.rs new file mode 100644 index 0000000..b6631b7 --- /dev/null +++ b/adapters/remote/src/failure_test.rs @@ -0,0 +1,249 @@ +//! What the hosted adapters do when the backend does not cooperate. +//! +//! Issue #18 §E6: "backend error, timeout, and partial-page responses on every +//! remote adapter — currently zero coverage". The existing per-adapter tests all +//! drive a backend that answers correctly, which is the half that was never in +//! doubt. +//! +//! The assertion that matters is not which error comes back — the contract's +//! error type is still `anyhow` under `MemoryError::Other` here, and §A4 is what +//! makes "unsupported" distinguishable from "failed". It is that a failure comes +//! back **at all**. +//! +//! A read that answers `Ok(None)` when the backend returned 500 is saying "this +//! memory does not exist" when the truth is "I could not ask". A caller cannot +//! tell those apart, so it writes the memory again, or reports to a user that +//! their memory is gone, or — worst — a sync job treats the empty read as +//! authoritative and prunes. Nothing surfaces until much later, which is exactly +//! the failure mode that keeps `OPENCOMPANY_MEMORY=remote` gated downstream. +//! +//! Each test drives a real adapter over a real TCP socket against a double that +//! misbehaves in one specific way, matching the harness the happy-path tests +//! already use. + +#![allow(clippy::expect_used, clippy::panic)] + +use axum::http::StatusCode; +use axum::routing::{any, get}; +use axum::Router; +use tinymemory_api::provider::MemoryProvider; +use tinymemory_api::recall::RecallOpts; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::{MemoryCategory, MemoryTaint}; + +use crate::{CogneeMemory, Mem0Memory, SupermemoryMemory}; + +/// Serves `app` on an ephemeral port and returns its base URL. +async fn serve(app: Router) -> String { + 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"); + }); + endpoint +} + +/// A backend that fails every route with `status`. +async fn failing(status: StatusCode) -> String { + serve(Router::new().fallback(any(move || async move { status }))).await +} + +/// A backend that answers every route with `200 OK` and a body that is not the +/// JSON the adapter expects. +/// +/// Distinct from an HTTP failure: the transport succeeded, so an adapter that +/// only checks the status code reaches its deserializer with rubbish. +async fn malformed() -> String { + serve(Router::new().fallback(any(|| async { "this is not the JSON you asked for" }))).await +} + +/// Every adapter, as a `Memory`, built against `endpoint`. +/// +/// Boxed rather than generic so each assertion below is written once and run +/// three times — the point is that no adapter is exempt. +fn adapters(endpoint: &str) -> Vec<(&'static str, Box)> { + vec![ + ( + "supermemory", + Box::new(SupermemoryMemory::new(endpoint, None).expect("client")) as Box, + ), + ( + "mem0", + Box::new(Mem0Memory::new(endpoint, None).expect("client")), + ), + ( + "cognee", + Box::new(CogneeMemory::self_hosted(endpoint, None).expect("client")), + ), + ] +} + +#[tokio::test] +async fn a_backend_failure_on_write_is_reported_rather_than_swallowed() { + let endpoint = failing(StatusCode::INTERNAL_SERVER_ERROR).await; + for (name, memory) in adapters(&endpoint) { + let result = memory + .store_with_taint( + "ns", + "k", + "content", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await; + assert!( + result.is_err(), + "{name}: a 500 on write must not report success — a caller that \ + believes the write landed has no reason to retry it" + ); + } +} + +#[tokio::test] +async fn a_backend_failure_on_read_is_not_reported_as_absence() { + // The one that matters most. `Ok(None)` here means "no such memory", and + // the truth is "the backend is down". + let endpoint = failing(StatusCode::INTERNAL_SERVER_ERROR).await; + for (name, memory) in adapters(&endpoint) { + let result = memory.get("ns", "k").await; + let Err(error) = result else { + panic!( + "{name}: a 500 on read must not be laundered into `Ok(None)` — \ + 'I could not ask' and 'it is not there' are different answers" + ); + }; + // Assert the failure is the *backend's*, not something incidental like a + // malformed URL. Without this the test would pass for the wrong reason + // if the adapter never reached the network at all. + let rendered = format!("{error:#}"); + assert!( + rendered.contains("500"), + "{name}: expected the backend status to survive into the error, got: {rendered}" + ); + } +} + +#[tokio::test] +async fn an_unauthorized_backend_is_not_reported_as_an_empty_store() { + // A wrong or expired credential is the most likely failure in production, + // and the most dangerous one to render as "you have no memories". + let endpoint = failing(StatusCode::UNAUTHORIZED).await; + for (name, memory) in adapters(&endpoint) { + let listed = memory.list(None, None, None).await; + assert!( + listed.is_err(), + "{name}: a 401 must not present as an empty result set" + ); + + let recalled = memory.recall("anything", 10, RecallOpts::default()).await; + assert!( + recalled.is_err(), + "{name}: a 401 on recall must not present as no matches" + ); + } +} + +#[tokio::test] +async fn a_malformed_backend_response_is_an_error_and_not_a_panic() { + // `200 OK` with a body the adapter cannot parse. An adapter that unwraps + // its way through deserialization takes the caller's process down. + let endpoint = malformed().await; + for (name, memory) in adapters(&endpoint) { + let result = memory.get("ns", "k").await; + assert!( + result.is_err(), + "{name}: an unparseable 200 body must surface as an error" + ); + } +} + +#[tokio::test] +async fn an_unreachable_backend_is_reported_rather_than_hanging() { + // Nothing is listening. This is the timeout/connection-refused leg of §E6. + // Bind a port, learn its number, drop the listener: the address is now + // reliably closed rather than merely unlikely to be in use. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + drop(listener); + + for (name, memory) in adapters(&endpoint) { + let result = memory.get("ns", "k").await; + assert!( + result.is_err(), + "{name}: an unreachable backend must surface as an error" + ); + } +} + +#[tokio::test] +async fn a_paginated_export_terminates_instead_of_looping() { + // The partial-page leg of §E6. A backend that keeps answering with a page + // and a cursor would spin an exporter forever; the contract terminates on + // `next_cursor: None`, and a driver that never emits one never finishes. + // + // Driven through the bound provider rather than the raw `Memory`: + // `export_page` is a `MemoryPortability` method, and portability is a + // mandatory supertrait of `MemoryProvider`, so it is always callable — which + // is exactly why a non-terminating one is worth pinning. + let app = Router::new().fallback(get(|| async { + axum::Json(serde_json::json!({ + "memoryEntries": [], + "results": [], + "data": [], + "pagination": {"totalPages": 1} + })) + })); + let endpoint = serve(app).await; + + let providers: Vec<(&str, Box)> = vec![ + ( + "supermemory", + Box::new(crate::supermemory_provider( + SupermemoryMemory::new(&endpoint, None).expect("client"), + )) as Box, + ), + ( + "mem0", + Box::new(crate::mem0_provider( + Mem0Memory::new(&endpoint, None).expect("client"), + )), + ), + ( + "cognee", + Box::new(crate::cognee_provider( + CogneeMemory::self_hosted(&endpoint, None).expect("client"), + )), + ), + ]; + + for (name, provider) in providers { + // Bounded so a non-terminating implementation fails rather than hanging + // the whole suite. + let finished = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let mut cursor: Option = None; + for page_number in 0..100usize { + let Ok(page) = provider.export_page(cursor.as_deref(), 100).await else { + // An error is an acceptable answer here; a hang is not. + return true; + }; + match page.next_cursor { + None => return true, + Some(next) => cursor = Some(next), + } + let _ = page_number; + } + false + }) + .await; + assert_eq!( + finished, + Ok(true), + "{name}: export_page never terminated — an exporter would spin here" + ); + } +} diff --git a/adapters/remote/src/lib.rs b/adapters/remote/src/lib.rs index d649e5e..dba72ed 100644 --- a/adapters/remote/src/lib.rs +++ b/adapters/remote/src/lib.rs @@ -38,3 +38,6 @@ pub fn mem0_provider(memory: Mem0Memory) -> MemoryTraitProvider { pub fn cognee_provider(memory: CogneeMemory) -> MemoryTraitProvider { MemoryTraitProvider::new(Arc::new(memory), COGNEE_DRIVER_ID) } + +#[cfg(test)] +mod failure_test; From 7128412813f476ed1332be031ced6de8cd4426a7 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 17:42:43 +0530 Subject: [PATCH 07/14] Restore the bundled example, and make a refusal an Error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §E7. `AGENTS.md` documents an `examples/` directory and tells the reader to run `cargo run --example basic`. Neither existed: no `examples/` directory at all, so the documented command has been failing for as long as it has been documented. (The issue attributes the reference to `README.md`; it is `AGENTS.md` lines 30 and 74.) The example binds the null driver, which needs no engine, no workspace and no network, so it runs anywhere. It walks the order a host actually follows — admit an id, then construct, then check the negotiated capabilities, then use the mandatory families — because that order is the part worth demonstrating. Admission is engine-neutral and answers "is this id real and may it answer for memory"; construction needs everything an engine needs. Swapping `NullMemoryProvider` for an adapter's provider changes nothing else in the file. Writing it surfaced an API gap, which is the argument for having a compiled example at all: `FallbackReason` implemented `Display` but not `std::error::Error`, so the obvious `registry.admit(..)?` in a function returning `Box` or `anyhow::Error` did not compile. Adding the impl is purely additive and changes nothing about the type; it makes the message usable where refusals actually travel. CI runs the example rather than only building it. `cargo build --all-targets` compiles examples, so a broken one still passes — and a compiled example can panic on its first line. Running it is what makes the documented command a promise rather than a comment. Not attempted here: §E7's "one example per engine". The three hosted engines need a live endpoint and a credential, which is why `adapters/remote/examples/conformance.rs` is a manual CLI rather than a CI target, and the embedded engine needs the host's process-global seams installed. Neither belongs in a `cargo run --example` a contributor is told to run. Refs #18 (§E7) --- .github/workflows/ci.yml | 8 ++++ examples/basic.rs | 83 ++++++++++++++++++++++++++++++++++++++++ src/registry/mod.rs | 10 +++++ 3 files changed, 101 insertions(+) create mode 100644 examples/basic.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15d8ae2..8330c72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,14 @@ jobs: # discards the `-p` scope, prints the whole-workspace inverse tree, and # exits 0 looking clean even when this crate is the one at fault. The # manifest says so; this runs what it says. + # `cargo build --all-targets` only *compiles* an example. `AGENTS.md` + # promises `cargo run --example basic` works, and a compiled example can + # still panic on its first line — which is the state the repository was in + # before issue #18 §E7, when the command was documented and there was no + # `examples/` directory at all. + - name: Run the bundled example + run: cargo run --example basic + - name: Assert the contract crate stays free of heavy dependencies run: | forbidden="$(cargo tree -p tinymemory-api -e normal,build --prefix none \ diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..86e8951 --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,83 @@ +//! Bind a memory driver the way a host does: admit, then construct, then use. +//! +//! Run with: +//! +//! ```sh +//! cargo run --example basic +//! ``` +//! +//! This uses the null driver so it needs no engine, no workspace, and no +//! network — the point is the *shape* of binding, which is identical for a real +//! engine. Swap `NullMemoryProvider` for an adapter's provider and nothing else +//! here changes. +//! +//! The order matters and is the reason this example exists. A host does not +//! construct a driver and then ask whether it was allowed; it admits an id +//! first, and only then builds the thing. Admission is engine-neutral and +//! answers one question — *is this driver id real, and may it answer for +//! memory* — while construction needs everything an engine needs. + +use std::sync::Arc; + +use tinymemory::api::null::NullMemoryProvider; +use tinymemory::api::provider::{audit_provider, MemoryProvider}; +use tinymemory::api::types::{MemoryCategory, MemoryTaint, GLOBAL_NAMESPACE}; +use tinymemory::registry::{ConfigLabels, DriverRegistry, NULL_DRIVER_ID}; +use tinymemory::CONTRACT_VERSION; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("contract version: {CONTRACT_VERSION:?}"); + + // 1. Admission. The host names a driver; the registry decides whether it is + // real and what class it binds as. A reserved embedded or null id needs + // no configuration entry, which is what lets an unconfigured host boot. + let registry = DriverRegistry::builtin(); + let admission = registry.admit(NULL_DRIVER_ID, None, ConfigLabels::default())?; + println!("admitted '{}' as {:?}", admission.id, admission.class); + + // 2. Construction. The host's job, not the registry's — see + // `tinymemory::registry`'s module docs for why the two are separate. + let provider: Arc = Arc::new(NullMemoryProvider::new()); + + // 3. Negotiation. `audit_provider` checks the driver advertises exactly the + // families it can actually serve. A driver whose capability set overstates + // its accessors would let a host register RPC methods that answer errors. + audit_provider(provider.as_ref())?; + // `Capabilities` is a set, not a string — render it by walking it, which is + // also how a host filters its RPC surface from the negotiated set. + let families: Vec<&str> = provider + .capabilities() + .iter() + .map(tinymemory::capabilities::Capability::as_str) + .collect(); + println!( + "driver '{}' serves {} families: {}", + provider.driver_id(), + families.len(), + families.join(", ") + ); + + // 4. Use. Every driver serves the three mandatory families, so this much + // works against any of them. + provider + .store( + GLOBAL_NAMESPACE, + "greeting", + "hello from the basic example", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await?; + + // The null driver accepts writes and discards them — `/dev/null` semantics, + // a legitimate binding for a deployment that wants the ports wired and + // nothing retained. Reading back nothing here is correct, not a failure. + match provider.get(GLOBAL_NAMESPACE, "greeting").await? { + Some(entry) => println!("read back: {}", entry.content), + None => println!("read back: nothing — the null driver retains no writes"), + } + + Ok(()) +} diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 7cdad93..b5de045 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -86,6 +86,16 @@ pub struct FallbackReason { pub reason: String, } +/// A refusal is an error, so `?` can propagate it. +/// +/// It carried `Display` from the start but not this, which meant a host writing +/// the obvious `registry.admit(..)?` in a function returning `Box` or +/// `anyhow::Error` got a type error instead. Nothing about the type changes — +/// this is the trait that makes the existing message usable where refusals +/// actually travel. Found by writing `examples/basic.rs` (issue #18 §E7), which +/// is the argument for having a compiled example at all. +impl std::error::Error for FallbackReason {} + impl fmt::Display for FallbackReason { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( From 868bd959fba9dc41f8692d8d64d2471407f1ea00 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 17:50:18 +0530 Subject: [PATCH 08/14] Measure the build: feature powerset, dependency budget, coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §D5, §E8, and the second half of §E2. Three CI additions that all answer the same kind of question — what is this build actually costing, and is it still true — so they land together. **Feature powerset (§E2).** `cargo hack --feature-powerset --depth 2`, check only. Cargo features are additive: enabling one for a crate enables it for every consumer in the graph, so a combination nobody builds deliberately can still be built by somebody else's dependency. `--depth 2` covers every pair without the blow-up of the full set. §E2 also lists per-feature lanes — `--features tinycortex`, `mem0`, `supermemory`, `cognee`, `sync-composio`. Those are not here because those features do not exist yet: they are §D1, and §D1 needs OpenHuman to opt into features it currently gets unconditionally. The powerset covers whatever features exist, so it starts useful and stays useful as §D1 adds them. Verified by hand across all eight combinations the workspace can express today (core: none, contacts, memory-git, test-support and their pairs; the tinycortex adapter with and without memory-git). Every one compiles, so this pins a property that currently holds rather than papering over a break. **Dependency budget (§D5).** `scripts/ci/dependency-budget.sh` prints the crate count for every configuration and fails when the minimal one grows past a ceiling. Today: minimal 40, api 39, the tinycortex adapter 168 — and 172 with `memory-git`, which is the native git stack the feature exists to keep out, so the +4 is the gate from §C3 working. The ceiling is 50 against a current 40, deliberately. A limit set at today's exact count fails on the first legitimate addition, gets raised without thought, and teaches everyone to ignore it. Only the minimal configuration is gated; the richer numbers are reported, because a number nobody chose is not a budget. **Coverage (§E8).** `AGENTS.md` asks for 80% of meaningful library behaviour and nothing measured it. The workspace is at **76.22% lines / 76.44% regions / 63.81% functions** — below the number it asks for, which is worth seeing rather than assuming. Some of the gap is stark: `core/src/tree/score/store.rs` and `core/src/tree/score/extract/mod.rs` are at 0.00%. Reported, not enforced, to begin with. A threshold picked before anyone has seen the number is a guess, and a gate that fails on day one gets disabled rather than fixed. The powerset and coverage run as their own job: both are slower than the main lane and independent of it, so a failure in one should not mask the other or delay the fast feedback the main job gives. Refs #18 (§D5, §E2, §E8) --- .github/workflows/ci.yml | 50 +++++++++++++++++++++++++++++++ scripts/ci/dependency-budget.sh | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100755 scripts/ci/dependency-budget.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8330c72..53d1d59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,15 @@ jobs: # still panic on its first line — which is the state the repository was in # before issue #18 §E7, when the command was documented and there was no # `examples/` directory at all. + # §D5. Prints the dependency count of every build configuration and fails + # when the minimal one grows past its ceiling. The property this protects + # — that asking for no features gets you the contract and nothing that + # links a storage engine or an HTTP stack — is invisible in a diff, + # because the dependency arrives transitively through a feature enabled + # two crates away. + - name: Dependency budget + run: ./scripts/ci/dependency-budget.sh + - name: Run the bundled example run: cargo run --example basic @@ -113,6 +122,47 @@ jobs: exit 1 fi + # Feature-unification and coverage. Their own job: both are slower than the + # main lane and independent of it, so a failure in one should not mask the + # other, and neither should delay the fast feedback the main job gives. + feature-matrix: + name: Feature powerset and coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - uses: Swatinem/rust-cache@v2 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-hack,cargo-llvm-cov + + # §E2's second half. Cargo features are additive: enabling one for crate A + # enables it for every consumer in the graph, so a pair that nobody builds + # deliberately can still be built by someone else's dependency. `--depth 2` + # covers every pair without the combinatorial blow-up of the full set. + # + # Check-only: this is about whether the combinations *compile*, and the + # behaviour of each is the main job's business. + - name: Feature powerset compiles + run: cargo hack --feature-powerset --depth 2 --workspace check --all-targets + + # §E8. `AGENTS.md` asks for 80% of meaningful library behaviour and + # nothing measured it. Reported rather than enforced to begin with: a + # threshold picked before anyone has seen the number is a guess, and a + # failing gate on day one gets disabled rather than fixed. + - name: Coverage + run: | + cargo llvm-cov --all-features --workspace --summary-only \ + | tee "$GITHUB_STEP_SUMMARY" + # The module crate is its own workspace root (see the `exclude` note in the # root Cargo.toml), so NONE of the steps above touch it: `--all-targets`, # `--all-features` and `--workspace` all stop at the workspace boundary and diff --git a/scripts/ci/dependency-budget.sh b/scripts/ci/dependency-budget.sh new file mode 100755 index 0000000..80c4e54 --- /dev/null +++ b/scripts/ci/dependency-budget.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Reports the dependency count of each build configuration, and fails when the +# minimal one grows past its ceiling. +# +# Issue #18 §D5. The point of the contract crate and of `--no-default-features` +# is that a host which wants memory ports and nothing else does not compile a +# storage engine, a native library, or an HTTP stack. That property is invisible +# in a diff: a dependency arrives transitively, through a feature enabled two +# crates away, and the PR that causes it looks innocent. Printing the numbers on +# every run makes the regression visible on the PR that caused it, which is the +# only moment it is cheap to fix. +# +# The ceiling applies only to the minimal configuration. The richer ones are +# reported, not gated: their sizes are a consequence of what an engine needs, +# and a number nobody chose is not a budget worth failing on. +set -euo pipefail + +# Deliberately generous: the minimal build links 40 crates today. This is a +# ratchet against accidental growth, not a target to optimise towards — a limit +# set at today's exact count would fail on the first legitimate addition and get +# raised without thought, which teaches everyone to ignore it. +MINIMAL_CEILING="${MINIMAL_CEILING:-50}" + +count() { + # `-e normal` excludes dev- and build-dependencies: a test-only crate is not + # something a consumer links. + cargo tree "$@" -e normal --prefix none 2>/dev/null \ + | sed 's/ (\*)$//' | awk 'NF' | sort -u | wc -l | tr -d ' ' +} + +printf '%-52s %s\n' "configuration" "crates" +printf '%-52s %s\n' "----------------------------------------------------" "------" + +minimal=$(count -p tinymemory --no-default-features) +printf '%-52s %s\n' "tinymemory --no-default-features" "$minimal" +printf '%-52s %s\n' "tinymemory --all-features" "$(count -p tinymemory --all-features)" +printf '%-52s %s\n' "tinymemory-api" "$(count -p tinymemory-api)" +printf '%-52s %s\n' "tinymemory-tinycortex (default)" "$(count -p tinymemory-tinycortex --no-default-features)" +printf '%-52s %s\n' "tinymemory-tinycortex --features memory-git" "$(count -p tinymemory-tinycortex --features memory-git)" +printf '%-52s %s\n' "tinymemory-remote" "$(count -p tinymemory-remote)" + +echo +if [ "$minimal" -gt "$MINIMAL_CEILING" ]; then + echo "the minimal build links $minimal crates, over its ceiling of $MINIMAL_CEILING" >&2 + echo >&2 + echo "A host that asks for no features should get the contract, the registry" >&2 + echo "and the mandatory composition — nothing that links a storage engine or" >&2 + echo "an HTTP stack. Check what the new dependency arrived through:" >&2 + echo >&2 + echo " cargo tree -p tinymemory --no-default-features -e normal" >&2 + exit 1 +fi +echo "minimal build links $minimal crates, within its ceiling of $MINIMAL_CEILING" From 3c083a8233ea436bbe601f0d99ce47059f3984f2 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 17:58:01 +0530 Subject: [PATCH 09/14] Extend the module E2E to the declared surface and to durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §E5. The loader E2E drove three of the eighteen families the module advertises, and nothing checked that a write reached the workspace it was given. **Every declared method is routed.** The module advertises `Capabilities::all()` and declares 88 methods; the existing manifest test compares two lists, so it passes for a method that is declared, listed, and answers "unknown member" — the bus-level version of a capability set that overstates its accessors. This calls each declared method and distinguishes the kind of refusal: a wired method rejects the empty argument list with `ai.tinyhumans.tinybus.Error.BadArguments`, while an unwired one is `ai.tinyhumans.tinybus.Error.UnknownMethod`. The two names are what make the test discriminate rather than pass vacuously, and the unrouted shape is taken from the build under test rather than hard-coded. Eighteen bespoke round trips would assert more, and would also need eighteen sets of valid arguments and eighteen engine preconditions. This asserts the one thing that is true of all of them and is cheap to keep true. **A write lands in the host's workspace.** Every other test here stores and reads back inside one admission, so a module that kept its store in a temporary directory of its own, or in memory, passes all of them — and the difference shows on the user's next launch. §E5 asks for a shutdown/restart cycle, and it is not here because it cannot be: TinyBus never unloads a library, so a second admission in the same process is refused with `ModuleRefused { reason: "module initialization failed" }`. That is the same constraint that already forces every test in this file to be the only one in its process. A real restart needs a second process against a shared workspace, which is a change to the CI loop rather than a test, so the directory assertion covers what restart would have been checking and the limitation is written down rather than left as a gap someone rediscovers. Verified the way CI runs them — every ignored test in its own process, all ten green. Refs #18 (§E5) --- crates/tinymemory-module/tests/module_e2e.rs | 115 +++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index 79c1344..7c24f77 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -611,3 +611,118 @@ async fn the_manifest_declares_every_method_the_module_serves() { declared.difference(&expected).collect::>() ); } + +#[tokio::test] +#[ignore = "drives a real dlopen'ed module; must be the only such test in the process — see the module docs"] +async fn what_is_written_lands_in_the_workspace_it_was_given() { + // Issue #18 §E5 asks for a shutdown/restart cycle. It cannot be written + // here, and the reason is structural rather than an omission: TinyBus never + // unloads a library, so a second admission in the same process is refused — + // `ModuleRefused { reason: "module initialization failed" }` — and every + // test in this file must be the only one in its process for the same + // reason. A genuine restart needs a second *process* against a shared + // workspace, which is a change to the CI loop rather than a test. + // + // What is assertable in one process is the property that restart would be + // checking: that a write goes to the durable workspace the host supplied, + // and not somewhere that disappears. A module that stored into a temporary + // directory of its own, or in memory, passes every other test in this file + // — each one stores and reads back inside a single admission, so the + // difference never shows. It would show on the user's next launch. + let workspace = tempfile::tempdir().expect("tempdir"); + + // Nothing has been asked of it yet. + let before = std::fs::read_dir(workspace.path()) + .expect("workspace readable") + .count(); + + let (client, _host, _task) = admit_module(workspace.path()).await; + proxy(&client) + .call::<()>( + "Store", + ( + "e2e", + "durable", + "written to the host's workspace", + MemoryCategory::Core, + Option::::None, + MemoryTaint::default(), + ), + ) + .await + .expect("Store"); + + let entry: Option = proxy(&client) + .call("Get", ("e2e", "durable")) + .await + .expect("Get"); + assert_eq!( + entry.expect("just stored").content, + "written to the host's workspace" + ); + + // The assertion that a same-admission round trip cannot make: the bytes are + // in the directory the host named. + let after = std::fs::read_dir(workspace.path()) + .expect("workspace readable") + .count(); + assert!( + after > before, + "the module answered correctly but wrote nothing into the workspace it \ + was given — a store that does not land here does not survive a restart" + ); +} + +#[tokio::test] +#[ignore = "drives a real dlopen'ed module; must be the only such test in the process — see the module docs"] +async fn every_declared_method_is_actually_routed() { + // Issue #18 §E5 asks the E2E to cover every family the module advertises. + // It advertises `Capabilities::all()` — eighteen families — and the tests + // above exercise three of them. + // + // Rather than eighteen bespoke round trips, this asserts the property that + // makes the advertisement honest at this layer: every method the manifest + // declares is actually *reachable*. `the_manifest_declares_every_method_the + // _module_serves` compares two lists and would pass for a method that is + // declared, routed, and answers "unknown member" — which is the bus-level + // version of a capability set that overstates its accessors. + // + // Each method is called with no arguments, so most fail. That is fine and is + // the point: what is asserted is the *kind* of failure. A method that is + // wired rejects the arguments; a method that is not wired rejects the + // member, and those carry different wire names. + let workspace = tempfile::tempdir().expect("tempdir"); + let (client, _host, _task) = admit_module(workspace.path()).await; + + // Establish the shape of a genuine not-routed refusal from this very build, + // rather than hard-coding tinybus's spelling of it. The two outcomes are + // distinct names — `ai.tinyhumans.tinybus.Error.UnknownMethod` for a member + // that is not there, `…Error.BadArguments` for one that is and did not like + // the empty argument list — which is what makes this test discriminate + // rather than pass vacuously. + let unrouted = proxy(&client) + .call::("NoSuchMethodAtAll", ()) + .await + .expect_err("an unknown member must be refused") + .wire_name() + .to_string(); + + let mut missing = Vec::new(); + for method in EXPECTED_METHODS { + // `Shutdown` would stop the module and strand every later iteration. + if *method == "Shutdown" { + continue; + } + let outcome = proxy(&client).call::(method, ()).await; + if let Err(error) = outcome { + if error.wire_name() == unrouted { + missing.push(*method); + } + } + } + + assert!( + missing.is_empty(), + "declared in the manifest but not routed: {missing:?}" + ); +} From 7dedb766fecc6751ca220b772cc486f12a1dc9b7 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 19:22:54 +0530 Subject: [PATCH 10/14] Delete the conversion layer now that both sides name one type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §A1, the tinymemory half. Companion to tinyhumansai/tinycortex#149, which makes `tinycortex-api` re-export this workspace's contract rather than redefining it. `convert.rs` existed because the engine's contract crate and this one described the same values under two names — the same code, in fact, since `api/` was extracted from `tinycortex-api` and held byte-identical. Being nominally distinct meant every call across the seam translated, and a field added to either contract had to be added to the other and to the conversion. Three places, or the value was silently dropped. With one type set there is nothing to convert: `convert.rs` and its tests are deleted, and the adapter's eleven conversion call sites become plain delegation. `list` and `namespace_summaries` lose an `into_iter().collect()` that had become an identity map, and `store`/`store_with_taint` pass their arguments straight through. The gitlink moves to the commit carrying the re-export. That commit also stops publishing tinycortex, which is what makes the git dependency legal — and records the state that repository was already in, since `cargo package` there has failed since `api/` was split out. The workspace root gains a `[patch]` for the git contract dependency. Without it cargo resolves the git copy *and* the path copy as two distinct crates, and `MemoryCategory` from one is not the same type as the other — the exact duplication this change deletes, reintroduced by the fix for it. Found by the compiler at the seam rather than reasoned about, and the patch table is the same mechanism this workspace already uses for tinycortex itself. Acceptance for §A1, checked rather than asserted: - `adapters/tinycortex/src/convert.rs` is deleted - no conversion function remains anywhere in the workspace (grep: 0 hits) - the workspace builds and tests green with no conversion at the seam `cargo test --all-features` reports 1101 passing, down 9 from 1110. That difference is exactly the nine tests in `convert_test.rs`, which tested the layer this change removes. Refs #18 (§A1) --- Cargo.lock | 1 + Cargo.toml | 13 ++ adapters/tinycortex/src/convert.rs | 197 ------------------------ adapters/tinycortex/src/convert_test.rs | 170 -------------------- adapters/tinycortex/src/lib.rs | 18 ++- adapters/tinycortex/src/memory.rs | 67 ++------ vendor/tinycortex | 2 +- 7 files changed, 40 insertions(+), 428 deletions(-) delete mode 100644 adapters/tinycortex/src/convert.rs delete mode 100644 adapters/tinycortex/src/convert_test.rs diff --git a/Cargo.lock b/Cargo.lock index 8b1112e..dd6b02f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1795,6 +1795,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.20", + "tinymemory-api", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 2915028..2d18feb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,19 @@ private_intra_doc_links = "warn" # so a host that already pins its own engine checkout unifies onto one copy # through its own patch table. These entries are what make a *standalone* build # of this workspace resolve them to the nested `vendor/` submodules. +# `tinycortex-api` takes this workspace's own contract crate by git, because +# neither crate is published (tinymemory#18 §A1). Without this entry cargo +# resolves the git copy *and* the path copy as two distinct crates, and +# `tinymemory_api::MemoryCategory` from one is not the same type as from the +# other — which is the exact duplication §A1 exists to delete, reintroduced by +# the fix for it. The error is loud rather than silent, but only at the seam. +# +# Patch tables apply from the workspace root being built, so this covers builds +# and tests here. A host that embeds this workspace needs the same entry, the +# same way it already patches tinycortex. +[patch."https://github.com/tinyhumansai/tinymemory"] +tinymemory-api = { path = "api" } + [patch.crates-io] tinycortex = { path = "vendor/tinycortex" } tinycortex-api = { path = "vendor/tinycortex/api" } diff --git a/adapters/tinycortex/src/convert.rs b/adapters/tinycortex/src/convert.rs deleted file mode 100644 index 8d7d540..0000000 --- a/adapters/tinycortex/src/convert.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Value conversions between `tinycortex-api` and `tinymemory-api`. -//! -//! The two contracts describe the same values and, today, describe them -//! identically — `tinymemory-api` was moved out of `tinycortex-api`. They are -//! nonetheless distinct Rust types in distinct crates, so a value has to be -//! rebuilt to cross. -//! -//! ## Every conversion destructures exhaustively -//! -//! This is the whole discipline of this file, and the reason it is not written -//! with `..` or field-by-field assignment onto a `Default`. Two contracts that -//! are allowed to drift *will* drift: someone adds a field to one side, and a -//! lenient conversion silently drops it. A struct literal built from a full -//! destructuring pattern turns that into a compile error on the very next -//! build, naming the field. -//! -//! The same applies to the enums: each `match` lists every variant, so a new -//! category or a third taint level cannot fall into a catch-all arm and be -//! quietly downgraded. -//! -//! ## Taint conversion is the security-relevant one -//! -//! [`tinymemory_api::types::MemoryTaint`] records whether content came from outside. Mapping it -//! wrongly — or defaulting it on an unrecognised value — would let -//! externally-sourced content be treated as internal-trust content. Both sides -//! fail closed to `ExternalSync` when decoding an unknown persisted string, and -//! the mapping here is a total two-arm match with no default, so there is -//! nowhere for a wrong answer to come from. - -use tinycortex::memory::types as tc; -use tinymemory_api::types as tm; - -/// Converts a category to the TinyMemory contract's form. -#[must_use] -pub fn category_to_tinymemory(category: tc::MemoryCategory) -> tm::MemoryCategory { - match category { - tc::MemoryCategory::Core => tm::MemoryCategory::Core, - tc::MemoryCategory::Daily => tm::MemoryCategory::Daily, - tc::MemoryCategory::Conversation => tm::MemoryCategory::Conversation, - tc::MemoryCategory::Custom(name) => tm::MemoryCategory::Custom(name), - } -} - -/// Converts a category to the TinyCortex engine's form. -#[must_use] -pub fn category_to_tinycortex(category: tm::MemoryCategory) -> tc::MemoryCategory { - match category { - tm::MemoryCategory::Core => tc::MemoryCategory::Core, - tm::MemoryCategory::Daily => tc::MemoryCategory::Daily, - tm::MemoryCategory::Conversation => tc::MemoryCategory::Conversation, - tm::MemoryCategory::Custom(name) => tc::MemoryCategory::Custom(name), - } -} - -/// Converts provenance to the TinyMemory contract's form. -/// -/// A total match with no default arm: see the module docs on why this one may -/// not be lenient. -#[must_use] -pub fn taint_to_tinymemory(taint: tc::MemoryTaint) -> tm::MemoryTaint { - match taint { - tc::MemoryTaint::Internal => tm::MemoryTaint::Internal, - tc::MemoryTaint::ExternalSync => tm::MemoryTaint::ExternalSync, - } -} - -/// Converts provenance to the TinyCortex engine's form. -#[must_use] -pub fn taint_to_tinycortex(taint: tm::MemoryTaint) -> tc::MemoryTaint { - match taint { - tm::MemoryTaint::Internal => tc::MemoryTaint::Internal, - tm::MemoryTaint::ExternalSync => tc::MemoryTaint::ExternalSync, - } -} - -/// Converts an entry to the TinyMemory contract's form. -#[must_use] -pub fn entry_to_tinymemory(entry: tc::MemoryEntry) -> tm::MemoryEntry { - // Exhaustive destructuring: a field added to the engine's entry breaks this - // line rather than being dropped on the floor. - let tc::MemoryEntry { - id, - key, - content, - namespace, - category, - timestamp, - session_id, - score, - taint, - } = entry; - tm::MemoryEntry { - id, - key, - content, - namespace, - category: category_to_tinymemory(category), - timestamp, - session_id, - score, - taint: taint_to_tinymemory(taint), - } -} - -/// Converts a namespace summary to the TinyMemory contract's form. -#[must_use] -pub fn namespace_summary_to_tinymemory(summary: tc::NamespaceSummary) -> tm::NamespaceSummary { - let tc::NamespaceSummary { - namespace, - count, - last_updated, - } = summary; - tm::NamespaceSummary { - namespace, - count, - last_updated, - } -} - -/// The owned recall filters, in the engine's owned form. -/// -/// Returned owned rather than borrowed because the engine's `RecallOpts` -/// borrows its string fields, and a borrow of a value built inside a conversion -/// function cannot outlive the call. Callers keep this alive and borrow from it. -#[must_use] -pub fn recall_opts_to_tinycortex(opts: &tm::OwnedRecallOpts) -> tc::OwnedRecallOpts { - let tm::OwnedRecallOpts { - namespace, - category, - session_id, - min_score, - cross_session, - } = opts; - tc::OwnedRecallOpts { - namespace: namespace.clone(), - category: category.clone().map(category_to_tinycortex), - session_id: session_id.clone(), - min_score: *min_score, - cross_session: *cross_session, - } -} - -#[cfg(test)] -#[path = "convert_test.rs"] -mod test; - -// ── The reverse direction, for a driver whose contract is TinyMemory's ──────── -// -// Everything above converts engine values *into* the TinyMemory contract, which -// is what wrapping TinyCortex as a TinyMemory driver needs. A module-backed -// driver runs the other way: it speaks TinyMemory, and a host whose binding -// still speaks TinyCortex has to convert its answers back. -// -// Same discipline as above — exhaustive destructuring, total matches, no `..` -// and no `Default` — for the same reason: two contracts allowed to drift will. - -/// Converts an entry to the `TinyCortex` contract's form. -#[must_use] -pub fn entry_to_tinycortex(entry: tm::MemoryEntry) -> tc::MemoryEntry { - let tm::MemoryEntry { - id, - key, - content, - namespace, - category, - timestamp, - session_id, - score, - taint, - } = entry; - tc::MemoryEntry { - id, - key, - content, - namespace, - category: category_to_tinycortex(category), - timestamp, - session_id, - score, - taint: taint_to_tinycortex(taint), - } -} - -/// Converts a namespace summary to the `TinyCortex` contract's form. -#[must_use] -pub fn namespace_summary_to_tinycortex(summary: tm::NamespaceSummary) -> tc::NamespaceSummary { - let tm::NamespaceSummary { - namespace, - count, - last_updated, - } = summary; - tc::NamespaceSummary { - namespace, - count, - last_updated, - } -} diff --git a/adapters/tinycortex/src/convert_test.rs b/adapters/tinycortex/src/convert_test.rs deleted file mode 100644 index 75016ec..0000000 --- a/adapters/tinycortex/src/convert_test.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Conversion tests. -//! -//! Two contracts that are allowed to drift will drift. The exhaustive -//! destructuring in `convert.rs` catches a *new* field at compile time; these -//! tests catch a *mis-mapped* one, which the compiler cannot see because every -//! field on both sides has the same type. - -#![allow(clippy::expect_used, clippy::panic)] - -use super::*; - -/// Every category must survive a round trip, including the custom variant's -/// payload — a `Custom(String)` mapped onto the wrong arm would silently -/// re-file every custom-categorised memory. -#[test] -fn every_category_round_trips() { - let cases = [ - tm::MemoryCategory::Core, - tm::MemoryCategory::Daily, - tm::MemoryCategory::Conversation, - tm::MemoryCategory::Custom("project-notes".to_string()), - ]; - for category in cases { - let round_tripped = category_to_tinymemory(category_to_tinycortex(category.clone())); - assert_eq!(round_tripped, category); - } -} - -/// The wire form is the persisted form on both sides, so a conversion that -/// round-trips the Rust value but changes the string would still corrupt a -/// store. Comparing the rendered forms catches that. -#[test] -fn category_conversion_preserves_the_persisted_spelling() { - let cases = [ - tm::MemoryCategory::Core, - tm::MemoryCategory::Daily, - tm::MemoryCategory::Conversation, - tm::MemoryCategory::Custom("x".to_string()), - ]; - for category in cases { - let engine = category_to_tinycortex(category.clone()); - assert_eq!( - engine.to_string(), - category.to_string(), - "the two contracts must agree on the persisted spelling" - ); - } -} - -/// Provenance is the security-relevant conversion: mapping `ExternalSync` onto -/// `Internal` would upgrade the trust of externally-sourced content. -#[test] -fn every_taint_round_trips_and_keeps_its_db_spelling() { - for taint in [tm::MemoryTaint::Internal, tm::MemoryTaint::ExternalSync] { - let engine = taint_to_tinycortex(taint); - assert_eq!(taint_to_tinymemory(engine), taint); - assert_eq!( - engine.as_db_str(), - taint.as_db_str(), - "the two contracts must agree on the persisted spelling" - ); - } -} - -/// `ExternalSync` must never come back as `Internal`, stated as its own -/// assertion rather than left implicit in the round trip above. -#[test] -fn external_content_is_never_laundered_into_internal_trust() { - assert_eq!( - taint_to_tinymemory(taint_to_tinycortex(tm::MemoryTaint::ExternalSync)), - tm::MemoryTaint::ExternalSync - ); - assert_ne!( - taint_to_tinymemory(taint_to_tinycortex(tm::MemoryTaint::ExternalSync)), - tm::MemoryTaint::Internal - ); -} - -#[test] -fn an_entry_round_trips_every_field() { - let engine = tc::MemoryEntry { - id: "ns/key".to_string(), - key: "key".to_string(), - content: "body".to_string(), - namespace: Some("ns".to_string()), - category: tc::MemoryCategory::Custom("notes".to_string()), - timestamp: "2026-08-10T00:00:00Z".to_string(), - session_id: Some("s1".to_string()), - score: Some(0.75), - taint: tc::MemoryTaint::ExternalSync, - }; - - let converted = entry_to_tinymemory(engine.clone()); - - assert_eq!(converted.id, engine.id); - assert_eq!(converted.key, engine.key); - assert_eq!(converted.content, engine.content); - assert_eq!(converted.namespace, engine.namespace); - assert_eq!(converted.category.to_string(), engine.category.to_string()); - assert_eq!(converted.timestamp, engine.timestamp); - assert_eq!(converted.session_id, engine.session_id); - assert_eq!(converted.score, engine.score); - assert_eq!(converted.taint, tm::MemoryTaint::ExternalSync); -} - -/// A score of `None` must stay `None`. Defaulting it to `0.0` would make an -/// unranked entry look like a worst-ranked one. -#[test] -fn an_absent_score_stays_absent() { - let engine = tc::MemoryEntry { - id: "i".to_string(), - key: "k".to_string(), - content: "c".to_string(), - namespace: None, - category: tc::MemoryCategory::Core, - timestamp: "t".to_string(), - session_id: None, - score: None, - taint: tc::MemoryTaint::Internal, - }; - let converted = entry_to_tinymemory(engine); - assert!(converted.score.is_none()); - assert!(converted.namespace.is_none()); - assert!(converted.session_id.is_none()); -} - -#[test] -fn a_namespace_summary_round_trips_every_field() { - let engine = tc::NamespaceSummary { - namespace: "projects".to_string(), - count: 12, - last_updated: Some("2026-08-10T00:00:00Z".to_string()), - }; - let converted = namespace_summary_to_tinymemory(engine.clone()); - assert_eq!(converted.namespace, engine.namespace); - assert_eq!(converted.count, engine.count); - assert_eq!(converted.last_updated, engine.last_updated); -} - -/// Every recall filter must cross. A dropped `min_score` or `cross_session` -/// silently widens a query. -#[test] -fn every_recall_filter_crosses() { - let opts = tm::OwnedRecallOpts { - namespace: Some("ns".to_string()), - category: Some(tm::MemoryCategory::Daily), - session_id: Some("s1".to_string()), - min_score: Some(0.5), - cross_session: true, - }; - let engine = recall_opts_to_tinycortex(&opts); - assert_eq!(engine.namespace, opts.namespace); - assert_eq!( - engine.category.as_ref().map(ToString::to_string), - opts.category.as_ref().map(ToString::to_string) - ); - assert_eq!(engine.session_id, opts.session_id); - assert_eq!(engine.min_score, opts.min_score); - assert_eq!(engine.cross_session, opts.cross_session); -} - -#[test] -fn empty_recall_filters_stay_empty() { - let engine = recall_opts_to_tinycortex(&tm::OwnedRecallOpts::default()); - assert!(engine.namespace.is_none()); - assert!(engine.category.is_none()); - assert!(engine.session_id.is_none()); - assert!(engine.min_score.is_none()); - assert!(!engine.cross_session); -} diff --git a/adapters/tinycortex/src/lib.rs b/adapters/tinycortex/src/lib.rs index ea9ead8..ec838a7 100644 --- a/adapters/tinycortex/src/lib.rs +++ b/adapters/tinycortex/src/lib.rs @@ -1,15 +1,18 @@ //! TinyCortex as a TinyMemory driver. //! //! This crate is the seam between the TinyCortex engine and the TinyMemory -//! contract. The two describe the same values but are distinct crates, so -//! something has to convert — and it is much better for that to be one small -//! audited crate than a conversion scattered across every call site in a host. +//! contract. //! -//! ## What is here +//! It used to carry a conversion layer as well. The two crates described the +//! same values under two names, so every call across the seam translated, and a +//! field added to one contract had to be added to the other and to the +//! conversion — three places, or the value was silently dropped. Since +//! issue #18 §A1 `tinycortex-api` re-exports `tinymemory-api` rather than +//! redefining it, so both sides name one type and `convert` is gone. What +//! remains is the trait shape: the engine's storage trait and the contract's +//! are still separate traits over the same values. //! -//! - [`convert`] — total, exhaustively-destructuring value conversions in both -//! directions. A field added to either contract becomes a compile error here -//! instead of a silently dropped value. +//! ## What is here //! - [`TinycortexMemory`] — wraps any TinyCortex [`tinycortex::memory::Memory`] //! backend as a TinyMemory //! [`Memory`](tinymemory_api::traits::Memory). @@ -45,7 +48,6 @@ //! [`engine::advertised_capabilities`] and not just the accessor — a build //! without the git-backed snapshot store must not claim a diff ledger. -pub mod convert; pub mod engine; mod memory; diff --git a/adapters/tinycortex/src/memory.rs b/adapters/tinycortex/src/memory.rs index 02f7533..3235973 100644 --- a/adapters/tinycortex/src/memory.rs +++ b/adapters/tinycortex/src/memory.rs @@ -1,9 +1,12 @@ //! [`TinycortexMemory`] — a TinyCortex storage backend, seen through the //! TinyMemory contract's [`Memory`] trait. //! -//! Every method is a delegation plus a conversion. The two that are not purely -//! mechanical are called out below; both are cases where getting the -//! delegation "obviously right" would be wrong. +//! Every method is a plain delegation. It used to be a delegation *plus a +//! conversion*, because the engine's contract crate defined its own copies of +//! the memory value types; since tinymemory#18 §A1 `tinycortex-api` re-exports +//! this contract instead, so the two sides name one type and there is nothing +//! left to convert. The one method that is still not purely mechanical is +//! called out below. use std::sync::Arc; @@ -14,11 +17,6 @@ use tinymemory_api::types::{ MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, }; -use crate::convert::{ - category_to_tinycortex, entry_to_tinymemory, namespace_summary_to_tinymemory, - recall_opts_to_tinycortex, taint_to_tinycortex, -}; - /// A TinyCortex backend exposed as a TinyMemory [`Memory`]. pub struct TinycortexMemory { inner: Arc, @@ -63,13 +61,7 @@ impl Memory for TinycortexMemory { session_id: Option<&str>, ) -> anyhow::Result<()> { self.inner - .store( - namespace, - key, - content, - category_to_tinycortex(category), - session_id, - ) + .store(namespace, key, content, category, session_id) .await } @@ -88,20 +80,13 @@ impl Memory for TinycortexMemory { taint: MemoryTaint, ) -> anyhow::Result<()> { self.inner - .store_with_taint( - namespace, - key, - content, - category_to_tinycortex(category), - session_id, - taint_to_tinycortex(taint), - ) + .store_with_taint(namespace, key, content, category, session_id, taint) .await } - /// The engine's `RecallOpts` borrows its string fields, so the owned - /// conversion has to outlive the borrow taken from it — hence the local - /// binding rather than a temporary in the call. + /// The engine's `RecallOpts` borrows its string fields, so the owned form + /// has to outlive the borrow taken from it — hence the local binding rather + /// than a temporary in the call. async fn recall( &self, query: &str, @@ -109,12 +94,7 @@ impl Memory for TinycortexMemory { opts: RecallOpts<'_>, ) -> anyhow::Result> { let owned = OwnedRecallOpts::from(opts); - let engine_owned = recall_opts_to_tinycortex(&owned); - let hits = self - .inner - .recall(query, limit, (&engine_owned).into()) - .await?; - Ok(hits.into_iter().map(entry_to_tinymemory).collect()) + Ok(self.inner.recall(query, limit, (&owned).into()).await?) } async fn recall_relevant_by_vector( @@ -130,11 +110,7 @@ impl Memory for TinycortexMemory { } async fn get(&self, namespace: &str, key: &str) -> anyhow::Result> { - Ok(self - .inner - .get(namespace, key) - .await? - .map(entry_to_tinymemory)) + self.inner.get(namespace, key).await } async fn list( @@ -143,14 +119,7 @@ impl Memory for TinycortexMemory { category: Option<&MemoryCategory>, session_id: Option<&str>, ) -> anyhow::Result> { - // `category` is borrowed on both sides, so the converted value needs a - // binding to borrow from. - let engine_category = category.cloned().map(category_to_tinycortex); - let entries = self - .inner - .list(namespace, engine_category.as_ref(), session_id) - .await?; - Ok(entries.into_iter().map(entry_to_tinymemory).collect()) + self.inner.list(namespace, category, session_id).await } async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result { @@ -158,13 +127,7 @@ impl Memory for TinycortexMemory { } async fn namespace_summaries(&self) -> anyhow::Result> { - Ok(self - .inner - .namespace_summaries() - .await? - .into_iter() - .map(namespace_summary_to_tinymemory) - .collect()) + self.inner.namespace_summaries().await } async fn count(&self) -> anyhow::Result { diff --git a/vendor/tinycortex b/vendor/tinycortex index 5fdeac9..34cbb6c 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit 5fdeac984c09d2dac65b61e92fd27e2c92ce1e6b +Subproject commit 34cbb6cfa91ea74d62605bd57790782b0c748556 From 2721f4c042211503d62be1e11e24ea684533b060 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 19:28:32 +0530 Subject: [PATCH 11/14] Name the contract's Memory trait instead of reaching through the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §A2. `tinymemory_core::traits` re-exported `Memory` and the memory value types from `tinycortex::memory` — from the *engine*. That is §1.1's finding: `tinymemory_core::MemoryEntry` was the engine's type rather than the contract's, so a second engine could not be bound without translating, and the crate was engine-neutral in name only. It now names `tinymemory-api` directly. Since §A1 the engine re-exports that same contract, so both spellings resolve to one type either way — but reaching an engine-neutral contract *through an engine* is precisely what would have to be undone before a second engine could be bound, and undoing it later is harder than not doing it. §A2's second clause comes along with the first: `UnifiedMemory` implements `crate::traits::Memory`, which is now the contract's trait, so it implements the TinyMemory trait directly with no further edit. This was not possible before §A1. The same repoint attempted then produced 10 compile errors, 9 of which wanted taint conversions added — the three-place edit §A1 exists to delete. With one type set behind both names it is a 20-line documentation change and a re-export, and the workspace compiles unchanged. Refs #18 (§A2) --- core/src/traits.rs | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/core/src/traits.rs b/core/src/traits.rs index 022bfb0..af12346 100644 --- a/core/src/traits.rs +++ b/core/src/traits.rs @@ -1,11 +1,16 @@ //! Core traits and data structures for the OpenHuman memory system. //! //! This module defines the foundational `Memory` trait that all storage backends -//! must implement. The standard memory value types (`MemoryEntry`, +//! must implement. The trait and the standard memory value types (`MemoryEntry`, //! `MemoryCategory`, `MemoryTaint`, `RecallOpts`, `NamespaceSummary`) are -//! **re-exported from the `tinycortex` crate** (migration W2, spec §0.5): the -//! crate is the single source of truth for these wire-compatible types, and the -//! 30+ host consumers keep their `memory::traits::…` import paths unchanged. +//! **re-exported from `tinymemory-api`**, the engine-neutral contract. +//! +//! They used to come from the `tinycortex` crate — from the *engine*, in other +//! words, which meant `tinymemory_core::MemoryEntry` was the engine's type and +//! not the contract's, and a second engine could not have been bound without +//! translating (issue #18 §A1/§A2). Naming the contract directly is what makes +//! this crate engine-neutral at the type level; the 30+ host consumers keep +//! their `memory::traits::…` import paths unchanged either way. //! //! `MemoryTaint` is security-critical provenance — it fails closed to //! `ExternalSync` for unknown/corrupt values so the subconscious gate refuses @@ -13,19 +18,19 @@ //! byte-identical to the former host definition before re-exporting; the tests //! below are the host-side seam that pins that contract on the crate type. //! -//! The `Memory` trait is also re-exported from `tinycortex`; backend-specific -//! resources such as SQLite connections are carried explicitly by factories -//! instead of being exposed through the storage abstraction. +//! Backend-specific resources such as SQLite connections are carried explicitly +//! by factories instead of being exposed through the storage abstraction. -// ── Value types: re-exported from the crate (W2 type-unification, spec §0.5) ── +// ── The contract's trait and value types ───────────────────────────────────── // -// These were formerly defined here. They are now the crate's types verbatim -// (identical fields, derives, serde attrs, and — for `MemoryTaint` — the same -// fail-closed `from_db_str`). Re-exporting keeps one source of truth while every -// `use crate::traits::{MemoryEntry, …}` site compiles unchanged. -pub use tinycortex::memory::{ - Memory, MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, -}; +// Named directly rather than reached through `tinycortex::memory`. Since §A1 the +// engine re-exports this same contract, so the two spellings resolve to one type +// either way — but going through the engine to reach an engine-neutral contract +// is what §1.1 of issue #18 calls out, and it is what would have to be undone +// before a second engine could be bound. +pub use tinymemory_api::recall::RecallOpts; +pub use tinymemory_api::traits::Memory; +pub use tinymemory_api::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; #[cfg(test)] mod tests { From ac2b1e60f5cd792203af906442406186c2d3d80b Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 19:49:27 +0530 Subject: [PATCH 12/14] Contain the engine behind one module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #18 §C1. Eighty-three files named the `tinycortex` crate, in two hundred and ninety-six places. That made the engine's shape an ambient fact of the whole crate rather than a dependency anyone had chosen, and made "what would a second engine have to provide?" a question nobody could answer without reading all of them. Every one now goes through `core/src/engine/backend.rs`, which re-exports the engine's memory surface and is the only file outside the seam that names it. Outside `core/src/engine/`, references to the crate are zero. Re-exporting rather than wrapping is deliberate. A wrapper over three hundred call sites would be a second surface to keep in step with the first, which is the failure §A1 had just finished deleting. What this buys is not insulation — the call sites use the engine's API verbatim — but a single enumerable place that names it. The seam is `engine`, not `tinycortex`. §C1's text says `core/src/tinycortex/`, but its own acceptance criterion asks that `grep -rl tinycortex core/src` match only files under that module, and those two cannot both hold: with the module named after the engine, every call site reads `crate::tinycortex::…` and matches. Naming the seam for its role rather than for one engine satisfies the criterion and is the better name regardless — a seam named after the thing it is meant to make replaceable is the coupling this section removes. The module was `pub` but had no consumer outside this crate, so the rename breaks nothing. The workspace root and the module crate each gain a `[patch]` for the contract's git dependency. Patch tables apply only from the root being built, and the module crate is its own root; without its own entry cargo resolves the git copy alongside the path copy and `MemoryTaint` from one is not the same type as from the other. Found by the compiler, not predicted. What is left matching `tinycortex` outside the seam is 82 log-message strings and a `tinycortex_kv` accessor name. Neither is a crate reference, and renaming the accessor is cosmetic churn §C1 does not ask for. Two stale doc comments describing a "thirteen-family `tinycortex_api` contract" are corrected — since §A1 the contract is `tinymemory-api`, and it has eighteen families. Acceptance, measured: 83 files naming the engine crate outside the seam, now 0. Refs #18 (§C1) --- core/src/conversations/blocking.rs | 6 +- core/src/conversations/mod.rs | 2 +- core/src/diff/mod.rs | 6 +- core/src/diff/ops.rs | 10 +-- core/src/diff/source.rs | 4 +- core/src/diff/stub.rs | 2 +- core/src/engine/backend.rs | 59 ++++++++++++++++ core/src/{tinycortex => engine}/chat.rs | 0 core/src/{tinycortex => engine}/config.rs | 0 core/src/{tinycortex => engine}/embeddings.rs | 0 core/src/{tinycortex => engine}/ingest.rs | 0 core/src/{tinycortex => engine}/mod.rs | 2 + core/src/{tinycortex => engine}/parity.rs | 0 core/src/{tinycortex => engine}/persona.rs | 0 .../{tinycortex => engine}/queue_driver.rs | 0 core/src/{tinycortex => engine}/seal.rs | 0 core/src/{tinycortex => engine}/summariser.rs | 0 core/src/{tinycortex => engine}/sync.rs | 0 core/src/ingest_pipeline.rs | 24 +++---- core/src/ingestion/mod.rs | 10 +-- core/src/lib.rs | 2 +- core/src/people/mod.rs | 8 ++- core/src/queue/ops.rs | 11 +-- core/src/queue/scheduler.rs | 8 +-- core/src/queue/store.rs | 36 +++++----- core/src/queue/types.rs | 2 +- core/src/queue/worker.rs | 10 +-- core/src/sources/readers/conversation.rs | 12 ++-- core/src/sources/readers/folder.rs | 12 ++-- core/src/sources/readers/github.rs | 16 +++-- core/src/sources/readers/rss.rs | 12 ++-- core/src/sources/readers/web_page.rs | 12 ++-- core/src/sources/registry.rs | 8 +-- core/src/sources/sync.rs | 14 ++-- core/src/sources/types.rs | 2 +- core/src/store/chunks/connection.rs | 6 +- core/src/store/chunks/embeddings.rs | 32 +++++---- core/src/store/chunks/mod.rs | 2 +- core/src/store/chunks/raw_refs.rs | 22 +++--- core/src/store/chunks/semantic.rs | 4 +- core/src/store/chunks/store.rs | 69 +++++++++++-------- core/src/store/chunks/types.rs | 4 +- core/src/store/client.rs | 3 +- core/src/store/content/mod.rs | 6 +- core/src/store/content/read.rs | 8 +-- core/src/store/content/tags.rs | 2 +- core/src/store/entities.rs | 18 ++--- core/src/store/kv.rs | 6 +- core/src/store/namespace_store/segments.rs | 2 +- core/src/store/profile_store.rs | 3 +- core/src/store/safety/mod.rs | 4 +- core/src/store/safety/pii.rs | 4 +- core/src/store/trees/hotness.rs | 12 ++-- core/src/store/trees/registry.rs | 6 +- core/src/store/trees/store.rs | 56 ++++++++------- core/src/store/trees/store_tests.rs | 2 +- core/src/store/trees/types.rs | 2 +- core/src/store/types.rs | 4 +- core/src/sync/composio/mod.rs | 8 +-- core/src/sync/composio/periodic.rs | 4 +- .../sync/composio/providers/clickup/mod.rs | 5 +- .../src/sync/composio/providers/github/mod.rs | 5 +- core/src/sync/composio/providers/gmail/mod.rs | 2 +- .../sync/composio/providers/gmail/provider.rs | 11 ++- .../sync/composio/providers/gmail/tests.rs | 2 +- core/src/sync/composio/providers/helpers.rs | 4 +- .../src/sync/composio/providers/linear/mod.rs | 2 +- core/src/sync/composio/providers/mod.rs | 2 +- .../src/sync/composio/providers/notion/mod.rs | 2 +- .../composio/providers/notion/provider.rs | 2 +- core/src/sync/composio/providers/slack/mod.rs | 2 +- .../sync/composio/providers/slack/provider.rs | 20 ++---- .../src/sync/composio/providers/sync_state.rs | 6 +- core/src/sync/composio/providers/traits.rs | 2 +- core/src/sync/sync_status/mod.rs | 2 +- core/src/sync/workspace/periodic.rs | 2 +- core/src/sync/workspace/watcher.rs | 2 +- core/src/tool_memory/mod.rs | 8 +-- core/src/tool_memory/store.rs | 2 +- core/src/traits.rs | 2 +- core/src/tree/graph/bfs.rs | 6 +- core/src/tree/graph/store.rs | 14 ++-- core/src/tree/health/mod.rs | 4 +- core/src/tree/ingest.rs | 6 +- core/src/tree/mod.rs | 2 +- core/src/tree/retrieval/benchmarks.rs | 2 +- core/src/tree/retrieval/cover.rs | 4 +- core/src/tree/retrieval/drill_down.rs | 4 +- core/src/tree/retrieval/engine.rs | 2 +- core/src/tree/retrieval/fast.rs | 6 +- core/src/tree/retrieval/fetch.rs | 6 +- core/src/tree/retrieval/integration_tests.rs | 2 +- core/src/tree/retrieval/search.rs | 4 +- core/src/tree/retrieval/source.rs | 8 +-- core/src/tree/retrieval/source_scope_tests.rs | 2 +- core/src/tree/retrieval/types.rs | 2 +- core/src/tree/score/extract/mod.rs | 12 ++-- core/src/tree/score/mod.rs | 14 ++-- core/src/tree/score/store.rs | 26 +++---- core/src/tree/summarise.rs | 14 ++-- core/src/tree/tree/bucket_seal.rs | 16 ++--- core/src/tree/tree/factory.rs | 12 ++-- core/src/tree/tree/flush.rs | 2 +- core/src/tree/tree_runtime/engine.rs | 17 ++--- core/src/tree/tree_runtime/mod.rs | 2 +- core/src/tree/tree_runtime/store.rs | 44 ++++++------ crates/tinymemory-module/Cargo.lock | 1 + crates/tinymemory-module/Cargo.toml | 8 +++ 108 files changed, 500 insertions(+), 406 deletions(-) create mode 100644 core/src/engine/backend.rs rename core/src/{tinycortex => engine}/chat.rs (100%) rename core/src/{tinycortex => engine}/config.rs (100%) rename core/src/{tinycortex => engine}/embeddings.rs (100%) rename core/src/{tinycortex => engine}/ingest.rs (100%) rename core/src/{tinycortex => engine}/mod.rs (99%) rename core/src/{tinycortex => engine}/parity.rs (100%) rename core/src/{tinycortex => engine}/persona.rs (100%) rename core/src/{tinycortex => engine}/queue_driver.rs (100%) rename core/src/{tinycortex => engine}/seal.rs (100%) rename core/src/{tinycortex => engine}/summariser.rs (100%) rename core/src/{tinycortex => engine}/sync.rs (100%) diff --git a/core/src/conversations/blocking.rs b/core/src/conversations/blocking.rs index eda10aa..3e18309 100644 --- a/core/src/conversations/blocking.rs +++ b/core/src/conversations/blocking.rs @@ -1,7 +1,7 @@ //! Async wrappers that run the conversation store's **blocking** operations on //! tokio's blocking pool (#5156). //! -//! Every `tinycortex::memory::conversations` entry point is synchronous, and +//! Every `crate::engine::backend::conversations` entry point is synchronous, and //! each one takes the process-global `CONVERSATION_STORE_LOCK` — a //! `parking_lot::Mutex` — and then does fsync'd JSONL file IO while holding it. //! Calling one directly from an `async fn` therefore parks a tokio **worker** @@ -35,9 +35,9 @@ use std::path::PathBuf; -use tinycortex::memory::conversations as store; +use crate::engine::backend::conversations as store; -use tinycortex::memory::conversations::{ +use crate::engine::backend::conversations::{ ConversationMessage, ConversationMessagePatch, ConversationPurgeStats, ConversationStore, ConversationThread, CreateConversationThread, CrossThreadHit, }; diff --git a/core/src/conversations/mod.rs b/core/src/conversations/mod.rs index a6b5491..663dfb7 100644 --- a/core/src/conversations/mod.rs +++ b/core/src/conversations/mod.rs @@ -4,7 +4,7 @@ //! append-only in `threads.jsonl`; each thread's messages in a dedicated JSONL //! file). The store / inverted-index / tokenizer / types engine is the crate's //! (a byte-identical port, incl. the D1 rank-before-materialize fix), and -//! consumers name `tinycortex::memory::conversations` directly — this module no +//! consumers name `crate::engine::backend::conversations` directly — this module no //! longer re-exports that surface under a second path. //! //! Host-retained: diff --git a/core/src/diff/mod.rs b/core/src/diff/mod.rs index 31b3f13..e7adb6e 100644 --- a/core/src/diff/mod.rs +++ b/core/src/diff/mod.rs @@ -14,12 +14,12 @@ //! the ledger is a derived view used purely for change tracking. //! //! W7: the snapshot/diff/checkpoint/ledger engine is now -//! `tinycortex::memory::diff::DiffEngine` (a byte-identical port over the same +//! `crate::engine::backend::diff::DiffEngine` (a byte-identical port over the same //! `/memory_diff/repo` git layout). This module is a thin host shim: //! [`ops`] async-wraps the engine, [`source`] supplies the chunk-store item //! seam (`DiffEngine`'s `SnapshotItemSource`), and `rpc`/`schemas`/`tools` //! keep the RPC + agent surface. The wire types are the crate's, named directly -//! (`tinycortex::memory::diff::types`) rather than through a host re-export +//! (`crate::engine::backend::diff::types`) rather than through a host re-export //! module. //! //! Features: @@ -65,7 +65,7 @@ pub mod source; // `memory::diff::{types, source}` are serde-only wire types and stay compiled; // only the git-touching `ledger`/`DiffEngine` half sits behind `git-diff`. A // stub copy would be a second definition of one serde shape, free to drift. -pub use tinycortex::memory::diff::types::{ +pub use crate::engine::backend::diff::types::{ ChangeKind, Checkpoint, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, Snapshot, SnapshotTrigger, }; diff --git a/core/src/diff/ops.rs b/core/src/diff/ops.rs index 8c50e8b..d56b401 100644 --- a/core/src/diff/ops.rs +++ b/core/src/diff/ops.rs @@ -1,5 +1,5 @@ //! Business logic for memory diff — thin host async wrappers over -//! `tinycortex::memory::diff::DiffEngine` (W7). +//! `crate::engine::backend::diff::DiffEngine` (W7). //! //! The snapshot/diff/checkpoint/ledger engine is the crate's; the git ledger it //! writes lives at the same `/memory_diff/repo` path with the same @@ -16,10 +16,10 @@ use tinymemory_api::host::test_support::TestHostConfig; use crate::sources::types::MemorySourceEntry; use crate::Config; -use tinycortex::memory::diff::{DiffEngine, SourceDescriptor}; +use crate::engine::backend::diff::{DiffEngine, SourceDescriptor}; use super::source::ChunkStoreItemSource; -use tinycortex::memory::diff::types::*; +use crate::engine::backend::diff::types::*; /// A crate [`SourceDescriptor`] from a host source entry. fn descriptor(source: &MemorySourceEntry) -> SourceDescriptor { @@ -101,7 +101,7 @@ pub async fn list_snapshots( let source_id = source_id.map(str::to_string); tokio::task::spawn_blocking(move || -> anyhow::Result> { - let ledger = tinycortex::memory::diff::Ledger::open(&workspace_dir)?; + let ledger = crate::engine::backend::diff::Ledger::open(&workspace_dir)?; ledger.list_snapshots(source_id.as_deref(), limit) }) .await @@ -310,7 +310,7 @@ pub async fn cleanup(config: &Config, older_than_days: u32) -> Result TestHostConfig { crate::test_seams::init(); diff --git a/core/src/diff/source.rs b/core/src/diff/source.rs index 2baff36..149ef34 100644 --- a/core/src/diff/source.rs +++ b/core/src/diff/source.rs @@ -1,6 +1,6 @@ //! The host implementation of the crate diff engine's chunk-source seam. //! -//! `tinycortex::memory::diff::DiffEngine` is generic over a +//! `crate::engine::backend::diff::DiffEngine` is generic over a //! [`SnapshotItemSource`]: during //! `take_snapshot` (directly, and transitively from `create_checkpoint` for any //! source lacking a baseline) it asks the source for a source's already-ingested @@ -22,7 +22,7 @@ use std::collections::HashMap; use std::sync::Arc; -use tinycortex::memory::diff::{extract_item_id, SnapshotItem, SnapshotItemSource}; +use crate::engine::backend::diff::{extract_item_id, SnapshotItem, SnapshotItemSource}; #[cfg(test)] use tinymemory_api::host::test_support::TestHostConfig; diff --git a/core/src/diff/stub.rs b/core/src/diff/stub.rs index f5658c8..fb49b35 100644 --- a/core/src/diff/stub.rs +++ b/core/src/diff/stub.rs @@ -26,7 +26,7 @@ use crate::sources::types::MemorySourceEntry; use crate::Config; -use tinycortex::memory::diff::types::{Checkpoint, CrossSourceDiff, Snapshot}; +use crate::engine::backend::diff::types::{Checkpoint, CrossSourceDiff, Snapshot}; /// The message every disabled entry point returns. /// diff --git a/core/src/engine/backend.rs b/core/src/engine/backend.rs new file mode 100644 index 0000000..c5485d7 --- /dev/null +++ b/core/src/engine/backend.rs @@ -0,0 +1,59 @@ +//! The engine's own memory surface, reached through one door. +//! +//! Issue #18 §C1 asks that nothing outside this module name the `tinycortex` +//! crate. Eighty-three files did, in two hundred and ninety-six places, which +//! made the engine's shape an ambient fact of the whole crate rather than a +//! dependency anyone had chosen — and made "what would a second engine have to +//! provide?" a question no one could answer without reading all of them. +//! +//! Re-exporting rather than wrapping is deliberate. A wrapper layer over three +//! hundred call sites would be a second surface to keep in step with the first, +//! which is the failure §A1 had just finished deleting. What this buys is not +//! insulation from the engine's API — the call sites still use it verbatim — +//! but a single place that *names* it, so the coupling is enumerable: this file +//! is the list of everything `tinymemory-core` needs an engine to provide. +//! +//! # Why this is not `MemoryProvider` +//! +//! §A3 proposes routing these call sites through `&dyn MemoryProvider` instead. +//! That is not possible, and the reason is worth recording where the next +//! reader will find it. Core does not *consume* the engine's memory API; it +//! shares the engine's SQLite database. Thirty-four files here hold a +//! `rusqlite::Transaction` or a `Connection`, and the entry points they call +//! take them: +//! +//! ```text +//! upsert_buffer_tx(tx: &Transaction<'_>, buf: &Buffer) -> Result<()> +//! shared_connection(config: &MemoryConfig) -> Result>> +//! ``` +//! +//! Serving those through the contract would put `rusqlite` in +//! `tinymemory-api`, which its own manifest forbids and CI now enforces. The +//! honest description is that core and the engine co-implement one store, and +//! separating them is a decomposition rather than a routing change. +//! +//! Nested under a module rather than re-exported flat because the seam already +//! has its own `ingest` and `sync` modules, which are host-side pieces and +//! not the engine's. + +// The engine's submodules. +pub use tinycortex::memory::{ + archivist, chunks, conversations, diff, graph, health, ingest, people, queue, retrieval, score, + sources, store, sync, tool_memory, tree, types, +}; + +// …and the items it re-exports at its own top level, which call sites reach for +// by the same short paths. Listed rather than globbed so this file stays the +// enumerable answer to "what does core need an engine to provide". +pub use tinycortex::memory::{ + GraphRelationRecord, InMemoryMemoryStore, MemoryCategory, MemoryConfig, MemoryEngineError, + MemoryEngineResult, MemoryEntry, MemoryId, MemoryInput, MemoryItemKind, MemoryKvRecord, + MemoryQuery, MemoryRecord, MemoryResult, MemoryStore, MemoryTaint, NamespaceDocumentInput, + NamespaceMemoryHit, NamespaceQueryResult, NamespaceRetrievalContext, NamespaceSummary, + RecallOpts, RetrievalScoreBreakdown, SearchHit, StoreError, StoredMemoryDocument, + WeightProfile, GLOBAL_NAMESPACE, +}; + +// The storage trait itself. Since §A2 this is the contract's trait, not the +// engine's — the engine re-exports the same one. +pub use tinycortex::memory::Memory; diff --git a/core/src/tinycortex/chat.rs b/core/src/engine/chat.rs similarity index 100% rename from core/src/tinycortex/chat.rs rename to core/src/engine/chat.rs diff --git a/core/src/tinycortex/config.rs b/core/src/engine/config.rs similarity index 100% rename from core/src/tinycortex/config.rs rename to core/src/engine/config.rs diff --git a/core/src/tinycortex/embeddings.rs b/core/src/engine/embeddings.rs similarity index 100% rename from core/src/tinycortex/embeddings.rs rename to core/src/engine/embeddings.rs diff --git a/core/src/tinycortex/ingest.rs b/core/src/engine/ingest.rs similarity index 100% rename from core/src/tinycortex/ingest.rs rename to core/src/engine/ingest.rs diff --git a/core/src/tinycortex/mod.rs b/core/src/engine/mod.rs similarity index 99% rename from core/src/tinycortex/mod.rs rename to core/src/engine/mod.rs index 6c4f116..a6613e3 100644 --- a/core/src/tinycortex/mod.rs +++ b/core/src/engine/mod.rs @@ -46,6 +46,8 @@ mod seal; mod summariser; mod sync; +pub mod backend; + pub use chat::{build_chat_provider, SeamChatProvider}; pub use config::{engine_config, memory_config_from}; pub use embeddings::SeamEmbedder; diff --git a/core/src/tinycortex/parity.rs b/core/src/engine/parity.rs similarity index 100% rename from core/src/tinycortex/parity.rs rename to core/src/engine/parity.rs diff --git a/core/src/tinycortex/persona.rs b/core/src/engine/persona.rs similarity index 100% rename from core/src/tinycortex/persona.rs rename to core/src/engine/persona.rs diff --git a/core/src/tinycortex/queue_driver.rs b/core/src/engine/queue_driver.rs similarity index 100% rename from core/src/tinycortex/queue_driver.rs rename to core/src/engine/queue_driver.rs diff --git a/core/src/tinycortex/seal.rs b/core/src/engine/seal.rs similarity index 100% rename from core/src/tinycortex/seal.rs rename to core/src/engine/seal.rs diff --git a/core/src/tinycortex/summariser.rs b/core/src/engine/summariser.rs similarity index 100% rename from core/src/tinycortex/summariser.rs rename to core/src/engine/summariser.rs diff --git a/core/src/tinycortex/sync.rs b/core/src/engine/sync.rs similarity index 100% rename from core/src/tinycortex/sync.rs rename to core/src/engine/sync.rs diff --git a/core/src/ingest_pipeline.rs b/core/src/ingest_pipeline.rs index f3fcd2e..3d87d5c 100644 --- a/core/src/ingest_pipeline.rs +++ b/core/src/ingest_pipeline.rs @@ -2,16 +2,16 @@ use anyhow::Result; -use crate::store::chunks::store::RawRef; -use crate::Config; -use tinycortex::memory::ingest::canonicalize::{ +use crate::engine::backend::ingest::canonicalize::{ chat::{self, ChatBatch}, document::{self, DocumentInput}, email::{self, EmailThread}, CanonicalisedSource, }; +use crate::store::chunks::store::RawRef; +use crate::Config; -pub use tinycortex::memory::ingest::IngestSummary as IngestResult; +pub use crate::engine::backend::ingest::IngestSummary as IngestResult; pub async fn ingest_chat( config: &Config, @@ -22,8 +22,8 @@ pub async fn ingest_chat( ) -> Result { let canonical = chat::canonicalise(source_id, owner, &tags, batch.clone()).map_err(anyhow::Error::msg)?; - let (memory, sink, scoring) = crate::tinycortex::ingest_context(config); - let result = tinycortex::memory::ingest::ingest_chat( + let (memory, sink, scoring) = crate::engine::ingest_context(config); + let result = crate::engine::backend::ingest::ingest_chat( &memory, source_id, owner, tags, batch, &sink, &scoring, ) .await?; @@ -40,8 +40,8 @@ pub async fn ingest_email( ) -> Result { let canonical = email::canonicalise(source_id, owner, &tags, thread.clone()).map_err(anyhow::Error::msg)?; - let (memory, sink, scoring) = crate::tinycortex::ingest_context(config); - let result = tinycortex::memory::ingest::ingest_email( + let (memory, sink, scoring) = crate::engine::ingest_context(config); + let result = crate::engine::backend::ingest::ingest_email( &memory, source_id, owner, tags, thread, &sink, &scoring, ) .await?; @@ -59,8 +59,8 @@ pub async fn ingest_email_with_raw_refs( ) -> Result { let canonical = email::canonicalise(source_id, owner, &tags, thread.clone()).map_err(anyhow::Error::msg)?; - let (memory, sink, scoring) = crate::tinycortex::ingest_context(config); - let result = tinycortex::memory::ingest::ingest_email_with_raw_refs( + let (memory, sink, scoring) = crate::engine::ingest_context(config); + let result = crate::engine::backend::ingest::ingest_email_with_raw_refs( &memory, source_id, owner, tags, thread, raw_refs, &sink, &scoring, ) .await?; @@ -101,8 +101,8 @@ pub async fn ingest_document_versioned( let canonical = document::canonicalise(source_id, owner, &tags, doc.clone(), path_scope.clone()) .map_err(anyhow::Error::msg)?; - let (memory, sink, scoring) = crate::tinycortex::ingest_context(config); - let result = tinycortex::memory::ingest::ingest_document_versioned( + let (memory, sink, scoring) = crate::engine::ingest_context(config); + let result = crate::engine::backend::ingest::ingest_document_versioned( &memory, source_id, owner, tags, doc, path_scope, version_ms, &sink, &scoring, ) .await?; diff --git a/core/src/ingestion/mod.rs b/core/src/ingestion/mod.rs index 9ee1fb8..16b9714 100644 --- a/core/src/ingestion/mod.rs +++ b/core/src/ingestion/mod.rs @@ -14,12 +14,12 @@ pub mod queue; pub mod state; -pub use queue::{IngestionJob, IngestionQueue, DEFAULT_QUEUE_CAPACITY}; -pub use state::{IngestionState, IngestionStatusSnapshot}; -pub use tinycortex::memory::ingest::{ +pub use crate::engine::backend::ingest::{ ExtractedEntity, ExtractedRelation, ExtractionMode, MemoryIngestionConfig, MemoryIngestionRequest, MemoryIngestionResult, DEFAULT_MEMORY_EXTRACTION_MODEL, }; +pub use queue::{IngestionJob, IngestionQueue, DEFAULT_QUEUE_CAPACITY}; +pub use state::{IngestionState, IngestionStatusSnapshot}; use serde_json::json; @@ -35,7 +35,7 @@ impl UnifiedMemory { request: MemoryIngestionRequest, ) -> Result { let (enriched_input, mut extraction) = - tinycortex::memory::ingest::extract_enriched_document( + crate::engine::backend::ingest::extract_enriched_document( &request.document, &request.config, ); @@ -62,7 +62,7 @@ impl UnifiedMemory { config: &MemoryIngestionConfig, ) -> Result { let (_enriched, mut extraction) = - tinycortex::memory::ingest::extract_enriched_document(document, config); + crate::engine::backend::ingest::extract_enriched_document(document, config); let namespace = Self::sanitize_namespace(&document.namespace); self.upsert_graph_relations(&namespace, document_id, &extraction, config) diff --git a/core/src/lib.rs b/core/src/lib.rs index 1935dcd..4455dd7 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -39,6 +39,7 @@ pub mod conversations; pub mod diff; pub mod embedding_adapter; pub mod embedding_host; +pub mod engine; pub mod events; pub mod global; pub mod ingest_pipeline; @@ -63,7 +64,6 @@ pub mod test_env_lock; #[cfg(test)] pub(crate) mod test_seams; pub mod thread_context; -pub mod tinycortex; pub mod tool_memory; pub mod traits; pub mod tree; diff --git a/core/src/people/mod.rs b/core/src/people/mod.rs index feccd58..e3507e0 100644 --- a/core/src/people/mod.rs +++ b/core/src/people/mod.rs @@ -2,7 +2,7 @@ //! //! # Why this is a shim //! -//! The implementation moved down into [`tinycortex::memory::people`]. People is +//! The implementation moved down into [`crate::engine::backend::people`]. People is //! *storage*: a SQLite database of people, handle aliases and interactions, //! with its own migrations and its own workspace-keyed connection. Storage //! belongs to the engine, which is what lets the memory contract stay @@ -14,7 +14,7 @@ //! references to `people::types`, did not all have to move in the same change. //! //! This mirrors [`crate::store::chunks`], which has related the same way to -//! `tinycortex::memory::chunks` since the engine seam was drawn. +//! `crate::engine::backend::chunks` since the engine seam was drawn. //! //! # The address book rides two gates //! @@ -24,4 +24,6 @@ //! stub returns an empty contact list, so a refresh seeds nothing rather than //! failing. -pub use tinycortex::memory::people::{address_book, migrations, resolver, scorer, store, types}; +pub use crate::engine::backend::people::{ + address_book, migrations, resolver, scorer, store, types, +}; diff --git a/core/src/queue/ops.rs b/core/src/queue/ops.rs index 56468b1..4276d4f 100644 --- a/core/src/queue/ops.rs +++ b/core/src/queue/ops.rs @@ -7,14 +7,14 @@ /// Mark whether a re-embed backfill currently has pending work. pub fn set_backfill_in_progress(v: bool) { - tinycortex::memory::queue::set_backfill_in_progress(v); + crate::engine::backend::queue::set_backfill_in_progress(v); } /// True while a re-embed backfill chain still has rows to process. The /// #1365 absence-reasoning consumer checks this before treating an empty /// semantic-recall result as "no memory exists". pub fn backfill_in_progress() -> bool { - tinycortex::memory::queue::backfill_in_progress() + crate::engine::backend::queue::backfill_in_progress() } /// #1574 §4: ensure a re-embed backfill chain exists for the **current** @@ -30,9 +30,10 @@ pub fn backfill_in_progress() -> bool { /// covered space enqueues nothing. Errors are logged, never propagated — /// a failed enqueue must not fail the user's settings save. pub fn ensure_reembed_backfill(config: &crate::Config) { - let memory = crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()); - let delegates = crate::tinycortex::HostQueueDelegates::new(config.to_arc()); - if let Err(error) = tinycortex::memory::queue::ensure_reembed_backfill(&memory, &delegates) { + let memory = crate::engine::memory_config_from(config, config.workspace_dir().clone()); + let delegates = crate::engine::HostQueueDelegates::new(config.to_arc()); + if let Err(error) = crate::engine::backend::queue::ensure_reembed_backfill(&memory, &delegates) + { log::warn!("[memory::jobs] ensure_reembed_backfill failed: {error:#}"); } } diff --git a/core/src/queue/scheduler.rs b/core/src/queue/scheduler.rs index aa01e88..54435c9 100644 --- a/core/src/queue/scheduler.rs +++ b/core/src/queue/scheduler.rs @@ -43,8 +43,8 @@ pub fn start(config: Arc) { /// Unrecoverable failures stay parked — see /// [`store::requeue_transient_failed`]. fn retry_transient_failures(config: &Config) { - let memory = crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()); - match tinycortex::memory::queue::scheduler::self_heal(&memory) { + let memory = crate::engine::memory_config_from(config, config.workspace_dir().clone()); + match crate::engine::backend::queue::scheduler::self_heal(&memory) { Ok(0) => {} Ok(n) => { log::info!("[memory::jobs] periodic retry requeued {n} transient-failed job(s)"); @@ -72,8 +72,8 @@ fn retry_transient_failures(config: &Config) { /// `LabelStrategy` for every tree, which no production caller uses and which /// would apply one tree kind's labelling to all of them. pub fn enqueue_flush_stale_job(config: &Config) -> Result { - let memory = crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()); - match tinycortex::memory::queue::scheduler::enqueue_flush_stale(&memory) { + let memory = crate::engine::memory_config_from(config, config.workspace_dir().clone()); + match crate::engine::backend::queue::scheduler::enqueue_flush_stale(&memory) { Ok(Some(_)) => { super::worker::wake_workers(); Ok(true) diff --git a/core/src/queue/store.rs b/core/src/queue/store.rs index 2ad8d55..fd3c223 100644 --- a/core/src/queue/store.rs +++ b/core/src/queue/store.rs @@ -7,28 +7,28 @@ use crate::tree::health::PipelineFailure; use crate::Config; use super::types::{Job, JobFailure, JobStatus, NewJob}; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; -pub use tinycortex::memory::queue::DEFAULT_LOCK_DURATION_MS; +pub use crate::engine::backend::queue::DEFAULT_LOCK_DURATION_MS; pub fn enqueue(config: &Config, job: &NewJob) -> Result> { - tinycortex::memory::queue::enqueue(&engine_config(config), job) + crate::engine::backend::queue::enqueue(&engine_config(config), job) } pub fn enqueue_tx(tx: &Transaction<'_>, job: &NewJob) -> Result> { - tinycortex::memory::queue::enqueue_tx(tx, job) + crate::engine::backend::queue::enqueue_tx(tx, job) } pub fn claim_next(config: &Config, lock_duration_ms: i64) -> Result> { - tinycortex::memory::queue::claim_next(&engine_config(config), lock_duration_ms) + crate::engine::backend::queue::claim_next(&engine_config(config), lock_duration_ms) } pub fn mark_done(config: &Config, job: &Job) -> Result<()> { - tinycortex::memory::queue::mark_done(&engine_config(config), job) + crate::engine::backend::queue::mark_done(&engine_config(config), job) } pub fn mark_failed(config: &Config, job: &Job, error: &str) -> Result<()> { - tinycortex::memory::queue::mark_failed(&engine_config(config), job, error) + crate::engine::backend::queue::mark_failed(&engine_config(config), job, error) } pub fn mark_failed_typed( @@ -41,7 +41,7 @@ pub fn mark_failed_typed( code: failure.code.as_str(), class: failure.class.as_str(), }); - tinycortex::memory::queue::mark_failed_typed( + crate::engine::backend::queue::mark_failed_typed( &engine_config(config), job, error, @@ -50,41 +50,41 @@ pub fn mark_failed_typed( } pub fn mark_deferred(config: &Config, job: &Job, until_ms: i64, reason: &str) -> Result<()> { - tinycortex::memory::queue::mark_deferred(&engine_config(config), job, until_ms, reason) + crate::engine::backend::queue::mark_deferred(&engine_config(config), job, until_ms, reason) } pub fn recover_stale_locks(config: &Config) -> Result { - tinycortex::memory::queue::recover_stale_locks(&engine_config(config)) + crate::engine::backend::queue::recover_stale_locks(&engine_config(config)) } pub fn requeue_failed(config: &Config) -> Result { - tinycortex::memory::queue::requeue_failed(&engine_config(config)) + crate::engine::backend::queue::requeue_failed(&engine_config(config)) } pub fn requeue_transient_failed(config: &Config) -> Result { - tinycortex::memory::queue::requeue_transient_failed(&engine_config(config)) + crate::engine::backend::queue::requeue_transient_failed(&engine_config(config)) } pub fn release_running_locks(config: &Config) -> Result { - tinycortex::memory::queue::release_running_locks(&engine_config(config)) + crate::engine::backend::queue::release_running_locks(&engine_config(config)) } pub fn count_by_status(config: &Config, status: JobStatus) -> Result { - tinycortex::memory::queue::count_by_status(&engine_config(config), status) + crate::engine::backend::queue::count_by_status(&engine_config(config), status) } pub fn count_failed_unrecoverable(config: &Config) -> Result { - tinycortex::memory::queue::count_failed_unrecoverable(&engine_config(config)) + crate::engine::backend::queue::count_failed_unrecoverable(&engine_config(config)) } pub fn count_total(config: &Config) -> Result { - tinycortex::memory::queue::count_total(&engine_config(config)) + crate::engine::backend::queue::count_total(&engine_config(config)) } pub fn retry_all_failed(config: &Config) -> Result { - tinycortex::memory::queue::retry_all_failed(&engine_config(config)) + crate::engine::backend::queue::retry_all_failed(&engine_config(config)) } pub fn get_job(config: &Config, id: &str) -> Result> { - tinycortex::memory::queue::get_job(&engine_config(config), id) + crate::engine::backend::queue::get_job(&engine_config(config), id) } diff --git a/core/src/queue/types.rs b/core/src/queue/types.rs index fc87750..3b91b2d 100644 --- a/core/src/queue/types.rs +++ b/core/src/queue/types.rs @@ -1,6 +1,6 @@ //! Queue wire types owned by tinycortex. -pub use tinycortex::memory::queue::{ +pub use crate::engine::backend::queue::{ AppendBufferPayload, AppendTarget, ExtractChunkPayload, FlushStalePayload, Job, JobFailure, JobKind, JobOutcome, JobStatus, NewJob, NodeRef, ReembedBackfillPayload, SealDocumentPayload, SealPayload, diff --git a/core/src/queue/worker.rs b/core/src/queue/worker.rs index 811c540..ebb5b70 100644 --- a/core/src/queue/worker.rs +++ b/core/src/queue/worker.rs @@ -1,6 +1,6 @@ //! Worker pool: drives the crate queue engine (W4 flip). Each `run_once` -//! delegates claim → dispatch → settle to `tinycortex::memory::queue::run_once` -//! via [`crate::tinycortex::HostQueueDelegates`]; the legacy host +//! delegates claim → dispatch → settle to `crate::engine::backend::queue::run_once` +//! via [`crate::engine::HostQueueDelegates`]; the legacy host //! `handlers` engine that used to own dispatch was deleted at the flip. //! //! Concurrency control for LLM-bound work is delegated to @@ -288,9 +288,9 @@ pub async fn run_once(config: &Config) -> Result { // single-slot LLM gate serialises llm-bound jobs; the legacy per-job // local/cloud permit routing and the extract-batch coalescing are // intentionally dropped here (perf, not correctness — W4 follow-up). - let mc = crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()); - let delegates = crate::tinycortex::HostQueueDelegates::new(config.to_arc()); - tinycortex::memory::queue::run_once(&mc, &delegates).await + let mc = crate::engine::memory_config_from(config, config.workspace_dir().clone()); + let delegates = crate::engine::HostQueueDelegates::new(config.to_arc()); + crate::engine::backend::queue::run_once(&mc, &delegates).await } /// Classify whether an error is a transient I/O failure that should be diff --git a/core/src/sources/readers/conversation.rs b/core/src/sources/readers/conversation.rs index 7b18c40..2380821 100644 --- a/core/src/sources/readers/conversation.rs +++ b/core/src/sources/readers/conversation.rs @@ -19,10 +19,10 @@ impl SourceReader for ConversationReader { source: &MemorySourceEntry, config: &Config, ) -> Result, String> { - tinycortex::memory::sources::SourceReader::list_items( - &tinycortex::memory::sources::readers::conversation::ConversationReader, + crate::engine::backend::sources::SourceReader::list_items( + &crate::engine::backend::sources::readers::conversation::ConversationReader, source, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) @@ -34,11 +34,11 @@ impl SourceReader for ConversationReader { item_id: &str, config: &Config, ) -> Result { - tinycortex::memory::sources::SourceReader::read_item( - &tinycortex::memory::sources::readers::conversation::ConversationReader, + crate::engine::backend::sources::SourceReader::read_item( + &crate::engine::backend::sources::readers::conversation::ConversationReader, source, item_id, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) diff --git a/core/src/sources/readers/folder.rs b/core/src/sources/readers/folder.rs index 31922d5..56bbbfb 100644 --- a/core/src/sources/readers/folder.rs +++ b/core/src/sources/readers/folder.rs @@ -19,10 +19,10 @@ impl SourceReader for FolderReader { source: &MemorySourceEntry, config: &Config, ) -> Result, String> { - tinycortex::memory::sources::SourceReader::list_items( - &tinycortex::memory::sources::readers::folder::FolderReader, + crate::engine::backend::sources::SourceReader::list_items( + &crate::engine::backend::sources::readers::folder::FolderReader, source, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) @@ -34,11 +34,11 @@ impl SourceReader for FolderReader { item_id: &str, config: &Config, ) -> Result { - tinycortex::memory::sources::SourceReader::read_item( - &tinycortex::memory::sources::readers::folder::FolderReader, + crate::engine::backend::sources::SourceReader::read_item( + &crate::engine::backend::sources::readers::folder::FolderReader, source, item_id, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) diff --git a/core/src/sources/readers/github.rs b/core/src/sources/readers/github.rs index 91d464d..b35af29 100644 --- a/core/src/sources/readers/github.rs +++ b/core/src/sources/readers/github.rs @@ -12,7 +12,9 @@ use crate::sources::readers::SourceReader; use crate::sources::types::{MemorySourceEntry, SourceContent, SourceItem, SourceKind}; use crate::Config; -pub use tinycortex::memory::sources::readers::github::{repo_archive_source_id, repo_chunk_scope}; +pub use crate::engine::backend::sources::readers::github::{ + repo_archive_source_id, repo_chunk_scope, +}; pub struct GithubReader; @@ -27,10 +29,10 @@ impl SourceReader for GithubReader { source: &MemorySourceEntry, config: &Config, ) -> Result, String> { - tinycortex::memory::sources::SourceReader::list_items( - &tinycortex::memory::sources::readers::github::GithubReader, + crate::engine::backend::sources::SourceReader::list_items( + &crate::engine::backend::sources::readers::github::GithubReader, source, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) @@ -42,11 +44,11 @@ impl SourceReader for GithubReader { item_id: &str, config: &Config, ) -> Result { - tinycortex::memory::sources::SourceReader::read_item( - &tinycortex::memory::sources::readers::github::GithubReader, + crate::engine::backend::sources::SourceReader::read_item( + &crate::engine::backend::sources::readers::github::GithubReader, source, item_id, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) diff --git a/core/src/sources/readers/rss.rs b/core/src/sources/readers/rss.rs index 05a543a..d6213a2 100644 --- a/core/src/sources/readers/rss.rs +++ b/core/src/sources/readers/rss.rs @@ -12,13 +12,13 @@ use crate::Config; /// `read_item`, so constructing it per trait call would turn one sync into /// N+1 downloads. pub struct RssReader { - inner: tinycortex::memory::sources::readers::rss::RssReader, + inner: crate::engine::backend::sources::readers::rss::RssReader, } impl RssReader { pub fn new() -> Self { Self { - inner: tinycortex::memory::sources::readers::rss::RssReader::new(), + inner: crate::engine::backend::sources::readers::rss::RssReader::new(), } } } @@ -40,10 +40,10 @@ impl SourceReader for RssReader { source: &MemorySourceEntry, config: &Config, ) -> Result, String> { - tinycortex::memory::sources::SourceReader::list_items( + crate::engine::backend::sources::SourceReader::list_items( &self.inner, source, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) @@ -55,11 +55,11 @@ impl SourceReader for RssReader { item_id: &str, config: &Config, ) -> Result { - tinycortex::memory::sources::SourceReader::read_item( + crate::engine::backend::sources::SourceReader::read_item( &self.inner, source, item_id, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) diff --git a/core/src/sources/readers/web_page.rs b/core/src/sources/readers/web_page.rs index a2f693c..69723c8 100644 --- a/core/src/sources/readers/web_page.rs +++ b/core/src/sources/readers/web_page.rs @@ -19,10 +19,10 @@ impl SourceReader for WebPageReader { source: &MemorySourceEntry, config: &Config, ) -> Result, String> { - tinycortex::memory::sources::SourceReader::list_items( - &tinycortex::memory::sources::readers::web_page::WebPageReader, + crate::engine::backend::sources::SourceReader::list_items( + &crate::engine::backend::sources::readers::web_page::WebPageReader, source, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) @@ -34,11 +34,11 @@ impl SourceReader for WebPageReader { item_id: &str, config: &Config, ) -> Result { - tinycortex::memory::sources::SourceReader::read_item( - &tinycortex::memory::sources::readers::web_page::WebPageReader, + crate::engine::backend::sources::SourceReader::read_item( + &crate::engine::backend::sources::readers::web_page::WebPageReader, source, item_id, - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), ) .await .map_err(|error| error.to_string()) diff --git a/core/src/sources/registry.rs b/core/src/sources/registry.rs index a33f532..f5c90b9 100644 --- a/core/src/sources/registry.rs +++ b/core/src/sources/registry.rs @@ -5,7 +5,7 @@ use std::sync::OnceLock; use crate::config_loader as config_rpc; use crate::sources::types::{MemorySourceEntry, SourceKind}; -pub use tinycortex::memory::sources::{ +pub use crate::engine::backend::sources::{ memory_sync_defaults_for_toolkit, ComposioUpsertTarget, MemorySourcePatch, }; @@ -18,9 +18,9 @@ pub(crate) async fn memory_sources_write_guard() -> tokio::sync::MutexGuard<'sta .await } -async fn registry() -> Result { +async fn registry() -> Result { let config = config_rpc::load_config_with_timeout().await?; - Ok(tinycortex::memory::sources::SourceRegistry::new( + Ok(crate::engine::backend::sources::SourceRegistry::new( config.config_path(), )) } @@ -59,7 +59,7 @@ pub fn get_source_in( config: &crate::Config, id: &str, ) -> Result, String> { - tinycortex::memory::sources::SourceRegistry::new(config.config_path().clone()) + crate::engine::backend::sources::SourceRegistry::new(config.config_path().clone()) .get(id) .map_err(|error| error.to_string()) } diff --git a/core/src/sources/sync.rs b/core/src/sources/sync.rs index af362a0..134a6e8 100644 --- a/core/src/sources/sync.rs +++ b/core/src/sources/sync.rs @@ -89,7 +89,7 @@ pub async fn sync_source(source: MemorySourceEntry, config: Arc) -> Resu let mut composio_usage = ComposioUsage::default(); let outcome = match source.kind { SourceKind::Composio => { - match crate::tinycortex::run_source_pipeline(&source, &*config).await { + match crate::engine::run_source_pipeline(&source, &*config).await { Ok(outcome) => { composio_usage.actions_called = outcome.actions_called; composio_usage.cost_usd = outcome.provider_cost_usd; @@ -103,17 +103,17 @@ pub async fn sync_source(source: MemorySourceEntry, config: Arc) -> Resu } } SourceKind::Conversation | SourceKind::Folder => { - crate::tinycortex::run_source_pipeline(&source, &*config) + crate::engine::run_source_pipeline(&source, &*config) .await .map(|outcome| outcome.records_ingested as usize) .map_err(|error| error.to_string()) } - SourceKind::GithubRepo => crate::tinycortex::run_source_pipeline(&source, &*config) + SourceKind::GithubRepo => crate::engine::run_source_pipeline(&source, &*config) .await .map(|outcome| outcome.records_ingested as usize) .map_err(|error| error.to_string()), SourceKind::RssFeed | SourceKind::WebPage => { - crate::tinycortex::run_source_pipeline(&source, &*config) + crate::engine::run_source_pipeline(&source, &*config) .await .map(|outcome| outcome.records_ingested as usize) .map_err(|error| error.to_string()) @@ -142,7 +142,7 @@ pub async fn sync_source(source: MemorySourceEntry, config: Arc) -> Resu Some(&source.id), ); - use crate::tinycortex::{append_audit_entry, SyncAuditEntry}; + use crate::engine::{append_audit_entry, SyncAuditEntry}; append_audit_entry( &*config, &SyncAuditEntry { @@ -185,7 +185,7 @@ pub async fn sync_source(source: MemorySourceEntry, config: Arc) -> Resu } Err(error) => { // Audit failed syncs too. - use crate::tinycortex::{append_audit_entry, SyncAuditEntry}; + use crate::engine::{append_audit_entry, SyncAuditEntry}; append_audit_entry( &*config, &SyncAuditEntry { @@ -266,7 +266,7 @@ pub async fn sync_source(source: MemorySourceEntry, config: Arc) -> Resu /// Reconcile raw files that are not yet covered by tree summaries. pub(crate) async fn check_and_rebuild_tree(source: &MemorySourceEntry, config: &Config) { - use crate::tinycortex::{needs_rebuild, rebuild_tree_from_raw}; + use crate::engine::{needs_rebuild, rebuild_tree_from_raw}; for scope in derive_scopes(source, config) { if !needs_rebuild(config, &scope.tree_scope, &scope.archive_source_id) { diff --git a/core/src/sources/types.rs b/core/src/sources/types.rs index 6fbab9f..7588253 100644 --- a/core/src/sources/types.rs +++ b/core/src/sources/types.rs @@ -1,5 +1,5 @@ //! Stable host path for tinycortex-owned memory-source contracts. -pub use tinycortex::memory::sources::{ +pub use crate::engine::backend::sources::{ ContentType, MemorySourceEntry, SourceContent, SourceItem, SourceKind, }; diff --git a/core/src/store/chunks/connection.rs b/core/src/store/chunks/connection.rs index 7fe48ed..746fe6d 100644 --- a/core/src/store/chunks/connection.rs +++ b/core/src/store/chunks/connection.rs @@ -3,15 +3,15 @@ use anyhow::Result; use rusqlite::Connection; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; use crate::Config; #[doc(hidden)] pub fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { - tinycortex::memory::chunks::with_connection(&engine_config(config), f) + crate::engine::backend::chunks::with_connection(&engine_config(config), f) } pub(crate) fn recover_corrupt_db(config: &Config) -> Result { log::warn!("[memory:chunks] checking corrupt database recovery"); - tinycortex::memory::chunks::recover_corrupt_db(&engine_config(config)) + crate::engine::backend::chunks::recover_corrupt_db(&engine_config(config)) } diff --git a/core/src/store/chunks/embeddings.rs b/core/src/store/chunks/embeddings.rs index 812852a..07d6a6f 100644 --- a/core/src/store/chunks/embeddings.rs +++ b/core/src/store/chunks/embeddings.rs @@ -5,15 +5,15 @@ use std::collections::HashMap; use anyhow::Result; use rusqlite::{Connection, Transaction}; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; use crate::Config; pub(crate) fn tree_active_signature(config: &Config) -> String { - tinycortex::memory::chunks::tree_active_signature(&engine_config(config)) + crate::engine::backend::chunks::tree_active_signature(&engine_config(config)) } pub fn set_chunk_embedding(config: &Config, id: &str, embedding: &[f32]) -> Result<()> { - tinycortex::memory::chunks::set_chunk_embedding(&engine_config(config), id, embedding) + crate::engine::backend::chunks::set_chunk_embedding(&engine_config(config), id, embedding) } pub fn set_chunk_embedding_for_signature( @@ -22,7 +22,7 @@ pub fn set_chunk_embedding_for_signature( signature: &str, embedding: &[f32], ) -> Result<()> { - tinycortex::memory::chunks::set_chunk_embedding_for_signature( + crate::engine::backend::chunks::set_chunk_embedding_for_signature( &engine_config(config), id, signature, @@ -34,7 +34,7 @@ pub(crate) fn has_uncovered_reembed_work( conn: &Connection, signature: &str, ) -> rusqlite::Result { - tinycortex::memory::chunks::has_uncovered_reembed_work(conn, signature) + crate::engine::backend::chunks::has_uncovered_reembed_work(conn, signature) } pub fn mark_chunk_reembed_skipped( @@ -43,7 +43,7 @@ pub fn mark_chunk_reembed_skipped( signature: &str, reason: &str, ) -> Result<()> { - tinycortex::memory::chunks::mark_chunk_reembed_skipped( + crate::engine::backend::chunks::mark_chunk_reembed_skipped( &engine_config(config), id, signature, @@ -52,11 +52,15 @@ pub fn mark_chunk_reembed_skipped( } pub fn clear_chunk_reembed_skipped(config: &Config, id: &str, signature: &str) -> Result<()> { - tinycortex::memory::chunks::clear_chunk_reembed_skipped(&engine_config(config), id, signature) + crate::engine::backend::chunks::clear_chunk_reembed_skipped( + &engine_config(config), + id, + signature, + ) } pub fn clear_reembed_skipped_for_signature(config: &Config, signature: &str) -> Result { - tinycortex::memory::chunks::clear_reembed_skipped_for_signature( + crate::engine::backend::chunks::clear_reembed_skipped_for_signature( &engine_config(config), signature, ) @@ -68,7 +72,9 @@ pub(crate) fn set_chunk_embedding_for_signature_tx( signature: &str, embedding: &[f32], ) -> Result<()> { - tinycortex::memory::chunks::set_chunk_embedding_for_signature_tx(tx, id, signature, embedding) + crate::engine::backend::chunks::set_chunk_embedding_for_signature_tx( + tx, id, signature, embedding, + ) } pub fn get_chunk_embedding_for_signature( @@ -76,7 +82,7 @@ pub fn get_chunk_embedding_for_signature( id: &str, signature: &str, ) -> Result>> { - tinycortex::memory::chunks::get_chunk_embedding_for_signature( + crate::engine::backend::chunks::get_chunk_embedding_for_signature( &engine_config(config), id, signature, @@ -84,7 +90,7 @@ pub fn get_chunk_embedding_for_signature( } pub fn get_chunk_embedding(config: &Config, id: &str) -> Result>> { - tinycortex::memory::chunks::get_chunk_embedding(&engine_config(config), id) + crate::engine::backend::chunks::get_chunk_embedding(&engine_config(config), id) } pub fn get_chunk_embeddings_for_signature_batch( @@ -92,7 +98,7 @@ pub fn get_chunk_embeddings_for_signature_batch( ids: &[String], signature: &str, ) -> Result>> { - tinycortex::memory::chunks::get_chunk_embeddings_for_signature_batch( + crate::engine::backend::chunks::get_chunk_embeddings_for_signature_batch( &engine_config(config), ids, signature, @@ -103,5 +109,5 @@ pub fn get_chunk_embeddings_batch( config: &Config, ids: &[String], ) -> Result>> { - tinycortex::memory::chunks::get_chunk_embeddings_batch(&engine_config(config), ids) + crate::engine::backend::chunks::get_chunk_embeddings_batch(&engine_config(config), ids) } diff --git a/core/src/store/chunks/mod.rs b/core/src/store/chunks/mod.rs index 9b6fb29..4ba07f1 100644 --- a/core/src/store/chunks/mod.rs +++ b/core/src/store/chunks/mod.rs @@ -20,7 +20,7 @@ pub mod semantic; pub mod store; pub mod types; +pub use crate::engine::backend::chunks::{chunk_markdown, ChunkerInput, ChunkerOptions}; pub use semantic::chunk_markdown as chunk_semantic; pub use store::*; -pub use tinycortex::memory::chunks::{chunk_markdown, ChunkerInput, ChunkerOptions}; pub use types::*; diff --git a/core/src/store/chunks/raw_refs.rs b/core/src/store/chunks/raw_refs.rs index bef5c76..c750ece 100644 --- a/core/src/store/chunks/raw_refs.rs +++ b/core/src/store/chunks/raw_refs.rs @@ -6,7 +6,7 @@ //! directly instead of going through the SQL preview path. //! //! **W3 sub-store flip:** these operations now delegate to -//! [`tinycortex::memory::chunks`] (ported from this exact module — identical SQL +//! [`crate::engine::backend::chunks`] (ported from this exact module — identical SQL //! against the same `mem_tree_chunks` / `mem_tree_summaries` tables in the shared //! `chunks.db` the crate now owns). The host signatures are preserved so the ~4 //! external callers (`content::read`, memory_sync gmail/slack ingest, rebuild) @@ -15,27 +15,27 @@ use anyhow::Result; use rusqlite::Transaction; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; use crate::Config; // `RawRef` is re-exported from the crate (identical fields + serde derives), so // every `chunks::RawRef { path, start, end }` construction site keeps compiling. -pub use tinycortex::memory::chunks::RawRef; +pub use crate::engine::backend::chunks::RawRef; /// Stash a list of [`RawRef`] entries on a chunk row. Replaces any previous /// value. pub fn set_chunk_raw_refs(config: &Config, chunk_id: &str, refs: &[RawRef]) -> Result<()> { - tinycortex::memory::chunks::set_chunk_raw_refs(&engine_config(config), chunk_id, refs) + crate::engine::backend::chunks::set_chunk_raw_refs(&engine_config(config), chunk_id, refs) } /// Stash raw archive pointers on a chunk row inside a caller-owned transaction. pub fn set_chunk_raw_refs_tx(tx: &Transaction<'_>, chunk_id: &str, refs: &[RawRef]) -> Result<()> { - tinycortex::memory::chunks::set_chunk_raw_refs_tx(tx, chunk_id, refs) + crate::engine::backend::chunks::set_chunk_raw_refs_tx(tx, chunk_id, refs) } /// Return the raw-archive pointers stored in SQLite for `chunk_id`, or `None`. pub fn get_chunk_raw_refs(config: &Config, chunk_id: &str) -> Result>> { - tinycortex::memory::chunks::get_chunk_raw_refs(&engine_config(config), chunk_id) + crate::engine::backend::chunks::get_chunk_raw_refs(&engine_config(config), chunk_id) } /// Collect every raw-archive path referenced by any chunk row, restricted to @@ -44,7 +44,7 @@ pub fn list_chunk_raw_ref_paths_with_prefix( config: &Config, rel_prefix: &str, ) -> Result> { - tinycortex::memory::chunks::list_chunk_raw_ref_paths_with_prefix( + crate::engine::backend::chunks::list_chunk_raw_ref_paths_with_prefix( &engine_config(config), rel_prefix, ) @@ -55,12 +55,12 @@ pub fn get_chunk_content_pointers( config: &Config, chunk_id: &str, ) -> Result> { - tinycortex::memory::chunks::get_chunk_content_pointers(&engine_config(config), chunk_id) + crate::engine::backend::chunks::get_chunk_content_pointers(&engine_config(config), chunk_id) } /// Return the `content_path` stored in SQLite for `chunk_id`, if any. pub fn get_chunk_content_path(config: &Config, chunk_id: &str) -> Result> { - tinycortex::memory::chunks::get_chunk_content_path(&engine_config(config), chunk_id) + crate::engine::backend::chunks::get_chunk_content_path(&engine_config(config), chunk_id) } /// Return both `content_path` and `content_sha256` stored in SQLite for `summary_id`. @@ -68,10 +68,10 @@ pub fn get_summary_content_pointers( config: &Config, summary_id: &str, ) -> Result> { - tinycortex::memory::chunks::get_summary_content_pointers(&engine_config(config), summary_id) + crate::engine::backend::chunks::get_summary_content_pointers(&engine_config(config), summary_id) } /// List all summary rows that have a non-NULL `content_path`. pub fn list_summaries_with_content_path(config: &Config) -> Result> { - tinycortex::memory::chunks::list_summaries_with_content_path(&engine_config(config)) + crate::engine::backend::chunks::list_summaries_with_content_path(&engine_config(config)) } diff --git a/core/src/store/chunks/semantic.rs b/core/src/store/chunks/semantic.rs index a624762..b682e85 100644 --- a/core/src/store/chunks/semantic.rs +++ b/core/src/store/chunks/semantic.rs @@ -1,7 +1,7 @@ //! Compatibility exports for tinycortex's semantic Markdown chunker. -pub use tinycortex::memory::chunks::SemanticChunk as Chunk; +pub use crate::engine::backend::chunks::SemanticChunk as Chunk; pub fn chunk_markdown(text: &str, max_tokens: usize) -> Vec { - tinycortex::memory::chunks::chunk_semantic(text, max_tokens) + crate::engine::backend::chunks::chunk_semantic(text, max_tokens) } diff --git a/core/src/store/chunks/store.rs b/core/src/store/chunks/store.rs index f2f7557..47cb55c 100644 --- a/core/src/store/chunks/store.rs +++ b/core/src/store/chunks/store.rs @@ -5,34 +5,38 @@ use std::collections::HashMap; use anyhow::Result; use rusqlite::Transaction; +use crate::engine::engine_config; use crate::store::chunks::types::{Chunk, SourceKind}; use crate::store::content::StagedChunk; -use crate::tinycortex::engine_config; use crate::Config; -pub use tinycortex::memory::chunks::{ +pub use crate::engine::backend::chunks::{ ListChunksQuery, RawRef, CHUNK_STATUS_ADMITTED, CHUNK_STATUS_BUFFERED, CHUNK_STATUS_DROPPED, CHUNK_STATUS_PENDING_EXTRACTION, CHUNK_STATUS_SEALED, RAW_FILE_GATE_KIND, }; pub fn upsert_chunks(config: &Config, chunks: &[Chunk]) -> Result { - tinycortex::memory::chunks::upsert_chunks(&engine_config(config), chunks) + crate::engine::backend::chunks::upsert_chunks(&engine_config(config), chunks) } pub fn upsert_chunks_tx(tx: &Transaction<'_>, chunks: &[Chunk]) -> Result { - tinycortex::memory::chunks::upsert_chunks_tx(tx, chunks) + crate::engine::backend::chunks::upsert_chunks_tx(tx, chunks) } pub fn upsert_staged_chunks_tx(tx: &Transaction<'_>, chunks: &[StagedChunk]) -> Result { - tinycortex::memory::chunks::upsert_staged_chunks_tx(tx, chunks) + crate::engine::backend::chunks::upsert_staged_chunks_tx(tx, chunks) } pub fn update_chunk_content_sha256(config: &Config, id: &str, sha256: &str) -> Result<()> { - tinycortex::memory::chunks::update_chunk_content_sha256(&engine_config(config), id, sha256) + crate::engine::backend::chunks::update_chunk_content_sha256(&engine_config(config), id, sha256) } pub fn update_summary_content_sha256(config: &Config, id: &str, sha256: &str) -> Result<()> { - tinycortex::memory::chunks::update_summary_content_sha256(&engine_config(config), id, sha256) + crate::engine::backend::chunks::update_summary_content_sha256( + &engine_config(config), + id, + sha256, + ) } pub fn list_source_ids_with_prefix( @@ -40,31 +44,35 @@ pub fn list_source_ids_with_prefix( kind: SourceKind, prefix: &str, ) -> Result> { - tinycortex::memory::chunks::list_source_ids_with_prefix(&engine_config(config), kind, prefix) + crate::engine::backend::chunks::list_source_ids_with_prefix( + &engine_config(config), + kind, + prefix, + ) } pub fn get_chunk(config: &Config, id: &str) -> Result> { - tinycortex::memory::chunks::get_chunk(&engine_config(config), id) + crate::engine::backend::chunks::get_chunk(&engine_config(config), id) } pub fn get_chunks_batch(config: &Config, ids: &[String]) -> Result> { - tinycortex::memory::chunks::get_chunks_batch(&engine_config(config), ids) + crate::engine::backend::chunks::get_chunks_batch(&engine_config(config), ids) } pub fn list_chunks(config: &Config, query: &ListChunksQuery) -> Result> { - tinycortex::memory::chunks::list_chunks(&engine_config(config), query) + crate::engine::backend::chunks::list_chunks(&engine_config(config), query) } pub fn count_chunks(config: &Config) -> Result { - tinycortex::memory::chunks::count_chunks(&engine_config(config)) + crate::engine::backend::chunks::count_chunks(&engine_config(config)) } pub fn extraction_coverage(config: &Config) -> Result { - tinycortex::memory::chunks::extraction_coverage(&engine_config(config)) + crate::engine::backend::chunks::extraction_coverage(&engine_config(config)) } pub fn set_chunk_lifecycle_status(config: &Config, id: &str, status: &str) -> Result<()> { - tinycortex::memory::chunks::set_chunk_lifecycle_status(&engine_config(config), id, status) + crate::engine::backend::chunks::set_chunk_lifecycle_status(&engine_config(config), id, status) } pub(crate) fn set_chunk_lifecycle_status_tx( @@ -72,23 +80,23 @@ pub(crate) fn set_chunk_lifecycle_status_tx( id: &str, status: &str, ) -> Result<()> { - tinycortex::memory::chunks::set_chunk_lifecycle_status_tx(tx, id, status) + crate::engine::backend::chunks::set_chunk_lifecycle_status_tx(tx, id, status) } pub fn get_chunk_lifecycle_status(config: &Config, id: &str) -> Result> { - tinycortex::memory::chunks::get_chunk_lifecycle_status(&engine_config(config), id) + crate::engine::backend::chunks::get_chunk_lifecycle_status(&engine_config(config), id) } pub fn get_chunk_lifecycle_status_tx(tx: &Transaction<'_>, id: &str) -> Result> { - tinycortex::memory::chunks::get_chunk_lifecycle_status_tx(tx, id) + crate::engine::backend::chunks::get_chunk_lifecycle_status_tx(tx, id) } pub fn count_chunks_by_lifecycle_status(config: &Config, status: &str) -> Result { - tinycortex::memory::chunks::count_chunks_by_lifecycle_status(&engine_config(config), status) + crate::engine::backend::chunks::count_chunks_by_lifecycle_status(&engine_config(config), status) } pub fn is_source_ingested(config: &Config, kind: SourceKind, id: &str) -> Result { - tinycortex::memory::chunks::is_source_ingested(&engine_config(config), kind, id) + crate::engine::backend::chunks::is_source_ingested(&engine_config(config), kind, id) } pub fn claim_source_ingest_tx( @@ -97,23 +105,26 @@ pub fn claim_source_ingest_tx( id: &str, now_ms: i64, ) -> Result { - tinycortex::memory::chunks::claim_source_ingest_tx(tx, kind, id, now_ms) + crate::engine::backend::chunks::claim_source_ingest_tx(tx, kind, id, now_ms) } pub fn mark_raw_paths_ingested(config: &Config, paths: &[String]) -> Result { - tinycortex::memory::chunks::mark_raw_paths_ingested(&engine_config(config), paths) + crate::engine::backend::chunks::mark_raw_paths_ingested(&engine_config(config), paths) } pub fn filter_raw_paths_not_ingested(config: &Config, paths: &[String]) -> Result> { - tinycortex::memory::chunks::filter_raw_paths_not_ingested(&engine_config(config), paths) + crate::engine::backend::chunks::filter_raw_paths_not_ingested(&engine_config(config), paths) } pub fn count_raw_paths_ingested_with_prefix(config: &Config, prefix: &str) -> Result { - tinycortex::memory::chunks::count_raw_paths_ingested_with_prefix(&engine_config(config), prefix) + crate::engine::backend::chunks::count_raw_paths_ingested_with_prefix( + &engine_config(config), + prefix, + ) } pub fn delete_chunks_by_source(config: &Config, kind: SourceKind, id: &str) -> Result { - tinycortex::memory::chunks::delete_chunks_by_source(&engine_config(config), kind, id) + crate::engine::backend::chunks::delete_chunks_by_source(&engine_config(config), kind, id) } pub fn delete_chunks_by_source_prefix( @@ -121,15 +132,19 @@ pub fn delete_chunks_by_source_prefix( kind: SourceKind, prefix: &str, ) -> Result { - tinycortex::memory::chunks::delete_chunks_by_source_prefix(&engine_config(config), kind, prefix) + crate::engine::backend::chunks::delete_chunks_by_source_prefix( + &engine_config(config), + kind, + prefix, + ) } pub fn delete_chunks_by_owner(config: &Config, kind: SourceKind, owner: &str) -> Result { - tinycortex::memory::chunks::delete_chunks_by_owner(&engine_config(config), kind, owner) + crate::engine::backend::chunks::delete_chunks_by_owner(&engine_config(config), kind, owner) } pub fn delete_orphaned_source_tree(config: &Config, kind: SourceKind, id: &str) -> Result { - tinycortex::memory::chunks::delete_orphaned_source_tree(&engine_config(config), kind, id) + crate::engine::backend::chunks::delete_orphaned_source_tree(&engine_config(config), kind, id) } #[path = "connection.rs"] diff --git a/core/src/store/chunks/types.rs b/core/src/store/chunks/types.rs index bd080fc..8c25440 100644 --- a/core/src/store/chunks/types.rs +++ b/core/src/store/chunks/types.rs @@ -12,13 +12,13 @@ //! **W3 type cutover:** these types + chunk-id/token helpers are now //! **re-exported from the `tinycortex` crate** (ported from this exact module — //! identical fields, derives, serde wire form, and `chunk_id` derivation, all -//! pinned by `tinycortex::memory::chunks::types_tests`). Re-exporting keeps one source of truth and lets +//! pinned by `crate::engine::backend::chunks::types_tests`). Re-exporting keeps one source of truth and lets //! the chunk store operations delegate to the crate without host↔crate type //! conversions. `DataSource` moved with the ingest cutover and is re-exported //! here alongside the chunk types. `StagedChunk` remains host-owned in //! `memory_store::content`. -pub use tinycortex::memory::chunks::{ +pub use crate::engine::backend::chunks::{ approx_token_count, chunk_id, conservative_token_estimate, truncate_to_conservative_tokens, Chunk, DataSource, Metadata, SourceKind, SourceRef, }; diff --git a/core/src/store/client.rs b/core/src/store/client.rs index 24cd438..992946e 100644 --- a/core/src/store/client.rs +++ b/core/src/store/client.rs @@ -77,8 +77,7 @@ impl MemoryClient { /// Typed access to the profile/facet tables. /// - /// **Not guarded.** The profile tables have no capability family in the - /// thirteen-family `tinycortex_api` contract, so these reads and writes + /// **Not guarded.** These reads and writes /// still run beneath `crate::guard::MemoryGuard`'s /// seven steps. What this buys is confinement, not policy: the SQL is in /// the memory family and the compiler keeps it there. diff --git a/core/src/store/content/mod.rs b/core/src/store/content/mod.rs index 52ec477..6da0a48 100644 --- a/core/src/store/content/mod.rs +++ b/core/src/store/content/mod.rs @@ -17,13 +17,13 @@ pub mod read; pub mod tags; -pub use tinycortex::memory::chunks::StagedChunk; +pub use crate::engine::backend::chunks::StagedChunk; /// The git-backed wiki content format. Re-exported only when `memory-git` is /// on: it lives behind tinycortex's `wiki-git` feature, which the gate carries /// along with `git-diff` and the libgit2 cohort. #[cfg(feature = "memory-git")] -pub use tinycortex::memory::store::content::wiki_git; -pub use tinycortex::memory::store::content::{ +pub use crate::engine::backend::store::content::wiki_git; +pub use crate::engine::backend::store::content::{ atomic, compose, obsidian, obsidian_registry, paths, raw, stage_chunks, StagedSummary, SummaryComposeInput, SummaryTreeKind, }; diff --git a/core/src/store/content/read.rs b/core/src/store/content/read.rs index d15ee13..f745e20 100644 --- a/core/src/store/content/read.rs +++ b/core/src/store/content/read.rs @@ -1,15 +1,15 @@ //! Product Config adapters over tinycortex content readers. -use crate::tinycortex::engine_config; +use crate::engine::engine_config; -pub use tinycortex::memory::store::content::{ +pub use crate::engine::backend::store::content::{ read_chunk_file, read_summary_file, verify_chunk_file, verify_summary_file, ChunkFileContents, VerifyResult, }; pub fn read_chunk_body(config: &crate::Config, chunk_id: &str) -> anyhow::Result { - tinycortex::memory::store::content::read_chunk_body(&engine_config(config), chunk_id) + crate::engine::backend::store::content::read_chunk_body(&engine_config(config), chunk_id) } pub fn read_summary_body(config: &crate::Config, summary_id: &str) -> anyhow::Result { - tinycortex::memory::store::content::read_summary_body(&engine_config(config), summary_id) + crate::engine::backend::store::content::read_summary_body(&engine_config(config), summary_id) } diff --git a/core/src/store/content/tags.rs b/core/src/store/content/tags.rs index 92413b1..87ab188 100644 --- a/core/src/store/content/tags.rs +++ b/core/src/store/content/tags.rs @@ -13,7 +13,7 @@ use crate::store::content::compose::{ use crate::tree::score::store::list_entity_ids_for_node; use crate::Config; -pub use tinycortex::memory::store::content::tags::{ +pub use crate::engine::backend::store::content::tags::{ entity_tag, slugify_tag_kind, slugify_tag_value, update_chunk_tags, }; diff --git a/core/src/store/entities.rs b/core/src/store/entities.rs index 47024f4..5cd375a 100644 --- a/core/src/store/entities.rs +++ b/core/src/store/entities.rs @@ -2,13 +2,13 @@ use std::sync::Arc; -use anyhow::Result; -use tinycortex::memory::store::entity_index::{ +use crate::engine::backend::store::entity_index::{ CanonicalEntity, EntityIndex, EntityKind, SelfIdentity, }; +use anyhow::Result; +use crate::engine::memory_config_from; use crate::sync::composio::providers::profile::{is_self_identity_any_toolkit, IdentityKind}; -use crate::tinycortex::memory_config_from; use crate::Config; /// Aggregate entity-index row for capability providers. @@ -32,7 +32,7 @@ pub fn namespace_entities( limit: usize, ) -> Result> { let memory = memory_config_from(config, config.workspace_dir().clone()); - let connection = tinycortex::memory::chunks::shared_connection(&memory)?; + let connection = crate::engine::backend::chunks::shared_connection(&memory)?; let guard = connection.lock(); let pattern = query.map(|value| format!("%{}%", value.to_ascii_lowercase())); let mut statement = guard.prepare( @@ -69,7 +69,7 @@ pub fn namespace_entity_edges( limit: usize, ) -> Result> { let memory = memory_config_from(config, config.workspace_dir().clone()); - let connection = tinycortex::memory::chunks::shared_connection(&memory)?; + let connection = crate::engine::backend::chunks::shared_connection(&memory)?; let guard = connection.lock(); let mut statement = guard.prepare( "SELECT b.entity_id, COUNT(*) @@ -97,7 +97,7 @@ pub fn namespace_entity_edges( Ok(rows) } -pub use tinycortex::memory::store::entity_index::EntityHit; +pub use crate::engine::backend::store::entity_index::EntityHit; #[derive(Debug)] struct HostSelfIdentity; @@ -115,7 +115,7 @@ impl SelfIdentity for HostSelfIdentity { fn index(config: &Config) -> Result { let memory = memory_config_from(config, config.workspace_dir().clone()); - let connection = tinycortex::memory::chunks::shared_connection(&memory)?; + let connection = crate::engine::backend::chunks::shared_connection(&memory)?; EntityIndex::from_shared_connection(connection, Arc::new(HostSelfIdentity)) } @@ -169,7 +169,7 @@ pub fn count_entity_index(config: &Config) -> Result { /// Most frequently observed entities, with recency as the tie-breaker. pub fn top_entities(config: &Config, limit: usize) -> Result> { let memory = memory_config_from(config, config.workspace_dir().clone()); - let connection = tinycortex::memory::chunks::shared_connection(&memory)?; + let connection = crate::engine::backend::chunks::shared_connection(&memory)?; let guard = connection.lock(); let mut statement = guard.prepare( "SELECT entity_id, entity_kind, MAX(surface), COUNT(*) @@ -209,7 +209,7 @@ mod tests { fn insert_entity(config: &Config, tree: &str, entity: &str, node: &str, surface: &str) { let memory = memory_config_from(config, config.workspace_dir().clone()); - let connection = tinycortex::memory::chunks::shared_connection(&memory).expect("db"); + let connection = crate::engine::backend::chunks::shared_connection(&memory).expect("db"); connection .lock() .execute( diff --git a/core/src/store/kv.rs b/core/src/store/kv.rs index 21f9827..625c7af 100644 --- a/core/src/store/kv.rs +++ b/core/src/store/kv.rs @@ -8,7 +8,7 @@ //! again. Canonicalizing here is a no-op for the write path (the transform is //! idempotent and identical) and makes the read path symmetric. -use tinycortex::memory::store::kv::KvStore; +use crate::engine::backend::store::kv::KvStore; use crate::store::namespace_store::UnifiedMemory; use crate::store::safety::canonical_identifier; @@ -92,7 +92,9 @@ impl UnifiedMemory { } } -fn convert_records(records: Vec) -> Vec { +fn convert_records( + records: Vec, +) -> Vec { records .into_iter() .map(|record| MemoryKvRecord { diff --git a/core/src/store/namespace_store/segments.rs b/core/src/store/namespace_store/segments.rs index 66744b6..3af779b 100644 --- a/core/src/store/namespace_store/segments.rs +++ b/core/src/store/namespace_store/segments.rs @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS conversation_segments ( status TEXT NOT NULL DEFAULT 'open', created_at REAL NOT NULL, updated_at REAL NOT NULL, - -- Per-session sequence numbers from tinycortex::memory::archivist::store, populated + -- Per-session sequence numbers from crate::engine::backend::archivist::store, populated -- alongside start_episodic_id / end_episodic_id during the FTS5 -> md -- migration. Once STM recall switches its segment-span dedup to use -- (session_id, seq) the legacy episodic_id columns can be dropped. diff --git a/core/src/store/profile_store.rs b/core/src/store/profile_store.rs index 9441d9a..0eb734f 100644 --- a/core/src/store/profile_store.rs +++ b/core/src/store/profile_store.rs @@ -8,8 +8,7 @@ //! [`super::namespace_store::profile`], both inside `crate`; //! callers outside the family hold this handle and never a `Connection`. //! -//! **This is not a guard win.** The profile/facet tables have no capability -//! family in the `tinycortex_api` contract, so reads and writes through this +//! **This is not a guard win.** Reads and writes through this //! type still run beneath `crate::guard::MemoryGuard`'s //! seven policy steps: no tier check, no source-scope predicate, no taint //! stamping, no redaction, no budget, no audit event. What changed is the shape diff --git a/core/src/store/safety/mod.rs b/core/src/store/safety/mod.rs index bf48ad4..fe021a0 100644 --- a/core/src/store/safety/mod.rs +++ b/core/src/store/safety/mod.rs @@ -1,5 +1,5 @@ //! Secret-detection and redaction for memory writes — thin host shim over -//! `tinycortex::memory::store::safety` (W3). +//! `crate::engine::backend::store::safety` (W3). //! //! The conservative secret + PII scrubbers (`has_likely_secret`, //! `has_likely_pii`, `sanitize_text`, `sanitize_json`) + the @@ -15,7 +15,7 @@ pub mod pii; use crate::store::types::NamespaceDocumentInput; -pub use tinycortex::memory::store::safety::{ +pub use crate::engine::backend::store::safety::{ has_likely_pii, has_likely_secret, sanitize_json, sanitize_text, SanitizationReport, Sanitized, }; diff --git a/core/src/store/safety/pii.rs b/core/src/store/safety/pii.rs index 9a9a74b..8acff9f 100644 --- a/core/src/store/safety/pii.rs +++ b/core/src/store/safety/pii.rs @@ -1,8 +1,8 @@ //! Personal-PII detection — thin host re-export of the crate scrubber (W3). //! //! The full multilingual national-ID PII module (checksum-gated patterns + -//! Unicode normalization) now lives in `tinycortex::memory::store::safety::pii`; +//! Unicode normalization) now lives in `crate::engine::backend::store::safety::pii`; //! content scrubbing runs inside the crate `sanitize_text`. Host consumers keep //! their `safety::pii::has_likely_pii` import path. -pub use tinycortex::memory::store::safety::pii::{has_likely_pii, redact_pii}; +pub use crate::engine::backend::store::safety::pii::{has_likely_pii, redact_pii}; diff --git a/core/src/store/trees/hotness.rs b/core/src/store/trees/hotness.rs index 5fc3ab4..5ab594a 100644 --- a/core/src/store/trees/hotness.rs +++ b/core/src/store/trees/hotness.rs @@ -2,29 +2,29 @@ use anyhow::Result; +use crate::engine::engine_config; use crate::store::trees::types::HotnessCounters; -use crate::tinycortex::engine_config; use crate::Config; pub fn get(config: &Config, entity_id: &str) -> Result> { - tinycortex::memory::tree::store::hotness::get(&engine_config(config), entity_id) + crate::engine::backend::tree::store::hotness::get(&engine_config(config), entity_id) } pub fn get_or_fresh(config: &Config, entity_id: &str) -> Result { - tinycortex::memory::tree::store::hotness::get_or_fresh(&engine_config(config), entity_id) + crate::engine::backend::tree::store::hotness::get_or_fresh(&engine_config(config), entity_id) } pub fn upsert(config: &Config, counters: &HotnessCounters) -> Result<()> { - tinycortex::memory::tree::store::hotness::upsert(&engine_config(config), counters) + crate::engine::backend::tree::store::hotness::upsert(&engine_config(config), counters) } pub fn distinct_sources_for(config: &Config, entity_id: &str) -> Result { - tinycortex::memory::tree::store::hotness::distinct_sources_for( + crate::engine::backend::tree::store::hotness::distinct_sources_for( &engine_config(config), entity_id, ) } pub fn count(config: &Config) -> Result { - tinycortex::memory::tree::store::hotness::count(&engine_config(config)) + crate::engine::backend::tree::store::hotness::count(&engine_config(config)) } diff --git a/core/src/store/trees/registry.rs b/core/src/store/trees/registry.rs index 3ce8e1b..32b596b 100644 --- a/core/src/store/trees/registry.rs +++ b/core/src/store/trees/registry.rs @@ -2,15 +2,15 @@ use anyhow::Result; +use crate::engine::engine_config; use crate::store::trees::types::{Tree, TreeKind}; -use crate::tinycortex::engine_config; use crate::Config; pub fn list_trees_by_kind(config: &Config, kind: TreeKind) -> Result> { - tinycortex::memory::tree::store::list_trees_by_kind(&engine_config(config), kind) + crate::engine::backend::tree::store::list_trees_by_kind(&engine_config(config), kind) } pub fn archive_tree(config: &Config, tree_id: &str) -> Result<()> { log::debug!("[memory:trees] archive tree_id={tree_id}"); - tinycortex::memory::tree::store::archive_tree(&engine_config(config), tree_id) + crate::engine::backend::tree::store::archive_tree(&engine_config(config), tree_id) } diff --git a/core/src/store/trees/store.rs b/core/src/store/trees/store.rs index 2508757..0bd52e2 100644 --- a/core/src/store/trees/store.rs +++ b/core/src/store/trees/store.rs @@ -6,29 +6,29 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use rusqlite::{Connection, Transaction}; +use crate::engine::engine_config; use crate::store::content::StagedSummary; use crate::store::trees::types::{Buffer, SummaryNode, Tree, TreeKind}; -use crate::tinycortex::engine_config; use crate::Config; pub fn insert_tree(config: &Config, tree: &Tree) -> Result<()> { - tinycortex::memory::tree::store::insert_tree(&engine_config(config), tree) + crate::engine::backend::tree::store::insert_tree(&engine_config(config), tree) } pub fn get_tree_by_scope(config: &Config, kind: TreeKind, scope: &str) -> Result> { - tinycortex::memory::tree::store::get_tree_by_scope(&engine_config(config), kind, scope) + crate::engine::backend::tree::store::get_tree_by_scope(&engine_config(config), kind, scope) } pub fn get_tree(config: &Config, id: &str) -> Result> { - tinycortex::memory::tree::store::get_tree(&engine_config(config), id) + crate::engine::backend::tree::store::get_tree(&engine_config(config), id) } pub fn get_trees_batch(config: &Config, ids: &[String]) -> Result> { - tinycortex::memory::tree::store::get_trees_batch(&engine_config(config), ids) + crate::engine::backend::tree::store::get_trees_batch(&engine_config(config), ids) } pub fn list_trees_by_kind(config: &Config, kind: TreeKind) -> Result> { - tinycortex::memory::tree::store::list_trees_by_kind(&engine_config(config), kind) + crate::engine::backend::tree::store::list_trees_by_kind(&engine_config(config), kind) } pub fn update_tree_after_seal_tx( @@ -38,7 +38,7 @@ pub fn update_tree_after_seal_tx( max_level: u32, sealed_at: DateTime, ) -> Result<()> { - tinycortex::memory::tree::store::update_tree_after_seal_tx( + crate::engine::backend::tree::store::update_tree_after_seal_tx( tx, tree_id, root_id, max_level, sealed_at, ) } @@ -49,7 +49,7 @@ pub fn insert_summary_tx( staged: Option<&StagedSummary>, model_signature: &str, ) -> Result<()> { - tinycortex::memory::tree::store::insert_staged_summary_tx(tx, node, staged, model_signature) + crate::engine::backend::tree::store::insert_staged_summary_tx(tx, node, staged, model_signature) } pub fn set_summary_embedding( @@ -57,7 +57,7 @@ pub fn set_summary_embedding( summary_id: &str, embedding: &[f32], ) -> Result { - tinycortex::memory::tree::store::set_summary_embedding( + crate::engine::backend::tree::store::set_summary_embedding( &engine_config(config), summary_id, embedding, @@ -66,7 +66,7 @@ pub fn set_summary_embedding( } pub fn get_summary_embedding(config: &Config, summary_id: &str) -> Result>> { - tinycortex::memory::tree::store::get_summary_embedding(&engine_config(config), summary_id) + crate::engine::backend::tree::store::get_summary_embedding(&engine_config(config), summary_id) } pub fn set_summary_embedding_for_signature( @@ -75,7 +75,7 @@ pub fn set_summary_embedding_for_signature( signature: &str, embedding: &[f32], ) -> Result<()> { - tinycortex::memory::tree::store::set_summary_embedding_for_signature( + crate::engine::backend::tree::store::set_summary_embedding_for_signature( &engine_config(config), summary_id, signature, @@ -89,7 +89,7 @@ pub fn mark_summary_reembed_skipped( signature: &str, reason: &str, ) -> Result<()> { - tinycortex::memory::chunks::mark_summary_reembed_skipped( + crate::engine::backend::chunks::mark_summary_reembed_skipped( &engine_config(config), summary_id, signature, @@ -102,7 +102,7 @@ pub fn clear_summary_reembed_skipped( summary_id: &str, signature: &str, ) -> Result<()> { - tinycortex::memory::chunks::clear_summary_reembed_skipped( + crate::engine::backend::chunks::clear_summary_reembed_skipped( &engine_config(config), summary_id, signature, @@ -115,7 +115,7 @@ pub(crate) fn set_summary_embedding_for_signature_tx( signature: &str, embedding: &[f32], ) -> Result<()> { - tinycortex::memory::chunks::set_summary_embedding_for_signature_tx( + crate::engine::backend::chunks::set_summary_embedding_for_signature_tx( tx, summary_id, signature, embedding, ) } @@ -125,7 +125,7 @@ pub fn get_summary_embedding_for_signature( summary_id: &str, signature: &str, ) -> Result>> { - tinycortex::memory::tree::store::get_summary_embedding_for_signature( + crate::engine::backend::tree::store::get_summary_embedding_for_signature( &engine_config(config), summary_id, signature, @@ -137,7 +137,7 @@ pub fn get_summary_embeddings_for_signature_batch( ids: &[String], signature: &str, ) -> Result>> { - tinycortex::memory::tree::store::get_summary_embeddings_for_signature_batch( + crate::engine::backend::tree::store::get_summary_embeddings_for_signature_batch( &engine_config(config), ids, signature, @@ -148,18 +148,18 @@ pub fn get_summary_embeddings_batch( config: &Config, ids: &[String], ) -> Result>> { - tinycortex::memory::tree::store::get_summary_embeddings_batch(&engine_config(config), ids) + crate::engine::backend::tree::store::get_summary_embeddings_batch(&engine_config(config), ids) } pub fn get_summary(config: &Config, id: &str) -> Result> { - tinycortex::memory::tree::store::get_summary(&engine_config(config), id) + crate::engine::backend::tree::store::get_summary(&engine_config(config), id) } pub fn get_summaries_batch( config: &Config, ids: &[String], ) -> Result> { - tinycortex::memory::tree::store::get_summaries_batch(&engine_config(config), ids) + crate::engine::backend::tree::store::get_summaries_batch(&engine_config(config), ids) } pub fn list_summaries_at_level( @@ -167,7 +167,11 @@ pub fn list_summaries_at_level( tree_id: &str, level: u32, ) -> Result> { - tinycortex::memory::tree::store::list_summaries_at_level(&engine_config(config), tree_id, level) + crate::engine::backend::tree::store::list_summaries_at_level( + &engine_config(config), + tree_id, + level, + ) } pub fn list_summaries_in_window( @@ -176,7 +180,7 @@ pub fn list_summaries_in_window( since_ms: i64, until_ms: i64, ) -> Result> { - tinycortex::memory::tree::store::list_summaries_in_window( + crate::engine::backend::tree::store::list_summaries_in_window( &engine_config(config), tree_id, since_ms, @@ -185,21 +189,21 @@ pub fn list_summaries_in_window( } pub fn count_summaries(config: &Config, tree_id: &str) -> Result { - tinycortex::memory::tree::store::count_summaries(&engine_config(config), tree_id) + crate::engine::backend::tree::store::count_summaries(&engine_config(config), tree_id) } pub fn get_buffer(config: &Config, tree_id: &str, level: u32) -> Result { - tinycortex::memory::tree::store::get_buffer(&engine_config(config), tree_id, level) + crate::engine::backend::tree::store::get_buffer(&engine_config(config), tree_id, level) } pub(crate) fn get_buffer_conn(conn: &Connection, tree_id: &str, level: u32) -> Result { - tinycortex::memory::tree::store::get_buffer_conn(conn, tree_id, level) + crate::engine::backend::tree::store::get_buffer_conn(conn, tree_id, level) } pub fn upsert_buffer_tx(tx: &Transaction<'_>, buffer: &Buffer) -> Result<()> { - tinycortex::memory::tree::store::upsert_buffer_tx(tx, buffer) + crate::engine::backend::tree::store::upsert_buffer_tx(tx, buffer) } pub fn list_stale_buffers(config: &Config, older_than: DateTime) -> Result> { - tinycortex::memory::tree::store::list_stale_buffers(&engine_config(config), older_than) + crate::engine::backend::tree::store::list_stale_buffers(&engine_config(config), older_than) } diff --git a/core/src/store/trees/store_tests.rs b/core/src/store/trees/store_tests.rs index d470951..741fe4d 100644 --- a/core/src/store/trees/store_tests.rs +++ b/core/src/store/trees/store_tests.rs @@ -297,7 +297,7 @@ fn buffer_upsert_and_clear() { with_connection(&cfg, |conn| { let tx = conn.unchecked_transaction()?; - tinycortex::memory::tree::store::clear_buffer_tx(&tx, "tree-1", 0)?; + crate::engine::backend::tree::store::clear_buffer_tx(&tx, "tree-1", 0)?; tx.commit()?; Ok(()) }) diff --git a/core/src/store/trees/types.rs b/core/src/store/trees/types.rs index 114fb97..b0cf40f 100644 --- a/core/src/store/trees/types.rs +++ b/core/src/store/trees/types.rs @@ -1,6 +1,6 @@ //! Compatibility exports for tinycortex summary-tree persistence types. -pub use tinycortex::memory::tree::store::{ +pub use crate::engine::backend::tree::store::{ Buffer, EntityIndexStats, HotnessCounters, SummaryNode, Tree, TreeKind, TreeStatus, DEFAULT_FLUSH_AGE_SECS, INPUT_TOKEN_BUDGET, OUTPUT_TOKEN_BUDGET, SUMMARY_FANOUT, TOPIC_ARCHIVE_THRESHOLD, TOPIC_CREATION_THRESHOLD, TOPIC_RECHECK_EVERY, diff --git a/core/src/store/types.rs b/core/src/store/types.rs index d72101e..4cc1ead 100644 --- a/core/src/store/types.rs +++ b/core/src/store/types.rs @@ -1,9 +1,9 @@ //! Stable host path for tinycortex-owned namespace memory contracts. -pub use tinycortex::memory::{ +pub use crate::engine::backend::{ GraphRelationRecord, MemoryItemKind, MemoryKvRecord, NamespaceDocumentInput, NamespaceMemoryHit, NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, StoredMemoryDocument, }; -pub(crate) use tinycortex::memory::types::GLOBAL_NAMESPACE; +pub(crate) use crate::engine::backend::types::GLOBAL_NAMESPACE; diff --git a/core/src/sync/composio/mod.rs b/core/src/sync/composio/mod.rs index 37e2721..b3df5eb 100644 --- a/core/src/sync/composio/mod.rs +++ b/core/src/sync/composio/mod.rs @@ -161,12 +161,8 @@ pub async fn run_connection_sync( .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - match crate::tinycortex::run_composio_connection( - &target.toolkit, - &target.connection_id, - &*config, - ) - .await + match crate::engine::run_composio_connection(&target.toolkit, &target.connection_id, &*config) + .await { Ok(outcome) => { let usage = ComposioUsage { diff --git a/core/src/sync/composio/periodic.rs b/core/src/sync/composio/periodic.rs index 22cfa71..400e43b 100644 --- a/core/src/sync/composio/periodic.rs +++ b/core/src/sync/composio/periodic.rs @@ -58,7 +58,7 @@ use tinymemory_api::host::DEFAULT_MEMORY_SYNC_INTERVAL_SECS; use super::providers::{get_provider, ComposioUsage}; use crate::composio_host; -use crate::tinycortex::{append_audit_entry, try_read_audit_log, SyncAuditEntry}; +use crate::engine::{append_audit_entry, try_read_audit_log, SyncAuditEntry}; use chrono::{DateTime, Utc}; /// How often the scheduler wakes up to look for due syncs. Independent @@ -555,7 +555,7 @@ pub(crate) async fn run_one_tick() -> Result<(), String> { "[composio:periodic] firing sync" ); let sync_started = Instant::now(); - let result = crate::tinycortex::run_source_pipeline(&source, &*config).await; + let result = crate::engine::run_source_pipeline(&source, &*config).await; let duration_ms = sync_started.elapsed().as_millis() as u64; match result { diff --git a/core/src/sync/composio/providers/clickup/mod.rs b/core/src/sync/composio/providers/clickup/mod.rs index bbf3908..807d7fe 100644 --- a/core/src/sync/composio/providers/clickup/mod.rs +++ b/core/src/sync/composio/providers/clickup/mod.rs @@ -6,7 +6,8 @@ //! re-learning a new shape: //! //! - `provider.rs` — `impl ComposioProvider for ClickUpProvider` -//! - `normalization` — payload-shape helpers, now `tinycortex::…::normalize::clickup` +//! - `normalization` — payload-shape helpers, now reached through +//! `crate::engine::engine` (issue #18 §C1) //! - `ingest.rs` — memory_tree document ingest (issue #2885) //! - `tools.rs` — `CLICKUP_CURATED` whitelist of Composio actions //! - `tests.rs` — unit tests for the helpers + trait metadata @@ -16,7 +17,7 @@ // The payload normalisers moved to tinycortex (they are pure Value // transforms, i.e. driver-side). Aliased under the old module name so // every `normalization::extract_*` call site below stays unchanged. -use tinycortex::memory::sync::composio::providers::normalize::clickup as normalization; +use crate::engine::backend::sync::composio::providers::normalize::clickup as normalization; mod provider; #[cfg(test)] mod tests; diff --git a/core/src/sync/composio/providers/github/mod.rs b/core/src/sync/composio/providers/github/mod.rs index 691b6e2..58c3cd8 100644 --- a/core/src/sync/composio/providers/github/mod.rs +++ b/core/src/sync/composio/providers/github/mod.rs @@ -6,7 +6,8 @@ //! re-learning a new shape: //! //! - `provider.rs` — `impl ComposioProvider for GitHubProvider` -//! - `normalization` — payload-shape helpers, now `tinycortex::…::normalize::github` +//! - `normalization` — payload-shape helpers, now reached through +//! `crate::engine::engine` (issue #18 §C1) //! - `tools.rs` — `GITHUB_CURATED` whitelist of Composio actions //! - `tests.rs` — unit tests for the helpers + trait metadata //! @@ -15,7 +16,7 @@ // The payload normalisers moved to tinycortex (they are pure Value // transforms, i.e. driver-side). Aliased under the old module name so // every `normalization::extract_*` call site below stays unchanged. -use tinycortex::memory::sync::composio::providers::normalize::github as normalization; +use crate::engine::backend::sync::composio::providers::normalize::github as normalization; mod provider; #[cfg(test)] mod tests; diff --git a/core/src/sync/composio/providers/gmail/mod.rs b/core/src/sync/composio/providers/gmail/mod.rs index fcdc23e..f8c99a0 100644 --- a/core/src/sync/composio/providers/gmail/mod.rs +++ b/core/src/sync/composio/providers/gmail/mod.rs @@ -1,7 +1,7 @@ // The Gmail post-processor moved to tinycortex (a pure Value transform, i.e. // driver-side). Aliased under the old module name so the single call site in // `provider.rs` stays unchanged. -use tinycortex::memory::sync::composio::providers::normalize::gmail_post_process as post_process; +use crate::engine::backend::sync::composio::providers::normalize::gmail_post_process as post_process; mod provider; #[cfg(test)] mod tests; diff --git a/core/src/sync/composio/providers/gmail/provider.rs b/core/src/sync/composio/providers/gmail/provider.rs index 07b7f0c..a46617e 100644 --- a/core/src/sync/composio/providers/gmail/provider.rs +++ b/core/src/sync/composio/providers/gmail/provider.rs @@ -152,12 +152,9 @@ impl ComposioProvider for GmailProvider { let Some(connection_id) = ctx.connection_id.as_deref() else { return Err("[composio:gmail] trigger missing connection_id".to_string()); }; - if let Err(e) = crate::tinycortex::run_composio_connection( - "gmail", - connection_id, - ctx.config.as_ref(), - ) - .await + if let Err(e) = + crate::engine::run_composio_connection("gmail", connection_id, ctx.config.as_ref()) + .await { tracing::warn!( error = %e, @@ -171,5 +168,5 @@ impl ComposioProvider for GmailProvider { // Message fetching (the `GMAIL_FETCH_EMAILS` action, the search query, the // `max_items` cap math and the `sync_depth_days` `after:` floor) is owned -// by `tinycortex::memory::sync::GmailSyncPipeline`. What stays here is the +// by `crate::engine::backend::sync::GmailSyncPipeline`. What stays here is the // host-side provider surface: profile lookup and trigger dispatch. diff --git a/core/src/sync/composio/providers/gmail/tests.rs b/core/src/sync/composio/providers/gmail/tests.rs index 70b5e6b..17a1825 100644 --- a/core/src/sync/composio/providers/gmail/tests.rs +++ b/core/src/sync/composio/providers/gmail/tests.rs @@ -1,7 +1,7 @@ //! Host-owned Gmail provider surface tests. //! //! Pagination, cursor, envelope parsing, and ingest behavior are owned and -//! tested by `tinycortex::memory::sync::GmailSyncPipeline`. +//! tested by `crate::engine::backend::sync::GmailSyncPipeline`. use super::GmailProvider; use crate::sync::composio::providers::ComposioProvider; diff --git a/core/src/sync/composio/providers/helpers.rs b/core/src/sync/composio/providers/helpers.rs index 6e1e2ba..d2aca25 100644 --- a/core/src/sync/composio/providers/helpers.rs +++ b/core/src/sync/composio/providers/helpers.rs @@ -1,11 +1,11 @@ //! Shared helpers for Composio provider implementations. //! //! `pick_str` used to live here. It is a provider payload normaliser, so it -//! moved to `tinycortex::memory::sync::composio::providers::normalize::helpers` +//! moved to `crate::engine::backend::sync::composio::providers::normalize::helpers` //! and is re-exported from this module's parent. The helpers that remain are //! request-building rather than normalisation, and stay host-side. -use tinycortex::memory::sync::composio::providers::normalize::helpers::pick_str; +use crate::engine::backend::sync::composio::providers::normalize::helpers::pick_str; /// Shallow-merge an `extra` JSON object into a (mutable) action-args /// object. Only object-typed extras are merged; non-object `extra` diff --git a/core/src/sync/composio/providers/linear/mod.rs b/core/src/sync/composio/providers/linear/mod.rs index 27ff7b7..af81bca 100644 --- a/core/src/sync/composio/providers/linear/mod.rs +++ b/core/src/sync/composio/providers/linear/mod.rs @@ -6,7 +6,7 @@ // The payload normalisers moved to tinycortex (they are pure Value // transforms, i.e. driver-side). Aliased under the old module name so // every `normalization::extract_*` call site below stays unchanged. -use tinycortex::memory::sync::composio::providers::normalize::linear as normalization; +use crate::engine::backend::sync::composio::providers::normalize::linear as normalization; mod provider; #[cfg(test)] mod tests; diff --git a/core/src/sync/composio/providers/mod.rs b/core/src/sync/composio/providers/mod.rs index 0a86355..7bb43ac 100644 --- a/core/src/sync/composio/providers/mod.rs +++ b/core/src/sync/composio/providers/mod.rs @@ -281,11 +281,11 @@ pub(crate) use helpers::{first_array_str, merge_extra}; // re-exported here so the ~40 in-tree call sites keep resolving unchanged. // Note this is deliberately NOT `providers::common::pick_str`, which coerces // numbers to strings — see the doc comments on both definitions. +pub(crate) use crate::engine::backend::sync::composio::providers::normalize::helpers::pick_str; pub use registry::{ all_providers, get_provider, init_default_providers, register_provider, ProviderArc, }; pub use scope_lookup::{curated_scope_for, toolkit_has_scope}; -pub(crate) use tinycortex::memory::sync::composio::providers::normalize::helpers::pick_str; pub use tool_scope::{classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope}; pub use traits::{resolve_sync_interval_secs, sync_interval_env_var, ComposioProvider}; pub use types::{ diff --git a/core/src/sync/composio/providers/notion/mod.rs b/core/src/sync/composio/providers/notion/mod.rs index 5613ed0..f781129 100644 --- a/core/src/sync/composio/providers/notion/mod.rs +++ b/core/src/sync/composio/providers/notion/mod.rs @@ -1,7 +1,7 @@ // The payload normalisers moved to tinycortex (they are pure Value // transforms, i.e. driver-side). Aliased under the old module name so // every `normalization::extract_*` call site below stays unchanged. -use tinycortex::memory::sync::composio::providers::normalize::notion as normalization; +use crate::engine::backend::sync::composio::providers::normalize::notion as normalization; mod provider; #[cfg(test)] mod tests; diff --git a/core/src/sync/composio/providers/notion/provider.rs b/core/src/sync/composio/providers/notion/provider.rs index 63990fd..d02cc3e 100644 --- a/core/src/sync/composio/providers/notion/provider.rs +++ b/core/src/sync/composio/providers/notion/provider.rs @@ -279,7 +279,7 @@ impl ComposioProvider for NotionProvider { return Err("[composio:notion] trigger missing connection_id".to_string()); }; if let Err(e) = - crate::tinycortex::run_composio_connection("notion", connection_id, ctx.config.as_ref()) + crate::engine::run_composio_connection("notion", connection_id, ctx.config.as_ref()) .await { tracing::warn!( diff --git a/core/src/sync/composio/providers/slack/mod.rs b/core/src/sync/composio/providers/slack/mod.rs index 034492e..8d1e84e 100644 --- a/core/src/sync/composio/providers/slack/mod.rs +++ b/core/src/sync/composio/providers/slack/mod.rs @@ -10,7 +10,7 @@ // driver-side). Re-exported under the old module name — `pub`, not a plain // `use`, because `tests/raw_coverage/memory_threads_raw_coverage_e2e.rs` // imports this path directly. -pub use tinycortex::memory::sync::composio::providers::normalize::slack_post_process as post_process; +pub use crate::engine::backend::sync::composio::providers::normalize::slack_post_process as post_process; pub mod types; mod provider; diff --git a/core/src/sync/composio/providers/slack/provider.rs b/core/src/sync/composio/providers/slack/provider.rs index 5d22208..3ed5270 100644 --- a/core/src/sync/composio/providers/slack/provider.rs +++ b/core/src/sync/composio/providers/slack/provider.rs @@ -233,12 +233,9 @@ impl ComposioProvider for SlackProvider { let Some(connection_id) = ctx.connection_id.as_deref() else { return Err("[composio:slack] trigger missing connection_id".to_string()); }; - if let Err(e) = crate::tinycortex::run_composio_connection( - "slack", - connection_id, - ctx.config.as_ref(), - ) - .await + if let Err(e) = + crate::engine::run_composio_connection("slack", connection_id, ctx.config.as_ref()) + .await { tracing::warn!( error = %e, @@ -262,13 +259,10 @@ pub async fn run_backfill_via_search( .as_deref() .ok_or_else(|| "[composio:slack] search backfill missing connection_id".to_string())?; let started_at_ms = now_ms(); - let outcome = crate::tinycortex::run_slack_search_backfill( - connection_id, - backfill_days, - ctx.config.as_ref(), - ) - .await - .map_err(|error| error.to_string())?; + let outcome = + crate::engine::run_slack_search_backfill(connection_id, backfill_days, ctx.config.as_ref()) + .await + .map_err(|error| error.to_string())?; Ok(SyncOutcome { toolkit: "slack".into(), connection_id: Some(connection_id.into()), diff --git a/core/src/sync/composio/providers/sync_state.rs b/core/src/sync/composio/providers/sync_state.rs index da33c44..4e33f91 100644 --- a/core/src/sync/composio/providers/sync_state.rs +++ b/core/src/sync/composio/providers/sync_state.rs @@ -1,9 +1,9 @@ //! Compatibility exports for sync state now owned by tinycortex. -pub use tinycortex::memory::sync::state::DEFAULT_DAILY_REQUEST_LIMIT; -pub use tinycortex::memory::sync::{DailyBudget, SyncState}; +pub use crate::engine::backend::sync::state::DEFAULT_DAILY_REQUEST_LIMIT; +pub use crate::engine::backend::sync::{DailyBudget, SyncState}; -pub const KV_NAMESPACE: &str = crate::tinycortex::HOST_SYNC_STATE_NAMESPACE; +pub const KV_NAMESPACE: &str = crate::engine::HOST_SYNC_STATE_NAMESPACE; pub fn extract_item_id(item: &serde_json::Value, paths: &[&str]) -> Option { paths.iter().find_map(|path| { diff --git a/core/src/sync/composio/providers/traits.rs b/core/src/sync/composio/providers/traits.rs index 8585eb2..b9a0e30 100644 --- a/core/src/sync/composio/providers/traits.rs +++ b/core/src/sync/composio/providers/traits.rs @@ -59,7 +59,7 @@ pub trait ComposioProvider: Send + Sync { ) })?; let started_at_ms = now_ms(); - let outcome = crate::tinycortex::run_composio_connection_with_budgets( + let outcome = crate::engine::run_composio_connection_with_budgets( self.toolkit_slug(), connection_id, ctx.config.as_ref(), diff --git a/core/src/sync/sync_status/mod.rs b/core/src/sync/sync_status/mod.rs index 019bbd6..11c5b83 100644 --- a/core/src/sync/sync_status/mod.rs +++ b/core/src/sync/sync_status/mod.rs @@ -13,4 +13,4 @@ //! * `openhuman.memory_sync_status_list` — handler in `rpc` //! * Controller registration via `schemas::all_registered_controllers` -pub use tinycortex::memory::sync::{FreshnessLabel, MemorySyncStatus}; +pub use crate::engine::backend::sync::{FreshnessLabel, MemorySyncStatus}; diff --git a/core/src/sync/workspace/periodic.rs b/core/src/sync/workspace/periodic.rs index a1d145e..dbc3a0b 100644 --- a/core/src/sync/workspace/periodic.rs +++ b/core/src/sync/workspace/periodic.rs @@ -33,13 +33,13 @@ use chrono::{DateTime, Utc}; use tokio::time::interval; use crate::config_loader as config_rpc; +use crate::engine::{try_read_audit_log, SyncAuditEntry}; use crate::scheduler_gate::resume_notify; use crate::sources::sync::sync_source; use crate::sources::types::{MemorySourceEntry, SourceKind}; use crate::sync::composio::periodic::{ connection_is_due, effective_interval_secs, periodic_pause_reason, }; -use crate::tinycortex::{try_read_audit_log, SyncAuditEntry}; use tinymemory_api::host::DEFAULT_MEMORY_SYNC_INTERVAL_SECS; /// How often the scheduler wakes up to look for due syncs. Matches the diff --git a/core/src/sync/workspace/watcher.rs b/core/src/sync/workspace/watcher.rs index 27bdaf8..c43e529 100644 --- a/core/src/sync/workspace/watcher.rs +++ b/core/src/sync/workspace/watcher.rs @@ -56,7 +56,7 @@ use tokio::sync::mpsc; use crate::Config; use crate::config_loader as config_rpc; use crate::ingest_pipeline::ingest_document_with_scope; -use tinycortex::memory::ingest::canonicalize::document::DocumentInput; +use crate::engine::backend::ingest::canonicalize::document::DocumentInput; use crate::sync::workspace::watcher::state::WatcherStateStore; use crate::scheduler_gate::current_policy; use crate::scheduler_gate::PauseReason; diff --git a/core/src/tool_memory/mod.rs b/core/src/tool_memory/mod.rs index 0bbf8fa..6314279 100644 --- a/core/src/tool_memory/mod.rs +++ b/core/src/tool_memory/mod.rs @@ -18,9 +18,9 @@ //! //! ## Components //! -//! - [`tinycortex::memory::tool_memory::types`] owns [`ToolMemoryRule`], +//! - [`crate::engine::backend::tool_memory::types`] owns [`ToolMemoryRule`], //! [`ToolMemoryPriority`], and [`ToolMemorySource`]. -//! - [`tinycortex::memory::tool_memory::store`] owns [`ToolMemoryStore`], the +//! - [`crate::engine::backend::tool_memory::store`] owns [`ToolMemoryStore`], the //! put/list/delete/prompt API built on top of an `Arc`. //! - `capture` — `ToolMemoryCaptureHook`, the post-turn //! `PostTurnHook` that records user edicts and repeated tool @@ -37,8 +37,8 @@ mod store; #[cfg(any(test, feature = "test-support"))] pub mod test_helpers; -pub use store::tool_memory_store; -pub use tinycortex::memory::tool_memory::{ +pub use crate::engine::backend::tool_memory::{ store::{ToolMemoryStore, TOOL_MEMORY_PROMPT_CAP}, types::{tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource}, }; +pub use store::tool_memory_store; diff --git a/core/src/tool_memory/store.rs b/core/src/tool_memory/store.rs index 3446fba..857a497 100644 --- a/core/src/tool_memory/store.rs +++ b/core/src/tool_memory/store.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::Memory; -use tinycortex::memory::tool_memory::store::ToolMemoryStore; +use crate::engine::backend::tool_memory::store::ToolMemoryStore; /// Build the crate-owned store over OpenHuman's shared memory object. pub fn tool_memory_store(memory: Arc) -> ToolMemoryStore { diff --git a/core/src/traits.rs b/core/src/traits.rs index af12346..d57f618 100644 --- a/core/src/traits.rs +++ b/core/src/traits.rs @@ -23,7 +23,7 @@ // ── The contract's trait and value types ───────────────────────────────────── // -// Named directly rather than reached through `tinycortex::memory`. Since §A1 the +// Named directly rather than reached through the engine's re-export. Since §A1 the // engine re-exports this same contract, so the two spellings resolve to one type // either way — but going through the engine to reach an engine-neutral contract // is what §1.1 of issue #18 calls out, and it is what would have to be undone diff --git a/core/src/tree/graph/bfs.rs b/core/src/tree/graph/bfs.rs index 444d068..51c379e 100644 --- a/core/src/tree/graph/bfs.rs +++ b/core/src/tree/graph/bfs.rs @@ -4,15 +4,15 @@ use anyhow::Result; use crate::Config; -pub use tinycortex::memory::graph::PairDistance; +pub use crate::engine::backend::graph::PairDistance; pub fn pair_distances( config: &Config, entity_ids: &[String], max_h: u32, ) -> Result> { - tinycortex::memory::graph::pair_distances( - &crate::tinycortex::memory_config_from(config, config.workspace_dir().clone()), + crate::engine::backend::graph::pair_distances( + &crate::engine::memory_config_from(config, config.workspace_dir().clone()), entity_ids, max_h, ) diff --git a/core/src/tree/graph/store.rs b/core/src/tree/graph/store.rs index 2408300..69094ed 100644 --- a/core/src/tree/graph/store.rs +++ b/core/src/tree/graph/store.rs @@ -3,17 +3,17 @@ use anyhow::Result; use rusqlite::Transaction; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; use crate::Config; -pub use tinycortex::memory::graph::pairs_from_entities; +pub use crate::engine::backend::graph::pairs_from_entities; pub fn upsert_edges_tx( transaction: &Transaction<'_>, pairs: &[(String, String)], timestamp_ms: i64, ) -> Result { - tinycortex::memory::graph::upsert_edges_tx(transaction, pairs, timestamp_ms) + crate::engine::backend::graph::upsert_edges_tx(transaction, pairs, timestamp_ms) } pub fn upsert_edges( @@ -21,20 +21,20 @@ pub fn upsert_edges( pairs: &[(String, String)], timestamp_ms: i64, ) -> Result { - tinycortex::memory::graph::upsert_edges(&engine_config(config), pairs, timestamp_ms) + crate::engine::backend::graph::upsert_edges(&engine_config(config), pairs, timestamp_ms) } pub fn neighbors(config: &Config, entity_id: &str) -> Result> { - tinycortex::memory::graph::edge_neighbors(&engine_config(config), entity_id) + crate::engine::backend::graph::edge_neighbors(&engine_config(config), entity_id) } pub fn clear_edges_for_entities_tx( transaction: &Transaction<'_>, entity_ids: &[String], ) -> Result { - tinycortex::memory::graph::clear_edges_for_entities_tx(transaction, entity_ids) + crate::engine::backend::graph::clear_edges_for_entities_tx(transaction, entity_ids) } pub fn count_edges(config: &Config) -> Result { - tinycortex::memory::graph::count_edges(&engine_config(config)) + crate::engine::backend::graph::count_edges(&engine_config(config)) } diff --git a/core/src/tree/health/mod.rs b/core/src/tree/health/mod.rs index 87bbbc8..2bfb1a8 100644 --- a/core/src/tree/health/mod.rs +++ b/core/src/tree/health/mod.rs @@ -3,7 +3,7 @@ //! The **taxonomy itself** — [`FailureCode`], [`FailureClass`], //! [`PipelineFailure`], [`DegradedState`], and the `classify_embed_error` //! classifier — now lives in the engine crate at -//! `tinycortex::memory::health`, and is re-exported below so every existing +//! `crate::engine::backend::health`, and is re-exported below so every existing //! `memory::tree::health::…` path keeps resolving. It moved because a build //! whose only driver was a third-party external backend would have no use for //! the engine's private failure vocabulary. @@ -27,7 +27,7 @@ pub(crate) use user_error::publish_local_model_unavailable_user_error; /// The failure taxonomy proper. Re-exported (rather than re-declared) so the /// ~30 `crate::tree::health::{…}` call sites across the host /// are unaffected by the move, and so there is exactly one definition. -pub use tinycortex::memory::health::{ +pub use crate::engine::backend::health::{ classify_embed_error, classify_embed_error_str, DegradedState, FailureClass, FailureCode, PipelineFailure, }; diff --git a/core/src/tree/ingest.rs b/core/src/tree/ingest.rs index 805c60e..edb2067 100644 --- a/core/src/tree/ingest.rs +++ b/core/src/tree/ingest.rs @@ -4,13 +4,13 @@ use anyhow::Context; use anyhow::Result; +use crate::engine::{memory_config_from, HostSummariser}; #[cfg(feature = "memory-git")] use crate::store::content::wiki_git::{SummaryCommitBatch, SummaryCommitEntry}; use crate::store::trees::types::Tree; -use crate::tinycortex::{memory_config_from, HostSummariser}; use crate::Config; -pub use tinycortex::memory::tree::{SummaryIngestInput, SummaryIngestOutcome}; +pub use crate::engine::backend::tree::{SummaryIngestInput, SummaryIngestOutcome}; pub async fn ingest_summary( config: &Config, @@ -27,7 +27,7 @@ pub async fn ingest_summary( log::warn!("[memory_tree::ingest] obsidian defaults failed: {error:#}"); } - let outcome = tinycortex::memory::tree::ingest_summary( + let outcome = crate::engine::backend::tree::ingest_summary( &memory_config_from(config, config.workspace_dir().clone()), tree, input.clone(), diff --git a/core/src/tree/mod.rs b/core/src/tree/mod.rs index 61e0ab4..3e26f8a 100644 --- a/core/src/tree/mod.rs +++ b/core/src/tree/mod.rs @@ -21,7 +21,7 @@ pub mod tree; pub mod tree_runtime; // Tree I/O contracts are engine-owned. -pub use tinycortex::memory::tree::{ +pub use crate::engine::backend::tree::{ TreeLabelStrategy, TreeLeafPayload, TreeReadHit, TreeReadRequest, TreeReadResult, TreeWriteOutcome, TreeWriteRequest, }; diff --git a/core/src/tree/retrieval/benchmarks.rs b/core/src/tree/retrieval/benchmarks.rs index f59338d..35ee2a9 100644 --- a/core/src/tree/retrieval/benchmarks.rs +++ b/core/src/tree/retrieval/benchmarks.rs @@ -25,12 +25,12 @@ use tempfile::TempDir; #[cfg(test)] use tinymemory_api::host::test_support::TestHostConfig; +use crate::engine::backend::ingest::canonicalize::chat::{ChatBatch, ChatMessage}; use crate::ingest_pipeline::ingest_chat; use crate::queue::testing::drain_until_idle; use crate::store::chunks::types::SourceKind; use crate::tree::retrieval::{fetch_leaves, query_source, search_entities}; use crate::Config; -use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage}; /// Shared test config — disables embedding for deterministic inert behaviour. fn bench_config() -> (TempDir, TestHostConfig) { diff --git a/core/src/tree/retrieval/cover.rs b/core/src/tree/retrieval/cover.rs index d5b7b09..2f196b8 100644 --- a/core/src/tree/retrieval/cover.rs +++ b/core/src/tree/retrieval/cover.rs @@ -1,8 +1,8 @@ use anyhow::Result; +use crate::engine::engine_config; use crate::source_scope::current_source_scope; use crate::store::chunks::types::SourceKind; -use crate::tinycortex::engine_config; use crate::tree::retrieval::types::QueryResponse; use crate::Config; @@ -62,7 +62,7 @@ pub async fn cover_window_scoped( source_kind.map(|k| k.as_str()), limit ); - let mut response = tinycortex::memory::retrieval::cover_window_scoped( + let mut response = crate::engine::backend::retrieval::cover_window_scoped( &engine_config(config), since_ms, until_ms, diff --git a/core/src/tree/retrieval/drill_down.rs b/core/src/tree/retrieval/drill_down.rs index d74f428..7e58af9 100644 --- a/core/src/tree/retrieval/drill_down.rs +++ b/core/src/tree/retrieval/drill_down.rs @@ -1,7 +1,7 @@ use anyhow::Result; +use crate::engine::engine_config; use crate::source_scope::current_source_scope; -use crate::tinycortex::engine_config; use crate::tree::retrieval::engine::EmbedderBridge; use crate::tree::retrieval::types::RetrievalHit; use crate::tree::score::embed::{build_embedder_from_config, InertEmbedder}; @@ -60,7 +60,7 @@ pub async fn drill_down_scoped( // limiting before the retain below would cap the result set with rows that // are about to be discarded. let engine_limit = scope.as_ref().map(|_| None).unwrap_or(limit); - let mut hits = tinycortex::memory::retrieval::drill_down( + let mut hits = crate::engine::backend::retrieval::drill_down( &engine_config(config), node_id, max_depth, diff --git a/core/src/tree/retrieval/engine.rs b/core/src/tree/retrieval/engine.rs index a23bcfe..5c541c5 100644 --- a/core/src/tree/retrieval/engine.rs +++ b/core/src/tree/retrieval/engine.rs @@ -6,7 +6,7 @@ use crate::tree::score::embed::Embedder as HostEmbedder; pub(super) struct EmbedderBridge<'a>(pub &'a dyn HostEmbedder); #[async_trait] -impl tinycortex::memory::score::embed::Embedder for EmbedderBridge<'_> { +impl crate::engine::backend::score::embed::Embedder for EmbedderBridge<'_> { fn name(&self) -> &'static str { self.0.name() } diff --git a/core/src/tree/retrieval/fast.rs b/core/src/tree/retrieval/fast.rs index 926e8a6..f8df280 100644 --- a/core/src/tree/retrieval/fast.rs +++ b/core/src/tree/retrieval/fast.rs @@ -2,15 +2,15 @@ use anyhow::Result; +use crate::engine::engine_config; use crate::source_scope::current_source_scope; -use crate::tinycortex::engine_config; use crate::tree::nlp; use crate::tree::retrieval::engine::EmbedderBridge; use crate::tree::retrieval::types::QueryResponse; use crate::tree::score::embed::build_embedder_from_config; use crate::Config; -pub use tinycortex::memory::retrieval::FastRetrieveOptions; +pub use crate::engine::backend::retrieval::FastRetrieveOptions; /// Deterministic graph-walk retrieval using the **ambient** source scope. /// @@ -49,7 +49,7 @@ pub async fn fast_retrieve_scoped( options.max_hops ); let embedder = build_embedder_from_config(config)?; - tinycortex::memory::retrieval::fast_retrieve( + crate::engine::backend::retrieval::fast_retrieve( &engine_config(config), query, &entity_ids, diff --git a/core/src/tree/retrieval/fetch.rs b/core/src/tree/retrieval/fetch.rs index d45c4dd..5fc2502 100644 --- a/core/src/tree/retrieval/fetch.rs +++ b/core/src/tree/retrieval/fetch.rs @@ -1,13 +1,13 @@ use anyhow::Result; +use crate::engine::engine_config; use crate::source_scope::chunk_source_allowed_in; use crate::source_scope::current_source_scope; use crate::store::chunks::store::get_chunks_batch; -use crate::tinycortex::engine_config; use crate::tree::retrieval::types::RetrievalHit; use crate::Config; -pub use tinycortex::memory::retrieval::MAX_BATCH; +pub use crate::engine::backend::retrieval::MAX_BATCH; /// Fetch leaf chunks by id, using the **ambient** scope. /// @@ -47,5 +47,5 @@ pub async fn fetch_leaves_scoped( } else { chunk_ids.to_vec() }; - tinycortex::memory::retrieval::fetch_leaves(&engine_config(config), &permitted_ids) + crate::engine::backend::retrieval::fetch_leaves(&engine_config(config), &permitted_ids) } diff --git a/core/src/tree/retrieval/integration_tests.rs b/core/src/tree/retrieval/integration_tests.rs index 9e41c8f..f449aa8 100644 --- a/core/src/tree/retrieval/integration_tests.rs +++ b/core/src/tree/retrieval/integration_tests.rs @@ -18,10 +18,10 @@ use tinymemory_api::host::MemoryHostConfig; #[cfg(test)] use tinymemory_api::host::test_support::TestHostConfig; +use crate::engine::backend::ingest::canonicalize::chat::{ChatBatch, ChatMessage}; use crate::ingest_pipeline::ingest_chat; use crate::store::chunks::types::SourceKind; use crate::tree::retrieval::{drill_down, fetch_leaves, query_source, search_entities}; -use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage}; fn test_config() -> (TempDir, TestHostConfig) { crate::test_seams::init(); diff --git a/core/src/tree/retrieval/search.rs b/core/src/tree/retrieval/search.rs index 9b379d8..2f0c69e 100644 --- a/core/src/tree/retrieval/search.rs +++ b/core/src/tree/retrieval/search.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; use crate::tree::retrieval::types::EntityMatch; use crate::tree::score::extract::EntityKind; use crate::Config; @@ -17,7 +17,7 @@ pub async fn search_entities( kinds.as_ref().map_or(0, Vec::len), limit ); - tinycortex::memory::retrieval::search_entities( + crate::engine::backend::retrieval::search_entities( &engine_config(config), query, kinds.as_deref(), diff --git a/core/src/tree/retrieval/source.rs b/core/src/tree/retrieval/source.rs index dbc0f62..95145a5 100644 --- a/core/src/tree/retrieval/source.rs +++ b/core/src/tree/retrieval/source.rs @@ -1,8 +1,8 @@ use anyhow::Result; +use crate::engine::engine_config; use crate::source_scope::current_source_scope; use crate::store::chunks::types::SourceKind; -use crate::tinycortex::engine_config; use crate::tree::retrieval::engine::EmbedderBridge; use crate::tree::retrieval::types::QueryResponse; use crate::tree::score::embed::build_embedder_from_config; @@ -90,7 +90,7 @@ pub async fn query_source_scoped( let mut response = if let Some(query) = semantic_query { let embedder = build_embedder_from_config(config)?; let bridge = EmbedderBridge(embedder.as_ref()); - tinycortex::memory::retrieval::query_source( + crate::engine::backend::retrieval::query_source( &engine_config(config), source_id, source_kind, @@ -101,13 +101,13 @@ pub async fn query_source_scoped( ) .await? } else { - tinycortex::memory::retrieval::query_source( + crate::engine::backend::retrieval::query_source( &engine_config(config), source_id, source_kind, time_window_days, None, - &tinycortex::memory::score::embed::InertEmbedder::new(), + &crate::engine::backend::score::embed::InertEmbedder::new(), usize::MAX, ) .await? diff --git a/core/src/tree/retrieval/source_scope_tests.rs b/core/src/tree/retrieval/source_scope_tests.rs index a36c4ea..92fd07c 100644 --- a/core/src/tree/retrieval/source_scope_tests.rs +++ b/core/src/tree/retrieval/source_scope_tests.rs @@ -15,7 +15,7 @@ //! (`tinycortex` `retrieval::{fetch,drill_down}`), so on leaves this is //! strictly narrower than predicate 1. `source.rs` / `cover.rs` additionally //! carry a *pre-filter* short circuit on the explicit `source_id` argument. -//! 3. `tinycortex::memory::chunks::store_list::append_source_scope` — a SQL +//! 3. `crate::engine::backend::chunks::store_list::append_source_scope` — a SQL //! predicate applied BEFORE `LIMIT`. Reached via `cover_window_scoped` and //! the raw `list_chunks` callers. It admits `mem_src:src-abc:` (empty item //! id), diverging from predicate 1. diff --git a/core/src/tree/retrieval/types.rs b/core/src/tree/retrieval/types.rs index 91b48fa..83e866e 100644 --- a/core/src/tree/retrieval/types.rs +++ b/core/src/tree/retrieval/types.rs @@ -1,6 +1,6 @@ //! Stable host path for tinycortex-owned retrieval wire types and converters. -pub use tinycortex::memory::retrieval::{ +pub use crate::engine::backend::retrieval::{ hit_from_chunk, hit_from_summary, hit_from_summary_with_tree, leaf_tree_placeholder, EntityMatch, NodeKind, QueryResponse, RetrievalHit, }; diff --git a/core/src/tree/score/extract/mod.rs b/core/src/tree/score/extract/mod.rs index 4768ebf..adc9c5d 100644 --- a/core/src/tree/score/extract/mod.rs +++ b/core/src/tree/score/extract/mod.rs @@ -5,23 +5,21 @@ use std::sync::Arc; use crate::Config; use async_trait::async_trait; -pub use tinycortex::memory::score::extract::{ +pub use crate::engine::backend::score::extract::{ ChatPrompt, ChatProvider, CompositeExtractor, EntityExtractor, EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic, LlmExtractorConfig, RegexEntityExtractor, }; pub mod regex { - pub use tinycortex::memory::score::extract::regex::extract; + pub use crate::engine::backend::score::extract::regex::extract; } -pub struct LlmEntityExtractor(tinycortex::memory::score::extract::LlmEntityExtractor); +pub struct LlmEntityExtractor(crate::engine::backend::score::extract::LlmEntityExtractor); impl LlmEntityExtractor { pub fn new(config: LlmExtractorConfig, provider: Arc) -> Self { - let provider = Arc::new(crate::tinycortex::SeamChatProvider::new(provider)); - Self(tinycortex::memory::score::extract::LlmEntityExtractor::new( - config, provider, - )) + let provider = Arc::new(crate::engine::SeamChatProvider::new(provider)); + Self(crate::engine::backend::score::extract::LlmEntityExtractor::new(config, provider)) } } diff --git a/core/src/tree/score/mod.rs b/core/src/tree/score/mod.rs index 136aeeb..240cf50 100644 --- a/core/src/tree/score/mod.rs +++ b/core/src/tree/score/mod.rs @@ -6,20 +6,20 @@ pub mod store; use std::sync::Arc; -pub use anyhow::Result; -pub use tinycortex::memory::score::{ +pub use crate::engine::backend::score::{ persist_score_tx, score_chunk, score_chunks, score_chunks_fast, ScoreResult, ScoringConfig, DEFAULT_DEFINITE_DROP, DEFAULT_DEFINITE_KEEP, DEFAULT_DROP_THRESHOLD, PRIORITY_BOOST, PRIORITY_TAG, }; -pub use tinycortex::memory::score::{resolver, signals}; +pub use crate::engine::backend::score::{resolver, signals}; +pub use anyhow::Result; /// Build crate scoring policy from product inference routing. pub fn scoring_config_from(config: &crate::Config) -> ScoringConfig { let (provider, model) = match crate::chat::build_chat_runtime(config) { Ok((provider, model)) => ( - Arc::new(crate::tinycortex::SeamChatProvider::new(provider)) - as Arc, + Arc::new(crate::engine::SeamChatProvider::new(provider)) + as Arc, model, ), Err(error) => { @@ -29,8 +29,8 @@ pub fn scoring_config_from(config: &crate::Config) -> ScoringConfig { return ScoringConfig::default_regex_only(); } }; - let extractor = tinycortex::memory::score::extract::LlmEntityExtractor::new( - tinycortex::memory::score::extract::LlmExtractorConfig { + let extractor = crate::engine::backend::score::extract::LlmEntityExtractor::new( + crate::engine::backend::score::extract::LlmExtractorConfig { model, output_language: config.output_language().map(str::to_string), ..Default::default() diff --git a/core/src/tree/score/store.rs b/core/src/tree/score/store.rs index 0a757e8..6f74abc 100644 --- a/core/src/tree/score/store.rs +++ b/core/src/tree/score/store.rs @@ -4,21 +4,21 @@ use std::collections::HashMap; use anyhow::Result; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; use crate::Config; -pub use tinycortex::memory::score::store::{EntityHit, ScoreRow}; +pub use crate::engine::backend::score::store::{EntityHit, ScoreRow}; pub fn upsert_score(config: &Config, row: &ScoreRow) -> Result<()> { - tinycortex::memory::score::store::upsert_score(&engine_config(config), row) + crate::engine::backend::score::store::upsert_score(&engine_config(config), row) } pub fn get_score(config: &Config, chunk_id: &str) -> Result> { - tinycortex::memory::score::store::get_score(&engine_config(config), chunk_id) + crate::engine::backend::score::store::get_score(&engine_config(config), chunk_id) } pub fn get_scores_batch(config: &Config, chunk_ids: &[String]) -> Result> { - tinycortex::memory::score::store::get_scores_batch(&engine_config(config), chunk_ids) + crate::engine::backend::score::store::get_scores_batch(&engine_config(config), chunk_ids) } pub use crate::store::entities::{ @@ -27,7 +27,7 @@ pub use crate::store::entities::{ pub fn index_entity( config: &Config, - entity: &tinycortex::memory::score::resolver::CanonicalEntity, + entity: &crate::engine::backend::score::resolver::CanonicalEntity, node_id: &str, node_kind: &str, timestamp_ms: i64, @@ -39,13 +39,13 @@ pub fn index_entity( pub fn index_entities( config: &Config, - entities: &[tinycortex::memory::score::resolver::CanonicalEntity], + entities: &[crate::engine::backend::score::resolver::CanonicalEntity], node_id: &str, node_kind: &str, timestamp_ms: i64, tree_id: Option<&str>, ) -> Result { - let entities: Vec = entities + let entities: Vec = entities .iter() .map(to_store_entity) .collect::>()?; @@ -60,11 +60,11 @@ pub fn index_entities( } fn to_store_entity( - entity: &tinycortex::memory::score::resolver::CanonicalEntity, -) -> Result { - Ok(tinycortex::memory::store::CanonicalEntity { + entity: &crate::engine::backend::score::resolver::CanonicalEntity, +) -> Result { + Ok(crate::engine::backend::store::CanonicalEntity { canonical_id: entity.canonical_id.clone(), - kind: tinycortex::memory::store::EntityKind::parse(entity.kind.as_str()) + kind: crate::engine::backend::store::EntityKind::parse(entity.kind.as_str()) .map_err(anyhow::Error::msg)?, surface: entity.surface.clone(), span_start: entity.span_start, @@ -74,5 +74,5 @@ fn to_store_entity( } pub fn count_scores(config: &Config) -> Result { - tinycortex::memory::score::store::count_scores(&engine_config(config)) + crate::engine::backend::score::store::count_scores(&engine_config(config)) } diff --git a/core/src/tree/summarise.rs b/core/src/tree/summarise.rs index fc69fdd..0fe5db0 100644 --- a/core/src/tree/summarise.rs +++ b/core/src/tree/summarise.rs @@ -5,7 +5,7 @@ use anyhow::{Context, Result}; use crate::chat::{build_chat_provider, ChatPrompt}; use crate::Config; -pub use tinycortex::memory::tree::{SummaryContext, SummaryInput}; +pub use crate::engine::backend::tree::{SummaryContext, SummaryInput}; /// Compatibility result carrying provider usage alongside the crate-owned /// summary output fields. @@ -25,9 +25,11 @@ pub async fn summarise( inputs: &[SummaryInput], context: &SummaryContext<'_>, ) -> Result { - let Some(prepared) = - tinycortex::memory::tree::prepare_summary_prompt(inputs, context, config.output_language()) - else { + let Some(prepared) = crate::engine::backend::tree::prepare_summary_prompt( + inputs, + context, + config.output_language(), + ) else { return Ok(SummaryOutput::default()); }; let provider = @@ -50,7 +52,7 @@ pub async fn summarise( .await .with_context(|| format!("memory_tree::summarise: provider={}", provider.name()))?; let output = - tinycortex::memory::tree::finish_provider_summary(&text, prepared.effective_budget); + crate::engine::backend::tree::finish_provider_summary(&text, prepared.effective_budget); let input_tokens = usage.as_ref().map_or(0, |usage| usage.input_tokens); let output_tokens = usage.as_ref().map_or(0, |usage| usage.output_tokens); let charged_amount_usd = usage @@ -75,7 +77,7 @@ pub async fn summarise( } pub fn fallback_summary(inputs: &[SummaryInput], budget: u32) -> SummaryOutput { - let output = tinycortex::memory::tree::fallback_summary(inputs, budget); + let output = crate::engine::backend::tree::fallback_summary(inputs, budget); SummaryOutput { content: output.content, token_count: output.token_count, diff --git a/core/src/tree/tree/bucket_seal.rs b/core/src/tree/tree/bucket_seal.rs index 6903832..ca2fd63 100644 --- a/core/src/tree/tree/bucket_seal.rs +++ b/core/src/tree/tree/bucket_seal.rs @@ -3,11 +3,11 @@ use anyhow::Result; use chrono::{DateTime, Utc}; +use crate::engine::engine_config; use crate::store::trees::types::{Buffer, Tree}; -use crate::tinycortex::engine_config; use crate::Config; -pub use tinycortex::memory::tree::{LabelStrategy, LeafRef, MERGE_LEVEL_BASE}; +pub use crate::engine::backend::tree::{LabelStrategy, LeafRef, MERGE_LEVEL_BASE}; pub async fn append_leaf( config: &Config, @@ -23,11 +23,11 @@ pub async fn append_leaf( leaf.token_count as i64, leaf.timestamp, )?; - crate::tinycortex::cascade_tree(config, tree, 0, false, strategy).await + crate::engine::cascade_tree(config, tree, 0, false, strategy).await } pub fn append_leaf_deferred(config: &Config, tree: &Tree, leaf: &LeafRef) -> Result { - tinycortex::memory::tree::append_leaf_deferred(&engine_config(config), tree, leaf) + crate::engine::backend::tree::append_leaf_deferred(&engine_config(config), tree, leaf) } pub fn append_to_buffer( @@ -38,7 +38,7 @@ pub fn append_to_buffer( token_delta: i64, item_ts: DateTime, ) -> Result<()> { - tinycortex::memory::tree::append_to_buffer( + crate::engine::backend::tree::append_to_buffer( &engine_config(config), tree_id, level, @@ -55,7 +55,7 @@ pub async fn cascade_all_from( force_now: Option>, strategy: &LabelStrategy, ) -> Result> { - crate::tinycortex::cascade_tree(config, tree, start_level, force_now.is_some(), strategy).await + crate::engine::cascade_tree(config, tree, start_level, force_now.is_some(), strategy).await } pub async fn seal_document_subtree( @@ -66,7 +66,7 @@ pub async fn seal_document_subtree( chunk_ids: &[String], strategy: &LabelStrategy, ) -> Result { - crate::tinycortex::seal_document_subtree(config, tree, doc_id, version_ms, chunk_ids, strategy) + crate::engine::seal_document_subtree(config, tree, doc_id, version_ms, chunk_ids, strategy) .await } @@ -77,5 +77,5 @@ pub async fn seal_one_level( strategy: &LabelStrategy, enqueue_follow_ups: bool, ) -> Result { - crate::tinycortex::seal_tree_level(config, tree, buffer, strategy, enqueue_follow_ups).await + crate::engine::seal_tree_level(config, tree, buffer, strategy, enqueue_follow_ups).await } diff --git a/core/src/tree/tree/factory.rs b/core/src/tree/tree/factory.rs index fbb738e..2ff6414 100644 --- a/core/src/tree/tree/factory.rs +++ b/core/src/tree/tree/factory.rs @@ -21,36 +21,36 @@ use crate::tree::tree::flush::force_flush_tree; use crate::tree::tree::registry::get_or_create_tree; use crate::Config; -pub use tinycortex::memory::tree::{TreeProfile, GLOBAL_SCOPE}; +pub use crate::engine::backend::tree::{TreeProfile, GLOBAL_SCOPE}; /// Factory/config object for one tree instance. #[derive(Debug, Clone)] pub struct TreeFactory<'a> { - inner: tinycortex::memory::tree::TreeFactory<'a>, + inner: crate::engine::backend::tree::TreeFactory<'a>, } impl<'a> TreeFactory<'a> { pub fn source(scope: impl Into>) -> Self { Self { - inner: tinycortex::memory::tree::TreeFactory::source(scope), + inner: crate::engine::backend::tree::TreeFactory::source(scope), } } pub fn topic(scope: impl Into>) -> Self { Self { - inner: tinycortex::memory::tree::TreeFactory::topic(scope), + inner: crate::engine::backend::tree::TreeFactory::topic(scope), } } pub fn global() -> Self { Self { - inner: tinycortex::memory::tree::TreeFactory::global(), + inner: crate::engine::backend::tree::TreeFactory::global(), } } pub fn from_tree(tree: &'a Tree) -> Self { Self { - inner: tinycortex::memory::tree::TreeFactory::from_tree(tree), + inner: crate::engine::backend::tree::TreeFactory::from_tree(tree), } } diff --git a/core/src/tree/tree/flush.rs b/core/src/tree/tree/flush.rs index 9c781e5..df74586 100644 --- a/core/src/tree/tree/flush.rs +++ b/core/src/tree/tree/flush.rs @@ -12,7 +12,7 @@ pub async fn flush_stale_buffers( max_age: Duration, strategy: &LabelStrategy, ) -> Result { - crate::tinycortex::flush_stale_tree_buffers(config, max_age, strategy).await + crate::engine::flush_stale_tree_buffers(config, max_age, strategy).await } pub async fn flush_stale_buffers_default( diff --git a/core/src/tree/tree_runtime/engine.rs b/core/src/tree/tree_runtime/engine.rs index ca3ec68..a67ee26 100644 --- a/core/src/tree/tree_runtime/engine.rs +++ b/core/src/tree/tree_runtime/engine.rs @@ -2,16 +2,16 @@ use std::sync::Arc; +use crate::engine::backend::tree::runtime::{ + NodeLevel, RuntimeObserver, Summariser, TreeNode, TreeStatus, +}; use anyhow::{Context, Result}; use async_trait::async_trait; use chrono::{DateTime, Timelike, Utc}; use tinyagents::harness::message::Message; use tinyagents::harness::model::{ChatModel, ModelRequest}; -use tinycortex::memory::tree::runtime::{ - NodeLevel, RuntimeObserver, Summariser, TreeNode, TreeStatus, -}; -use crate::tinycortex::engine_config; +use crate::engine::engine_config; use crate::Config; const SUMMARIZATION_TEMP: f64 = 0.3; @@ -83,7 +83,7 @@ pub async fn run_summarization( ts: DateTime, ) -> Result> { log::debug!("[tree_summarizer] tinycortex run namespace={namespace}"); - let result = tinycortex::memory::tree::runtime::run_summarization_observed( + let result = crate::engine::backend::tree::runtime::run_summarization_observed( &engine_config(config), &ChatSummariser(provider), namespace, @@ -105,7 +105,7 @@ pub async fn rebuild_tree( namespace: &str, ) -> Result { log::debug!("[tree_summarizer] tinycortex rebuild namespace={namespace}"); - tinycortex::memory::tree::runtime::rebuild_tree_observed( + crate::engine::backend::tree::runtime::rebuild_tree_observed( &engine_config(config), &ChatSummariser(provider), namespace, @@ -134,8 +134,9 @@ pub async fn run_hourly_loop(config: Arc, provider: Arc PathBuf { - tinycortex::memory::tree::runtime::store::tree_dir(&engine_config(config), namespace) + crate::engine::backend::tree::runtime::store::tree_dir(&engine_config(config), namespace) } pub fn buffer_dir(config: &Config, namespace: &str) -> PathBuf { - tinycortex::memory::tree::runtime::store::buffer_dir(&engine_config(config), namespace) + crate::engine::backend::tree::runtime::store::buffer_dir(&engine_config(config), namespace) } pub fn node_file_path(config: &Config, namespace: &str, node_id: &str) -> PathBuf { - tinycortex::memory::tree::runtime::store::node_file_path( + crate::engine::backend::tree::runtime::store::node_file_path( &engine_config(config), namespace, node_id, ) } -pub use tinycortex::memory::tree::runtime::store::{validate_namespace, validate_node_id}; +pub use crate::engine::backend::tree::runtime::store::{validate_namespace, validate_node_id}; pub fn write_node(config: &Config, node: &TreeNode) -> Result<()> { - tinycortex::memory::tree::runtime::store::write_node(&engine_config(config), node) + crate::engine::backend::tree::runtime::store::write_node(&engine_config(config), node) } pub fn read_node(config: &Config, namespace: &str, node_id: &str) -> Result> { - tinycortex::memory::tree::runtime::store::read_node(&engine_config(config), namespace, node_id) + crate::engine::backend::tree::runtime::store::read_node( + &engine_config(config), + namespace, + node_id, + ) } pub fn read_children(config: &Config, namespace: &str, parent_id: &str) -> Result> { - tinycortex::memory::tree::runtime::store::read_children( + crate::engine::backend::tree::runtime::store::read_children( &engine_config(config), namespace, parent_id, @@ -45,7 +49,7 @@ pub fn read_children(config: &Config, namespace: &str, parent_id: &str) -> Resul } pub fn read_ancestors(config: &Config, namespace: &str, node_id: &str) -> Result> { - tinycortex::memory::tree::runtime::store::read_ancestors( + crate::engine::backend::tree::runtime::store::read_ancestors( &engine_config(config), namespace, node_id, @@ -53,11 +57,11 @@ pub fn read_ancestors(config: &Config, namespace: &str, node_id: &str) -> Result } pub fn count_nodes(config: &Config, namespace: &str) -> Result { - tinycortex::memory::tree::runtime::store::count_nodes(&engine_config(config), namespace) + crate::engine::backend::tree::runtime::store::count_nodes(&engine_config(config), namespace) } pub fn get_tree_status(config: &Config, namespace: &str) -> Result { - tinycortex::memory::tree::runtime::store::get_tree_status(&engine_config(config), namespace) + crate::engine::backend::tree::runtime::store::get_tree_status(&engine_config(config), namespace) } pub fn collect_root_summaries_with_caps( @@ -65,7 +69,7 @@ pub fn collect_root_summaries_with_caps( per_namespace_cap: usize, total_cap: usize, ) -> Vec<(String, String, DateTime)> { - tinycortex::memory::tree::runtime::store::collect_root_summaries_with_caps( + crate::engine::backend::tree::runtime::store::collect_root_summaries_with_caps( workspace_dir, per_namespace_cap, total_cap, @@ -73,11 +77,11 @@ pub fn collect_root_summaries_with_caps( } pub fn list_namespaces_with_root(config: &Config) -> Result> { - tinycortex::memory::tree::runtime::store::list_namespaces_with_root(&engine_config(config)) + crate::engine::backend::tree::runtime::store::list_namespaces_with_root(&engine_config(config)) } pub fn delete_tree(config: &Config, namespace: &str) -> Result { - tinycortex::memory::tree::runtime::store::delete_tree(&engine_config(config), namespace) + crate::engine::backend::tree::runtime::store::delete_tree(&engine_config(config), namespace) } pub fn buffer_write( @@ -87,7 +91,7 @@ pub fn buffer_write( ts: &DateTime, metadata: Option<&Value>, ) -> Result { - tinycortex::memory::tree::runtime::store::buffer_write( + crate::engine::backend::tree::runtime::store::buffer_write( &engine_config(config), namespace, content, @@ -97,11 +101,11 @@ pub fn buffer_write( } pub fn buffer_read(config: &Config, namespace: &str) -> Result> { - tinycortex::memory::tree::runtime::store::buffer_read(&engine_config(config), namespace) + crate::engine::backend::tree::runtime::store::buffer_read(&engine_config(config), namespace) } pub fn buffer_delete(config: &Config, namespace: &str, filenames: &[String]) -> Result<()> { - tinycortex::memory::tree::runtime::store::buffer_delete( + crate::engine::backend::tree::runtime::store::buffer_delete( &engine_config(config), namespace, filenames, @@ -109,9 +113,9 @@ pub fn buffer_delete(config: &Config, namespace: &str, filenames: &[String]) -> } pub fn buffer_drain(config: &Config, namespace: &str) -> Result> { - tinycortex::memory::tree::runtime::store::buffer_drain(&engine_config(config), namespace) + crate::engine::backend::tree::runtime::store::buffer_drain(&engine_config(config), namespace) } pub fn parse_node_markdown_pub(raw: &str, namespace: &str, node_id: &str) -> Result { - tinycortex::memory::tree::runtime::store::parse_node_markdown_pub(raw, namespace, node_id) + crate::engine::backend::tree::runtime::store::parse_node_markdown_pub(raw, namespace, node_id) } diff --git a/crates/tinymemory-module/Cargo.lock b/crates/tinymemory-module/Cargo.lock index e5599eb..6b52126 100644 --- a/crates/tinymemory-module/Cargo.lock +++ b/crates/tinymemory-module/Cargo.lock @@ -1935,6 +1935,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.20", + "tinymemory-api", "uuid", ] diff --git a/crates/tinymemory-module/Cargo.toml b/crates/tinymemory-module/Cargo.toml index adbc845..26e2c18 100644 --- a/crates/tinymemory-module/Cargo.toml +++ b/crates/tinymemory-module/Cargo.toml @@ -79,6 +79,14 @@ tempfile = "3" # which is unusual but correct: these are the same submodules the parent builds # against, and pointing somewhere else would compile the module against a # different engine than the workspace it ships from. +# `tinycortex-api` takes the contract by git (tinymemory#18 §A1). This crate is +# its own workspace root, and patch tables only apply from the root being built, +# so the entry has to be repeated here — without it cargo resolves the git copy +# alongside the path copy and `MemoryTaint` from one is not the same type as +# from the other. +[patch."https://github.com/tinyhumansai/tinymemory"] +tinymemory-api = { path = "../../api" } + [patch.crates-io] tinycortex = { path = "../../vendor/tinycortex" } tinycortex-api = { path = "../../vendor/tinycortex/api" } From 474904e98fb73d7da2596cfafb53364f6316ff13 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 20:18:48 +0530 Subject: [PATCH 13/14] Patch the contract's git dependency in the module crate too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module crate is its own workspace root, and a patch table applies only from the root being built — so the parent workspace's entry does not reach it. Without its own, cargo resolves the git copy of `tinymemory-api` alongside the path copy and `MemoryTaint` from one is not the same type as from the other. Belongs in this commit rather than a later one: this is where the git dependency arrives, so this is where its consequence has to be handled. CI caught it — the module lane failed with eight type mismatches while every other lane was green, because it is the only job that builds from that root. Refs #18 (§A1) --- crates/tinymemory-module/Cargo.lock | 1 + crates/tinymemory-module/Cargo.toml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/crates/tinymemory-module/Cargo.lock b/crates/tinymemory-module/Cargo.lock index e5599eb..6b52126 100644 --- a/crates/tinymemory-module/Cargo.lock +++ b/crates/tinymemory-module/Cargo.lock @@ -1935,6 +1935,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.20", + "tinymemory-api", "uuid", ] diff --git a/crates/tinymemory-module/Cargo.toml b/crates/tinymemory-module/Cargo.toml index adbc845..ced3f1e 100644 --- a/crates/tinymemory-module/Cargo.toml +++ b/crates/tinymemory-module/Cargo.toml @@ -79,6 +79,14 @@ tempfile = "3" # which is unusual but correct: these are the same submodules the parent builds # against, and pointing somewhere else would compile the module against a # different engine than the workspace it ships from. +# `tinycortex-api` takes the contract by git (issue #18 §A1). This crate is its +# own workspace root, and a patch table only applies from the root being built, +# so the entry has to be repeated here — the parent workspace's identical entry +# does not reach it. Without this, cargo resolves the git copy alongside the +# path copy and `MemoryTaint` from one is not the same type as from the other. +[patch."https://github.com/tinyhumansai/tinymemory"] +tinymemory-api = { path = "../../api" } + [patch.crates-io] tinycortex = { path = "../../vendor/tinycortex" } tinycortex-api = { path = "../../vendor/tinycortex/api" } From fd0aff8492ea77a5bbf89fbb8fad22f4a980b724 Mon Sep 17 00:00:00 2001 From: Shanu Date: Tue, 18 Aug 2026 16:34:10 +0530 Subject: [PATCH 14/14] Re-point the tinycortex gitlink at the merged commit tinycortex#149 landed as a squash (8401346b), discarding the branch head this pin pointed at; 34cbb6c is diverged from tinycortex main rather than an ancestor of it. The merged commit is also the one that deletes the 33 duplicated files under api/src/, which is the state this stack depends on. --- vendor/tinycortex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinycortex b/vendor/tinycortex index 34cbb6c..8401346 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit 34cbb6cfa91ea74d62605bd57790782b0c748556 +Subproject commit 8401346b574cacb1dc0cf6b36bc608ff5ef9f6f5