From fe636d46696aa00215f091efe872d12d252f34f9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 16:59:13 +0300 Subject: [PATCH 1/6] chore(deps): add tinymemory as a git submodule Add the tinymemory library as a vendored dependency via a git submodule to provide memory management utilities used by the project. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .gitmodules | 4 ++++ vendor/tinymemory | 1 + 2 files changed, 5 insertions(+) create mode 160000 vendor/tinymemory diff --git a/.gitmodules b/.gitmodules index 9281cb0b03..eccb4cea0f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -23,3 +23,7 @@ path = vendor/tinybus url = https://github.com/tinyhumansai/tinybus.git branch = main +[submodule "vendor/tinymemory"] + path = vendor/tinymemory + url = https://github.com/tinyhumansai/tinymemory.git + branch = main diff --git a/vendor/tinymemory b/vendor/tinymemory new file mode 160000 index 0000000000..68a677f253 --- /dev/null +++ b/vendor/tinymemory @@ -0,0 +1 @@ +Subproject commit 68a677f2535de213599b7dfcdc1a05a34f791554 From 565911a30884cd4cb4511112d662609ef5aaa808 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 17:30:34 +0300 Subject: [PATCH 2/6] chore(deps): update tinymemory dependency Updated the tinymemory dependency to a new version, which includes improvements to memory binding behavior. The Cargo.lock and Cargo.toml files were updated accordingly to reflect the new dependency version. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 40 +++++++++++++++++++++++++++++++++ Cargo.toml | 23 +++++++++++++++++++ src/openhuman/memory/binding.rs | 20 ++++++++--------- vendor/tinymemory | 2 +- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c25e84fa65..4f49d1f8b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4727,6 +4727,9 @@ dependencies = [ "tinyflows", "tinyhumans-sdk", "tinyjuice", + "tinymemory", + "tinymemory-api", + "tinymemory-tinycortex", "tinyplace", "tokio", "tokio-stream", @@ -7382,6 +7385,43 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "tinymemory" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "log", + "serde", + "serde_json", + "tinymemory-api", +] + +[[package]] +name = "tinymemory-api" +version = "0.1.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid 1.23.1", +] + +[[package]] +name = "tinymemory-tinycortex" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "tinycortex", + "tinymemory", + "tinymemory-api", +] + [[package]] name = "tinyplace" version = "2.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7d66a5357b..10589d6dea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,6 +153,29 @@ tinycortex = { version = "0.1", features = [ # the embedded engine. No `[patch.crates-io]` entry is needed: cargo unifies # this with the `path = "api"` dependency the engine crate already declares. tinycortex-api = { path = "vendor/tinycortex/api" } + +# tinymemory — the engine-neutral memory layer +# (https://github.com/tinyhumansai/tinymemory). Owns the parts of the memory +# subsystem that are true for *any* engine: the driver-admission rules +# (`tinymemory::registry`) and the three mandatory capability families composed +# over the `Memory` storage trait (`tinymemory::mandatory`). TinyCortex stays +# the engine; this is the layer a second engine would enter through. +# +# Vendored as a git submodule under `vendor/` beside the other tiny* crates and +# consumed by path: the crate is not published to crates.io, so there is no +# `[patch.crates-io]` entry for it, and for the same reason +# `app/src-tauri/Cargo.toml` needs no `../../` twin — an unpublished path +# dependency resolves transitively through this manifest. +# +# `tinymemory-tinycortex` is the seam between TinyCortex's contract types and +# TinyMemory's. It names `tinycortex` by version requirement, so the +# `[patch.crates-io]` entry below unifies it onto *this* checkout's engine +# rather than the one pinned inside the tinymemory submodule. +# +# After cloning: `git submodule update --init --recursive vendor/tinymemory`. +tinymemory = { path = "vendor/tinymemory" } +tinymemory-api = { path = "vendor/tinymemory/api" } +tinymemory-tinycortex = { path = "vendor/tinymemory/adapters/tinycortex" } tinychannels = { version = "0.1", features = ["relay-websocket"] } # tinybus — the message bus. Owns what `src/core/event_bus/` used to: the typed # pub/sub surface (`EventBus`, `EventHandler`, `SubscriptionHandle`), the diff --git a/src/openhuman/memory/binding.rs b/src/openhuman/memory/binding.rs index 74d64429c5..57d649f204 100644 --- a/src/openhuman/memory/binding.rs +++ b/src/openhuman/memory/binding.rs @@ -72,18 +72,16 @@ use crate::openhuman::memory::guard::{GuardPolicy, MemoryGuard}; /// Why a bind fell back to the placeholder driver. /// -/// `reason` is operator-facing: it is logged, published on the event bus, and -/// rendered in status. It must therefore never interpolate `credential_ref` or -/// `endpoint` from [`crate::openhuman::config::schema::MemoryDriverConfig`], -/// which carries a manual redacting `Debug` for exactly that reason. Pinned by +/// Defined in [`tinymemory::registry`] alongside the admission rules that +/// produce it. `reason` is operator-facing: it is logged, published on the +/// event bus, and rendered in status, so it must never interpolate +/// `credential_ref` or `endpoint` from +/// [`crate::openhuman::config::schema::MemoryDriverConfig`], which carries a +/// manual redacting `Debug` for exactly that reason. The crate enforces this +/// structurally — [`DriverEntry`] carries neither field, so a refusal built +/// there cannot reach one. Pinned by /// `fallback_reason_never_contains_credential_ref_or_endpoint`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FallbackReason { - /// The driver id that was asked for in `[subsystems.memory] driver`. - pub configured_driver: String, - /// Why it was refused. - pub reason: String, -} +pub use tinymemory::registry::FallbackReason; /// One bound memory driver, for one workspace. pub struct MemoryBinding { diff --git a/vendor/tinymemory b/vendor/tinymemory index 68a677f253..c58ca499af 160000 --- a/vendor/tinymemory +++ b/vendor/tinymemory @@ -1 +1 @@ -Subproject commit 68a677f2535de213599b7dfcdc1a05a34f791554 +Subproject commit c58ca499afd853e4b4f205fb88e6ddcb22d98f69 From 0638e0ee2129040efbe3f7690ef211111c0faf8c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 17:31:10 +0300 Subject: [PATCH 3/6] chore: files changed src/openhuman/memory/binding.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/memory/binding.rs | 153 +++++++++++--------------------- 1 file changed, 54 insertions(+), 99 deletions(-) diff --git a/src/openhuman/memory/binding.rs b/src/openhuman/memory/binding.rs index 57d649f204..437f2a0a7a 100644 --- a/src/openhuman/memory/binding.rs +++ b/src/openhuman/memory/binding.rs @@ -218,39 +218,46 @@ pub fn unbound_default_capabilities() -> Capabilities { Capabilities::all() } +/// The registry of driver ids whose class this host fixes. +/// +/// [`DriverRegistry::builtin`] already reserves `null` and `tinycortex`, which +/// are exactly this host's two built-in ids — so the builtin set is used as-is +/// rather than re-declared. A host bundling an adapter the crate does not know +/// about would add it here with `with_reserved`. +fn registry() -> DriverRegistry { + DriverRegistry::builtin() +} + +/// The config-path spellings quoted back to the operator in refusal messages. +/// +/// The crate does not know what this host's config file looks like; these are +/// the blocks an operator would actually edit. +const CONFIG_LABELS: ConfigLabels<'static> = ConfigLabels { + section: "[subsystems.memory]", + drivers: "[subsystems.memory.drivers]", + driver_entry: "[subsystems.memory.drivers.]", +}; + /// The class a built-in driver id is *fixed* to, or `None` for any other id. /// -/// Both built-in ids name one specific implementation, so this is the authority -/// for their class in every path — the implicit one below and the explicit -/// `class = …` line in [`admit`], which may only confirm what this returns. +/// Both built-in ids name one specific implementation, so the registry is the +/// authority for their class in every path — the implicit one and the explicit +/// `class = …` line, which may only confirm what this returns. pub(crate) fn reserved_class(id: &str) -> Option { - match id { - NULL_DRIVER_ID => Some(DriverClass::Null), - EMBEDDED_DRIVER_ID => Some(DriverClass::Embedded), - _ => None, - } + registry().reserved_class(id).map(from_contract_class) } -/// The class a driver id implies when nothing says otherwise. Only the two -/// built-in ids admit: the embedded default and the null placeholder. Anything -/// else — a typo, or an external backend that forgot its `drivers.` entry — -/// is refused so the fallback machinery surfaces the mistake in status instead -/// of mislabelling the bound engine. +/// The contract's driver class in the kernel's generic vocabulary. /// -/// `context` names which part of the config was missing; the refusal echoes it -/// so the operator knows whether to add an entry or a `class` line. -fn implicit_class( - id: &str, - refuse: &impl Fn(&str) -> FallbackReason, - context: &str, -) -> Result<(String, DriverClass), FallbackReason> { - if let Some(class) = reserved_class(id) { - Ok((id.to_string(), class)) - } else { - Err(refuse(&format!( - "unknown driver id \"{id}\": {context}, and the id is neither the \ - embedded default nor \"null\"" - ))) +/// A total three-arm match, which is why both enums were shaped one-for-one. +/// The kernel's own enum is deliberately not replaced by the contract's: it is +/// shared with the subsystems that come after memory, which must not inherit +/// their vocabulary from a *memory* crate. +fn from_contract_class(class: ContractDriverClass) -> DriverClass { + match class { + ContractDriverClass::Embedded => DriverClass::Embedded, + ContractDriverClass::External => DriverClass::External, + ContractDriverClass::Null => DriverClass::Null, } } @@ -259,6 +266,20 @@ fn implicit_class( /// Pure — no I/O, no globals — so the fail-closed trust rule is unit-testable /// without booting anything. /// +/// The rules themselves live in [`tinymemory::registry`], because they are the +/// one part of binding with the same correct answer for every host: a built-in +/// id's class is fixed and an explicit `class` line may only confirm it, an +/// unknown id is refused rather than guessed, and an external driver is +/// fail-closed on trust. This function is the projection from *this* host's +/// config shape onto that decision, and the conversion back into the kernel's +/// generic driver vocabulary. +/// +/// Note what is deliberately **not** passed to the crate: only the `class` and +/// `trust_state` of the driver entry cross, never `credential_ref` or +/// `endpoint`. A refusal message is operator-facing and logged, so the narrow +/// projection is what makes "no secret can appear in a refusal" structural +/// rather than a rule someone has to remember. +/// /// # Errors /// /// Returns the [`FallbackReason`] to record and publish when the configured @@ -266,79 +287,13 @@ fn implicit_class( /// requires the subsystem stay bound, loudly. pub fn admit(cfg: &MemorySubsystemConfig) -> Result<(String, DriverClass), FallbackReason> { let id = cfg.driver.trim(); - if id.is_empty() { - return Err(FallbackReason { - configured_driver: String::new(), - reason: "[subsystems.memory] driver is empty".to_string(), - }); - } - - let refuse = |reason: &str| FallbackReason { - configured_driver: id.to_string(), - reason: reason.to_string(), - }; - - // A driver needs no `[subsystems.memory.drivers.]` entry: the embedded - // default's options still live in the existing `[memory]` blocks. But only - // the two built-in ids are implicitly admitted — anything else is a typo or - // an external backend that forgot its entry, and admitting it would silently - // run TinyCortex under an invented driver id (kernel.md §3.1, one driver per - // slot, named truthfully). Refuse it so the fallback machinery surfaces the - // mistake in status instead of mislabelling the bound engine. - let Some(entry) = cfg.drivers.get(id) else { - return implicit_class(id, &refuse, "no [subsystems.memory.drivers.] entry"); - }; - - let (admitted_id, class) = match entry.class.as_deref() { - // An entry that names no class still cannot admit an arbitrary id: the - // embedded default is the only non-null id that implies Embedded. - None => implicit_class( - id, - &refuse, - "[subsystems.memory.drivers.] has no class line", - )?, - Some(raw) => { - let class = DriverClass::parse(raw).map_err(|e| refuse(&e))?; - // The two built-in ids name a *fixed* implementation, so an - // explicit `class` line may confirm it but never override it. - // Without this, `driver = "null"` plus - // `[subsystems.memory.drivers.null] class = "embedded"` would build - // `EmbeddedMemoryProvider`, advertise all thirteen families and - // persist memory under the id documented as `/dev/null`; the - // inverse would label a store-nothing provider `tinycortex`. Either - // way the bound engine is mislabelled, which is exactly what the - // implicit-class refusal above exists to prevent (kernel.md §3.1 — - // one driver per slot, named truthfully). - if let Some(fixed) = reserved_class(id) { - if class != fixed { - return Err(refuse(&format!( - "driver id \"{id}\" is built in and is always class \ - \"{}\"; remove the conflicting class = \"{raw}\" line", - fixed.as_str() - ))); - } - } - (id.to_string(), class) - } - }; - - if class == DriverClass::External { - // kernel.md §3.4: fail-closed. Trust must be explicitly raised before - // an out-of-process driver is allowed to answer for memory. - if entry.trust_state != "trusted" { - return Err(refuse( - "external driver is untrusted: set trust_state = \"trusted\" \ - under [subsystems.memory.drivers] to allow this binding", - )); - } - // Distinct reason string from the trust refusal above, so the trust - // test cannot pass for the wrong reason. - return Err(refuse( - "external driver transport is not implemented yet (the http adapter lands in M4)", - )); - } + let entry = cfg.drivers.get(id).map(|entry| DriverEntry { + class: entry.class.as_deref(), + trust_state: entry.trust_state.as_str(), + }); - Ok((admitted_id, class)) + let admission = registry().admit(&cfg.driver, entry, CONFIG_LABELS)?; + Ok((admission.id, from_contract_class(admission.class))) } /// Build the binding for a workspace. Infallible by design: an inadmissible From 622c18dcd91c03cfd15998894d007bd47463b5ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 17:34:37 +0300 Subject: [PATCH 4/6] fix(memory): handle missing binding in embedded driver When the embedded memory driver encounters a binding that is not present in its registry, it now returns an appropriate error instead of panicking or silently failing. This ensures predictable error handling when the driver is used with an incomplete or mismatched binding configuration. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/memory/binding.rs | 4 ++++ src/openhuman/memory/driver/embedded/mod.rs | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/openhuman/memory/binding.rs b/src/openhuman/memory/binding.rs index 437f2a0a7a..485dd25fbb 100644 --- a/src/openhuman/memory/binding.rs +++ b/src/openhuman/memory/binding.rs @@ -63,6 +63,10 @@ use tinycortex_api::null::{NullMemoryProvider, NULL_DRIVER_ID}; use tinycortex_api::provider::MemoryProvider; use tinycortex_api::CONTRACT_VERSION; +use tinymemory::registry::{ + ConfigLabels, DriverClass as ContractDriverClass, DriverEntry, DriverRegistry, +}; + use crate::core::subsystem::{ BoundDriver, DriverCapabilities, DriverClass, DriverHealth, SubsystemSlot, }; diff --git a/src/openhuman/memory/driver/embedded/mod.rs b/src/openhuman/memory/driver/embedded/mod.rs index 068aab661d..66516d9d51 100644 --- a/src/openhuman/memory/driver/embedded/mod.rs +++ b/src/openhuman/memory/driver/embedded/mod.rs @@ -75,7 +75,15 @@ use crate::openhuman::memory::Memory; /// Matches the `[subsystems.memory] driver` default (`default_memory_driver` /// in `config::schema::subsystems`), so a default-configured host reports the /// same id from config and from the bound provider. -pub const EMBEDDED_DRIVER_ID: &str = "tinycortex"; +/// +/// **Re-exported, not re-declared.** The same id is what +/// [`tinymemory::registry`] reserves at [`DriverClass::Embedded`], and +/// admission is decided there. Two independent string literals that must agree +/// is precisely the kind of pair that silently stops agreeing: the day one is +/// edited, `admit` would stop recognising this driver and every bind would fall +/// back to the null placeholder — loudly in the logs, but with memory writes +/// discarded for the whole run. One constant, one definition, no drift. +pub use tinymemory::registry::TINYCORTEX_DRIVER_ID as EMBEDDED_DRIVER_ID; /// The families this driver advertises: **all thirteen**. /// From 7cff5f0ce83b3db6f8e4612e3ffe32443b2a573f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 17:50:56 +0300 Subject: [PATCH 5/6] feat(memory): install tinymemory and source driver admission from it Adds vendor/tinymemory as a submodule and moves the memory subsystem's driver-admission rules onto it. Those rules are the one part of binding with the same correct answer for every host: a built-in id's class is fixed and an explicit class line may only confirm it, an unknown id is refused rather than guessed, and an external driver is fail-closed on trust. binding.rs keeps what is genuinely host-side -- the per-workspace cache, the conversion into the kernel's generic driver vocabulary, and guard construction -- and becomes a projection from this host's config shape onto the crate's decision. Only class and trust_state cross; credential_ref and endpoint cannot, because DriverEntry has no such fields. That makes 'no secret can appear in an operator-facing refusal' structural rather than a rule someone has to remember. EMBEDDED_DRIVER_ID is now re-exported from the registry rather than declared a second time. Two string literals that must agree is exactly the pair that silently stops agreeing, and the failure is quiet: admit would stop recognising the driver and every bind would fall back to the null placeholder, discarding memory writes for the whole run. All 28 binding tests pass unchanged, including the twelve admit rule tests and fallback_reason_never_contains_credential_ref_or_endpoint. The dependency is a path dep with no [patch.crates-io] entry, so app/src-tauri/Cargo.toml needs no change. tinymemory-tinycortex names tinycortex by version requirement, so this checkout's existing patch unifies it onto one engine copy -- verified: exactly one tinycortex resolves. Co-authored-by: Medulla --- src/openhuman/memory/binding.rs | 2 +- src/openhuman/memory/binding_tests.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/openhuman/memory/binding.rs b/src/openhuman/memory/binding.rs index 485dd25fbb..f6716fa13c 100644 --- a/src/openhuman/memory/binding.rs +++ b/src/openhuman/memory/binding.rs @@ -71,7 +71,7 @@ use crate::core::subsystem::{ BoundDriver, DriverCapabilities, DriverClass, DriverHealth, SubsystemSlot, }; use crate::openhuman::config::schema::{MemoryHooksConfig, MemorySubsystemConfig}; -use crate::openhuman::memory::driver::embedded::{EmbeddedMemoryProvider, EMBEDDED_DRIVER_ID}; +use crate::openhuman::memory::driver::embedded::EmbeddedMemoryProvider; use crate::openhuman::memory::guard::{GuardPolicy, MemoryGuard}; /// Why a bind fell back to the placeholder driver. diff --git a/src/openhuman/memory/binding_tests.rs b/src/openhuman/memory/binding_tests.rs index 8f1ab73b04..87e2e95136 100644 --- a/src/openhuman/memory/binding_tests.rs +++ b/src/openhuman/memory/binding_tests.rs @@ -11,6 +11,11 @@ use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; +// Imported here rather than re-exported from `binding.rs`: since admission +// moved to `tinymemory::registry`, the production module no longer names this +// constant and an import kept alive only for the tests would read as dead code. +use crate::openhuman::memory::driver::embedded::EMBEDDED_DRIVER_ID; + use async_trait::async_trait; use tinycortex_api::capabilities::Capability; use tinycortex_api::error::MemoryError; From 54f8710f9a6257833e2580d3203915dc3a11cc3d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 17:57:34 +0300 Subject: [PATCH 6/6] chore(ci): add tinymemory submodule to workflows and update dependencies Add the tinymemory vendored submodule to CI and release workflows, update the Cargo.lock with the new tinymemory crates, and document the memory subsystem architecture in AGENTS.md and the memory plan specification. This change integrates the engine-neutral memory layer alongside the existing tinycortex engine, enabling future support for multiple memory backends through a shared contract and driver admission system. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/workflows/ci-lite.yml | 4 +-- .github/workflows/release-production.yml | 2 +- .github/workflows/release-staging.yml | 2 +- .github/workflows/test-reusable.yml | 4 +-- AGENTS.md | 32 +++++++++++++++++ app/src-tauri/Cargo.lock | 40 +++++++++++++++++++++ docs/specs/plan-memory.md | 44 ++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 1930735c5f..3f46e6d58f 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -929,10 +929,10 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Init tinycortex submodule + - name: Init tinycortex and tinymemory submodules run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule update --init --recursive vendor/tinycortex + git submodule update --init --recursive vendor/tinycortex vendor/tinymemory - name: Cache TinyCortex build artifacts uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index fe6a57fedd..b4be0ba96c 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -401,7 +401,7 @@ jobs: # fork the core image doesn't need. The Dockerfile COPYs vendor/ because # [patch.crates-io] resolves Rust SDK crates from vendor/. - name: Init vendored Rust submodules - run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk + run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk vendor/tinybus vendor/tinymemory - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to GHCR diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 40c2daa1db..0d5bc50de9 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -297,7 +297,7 @@ jobs: # fork the core image doesn't need. The Dockerfile COPYs vendor/ because # [patch.crates-io] resolves Rust SDK crates from vendor/. - name: Init vendored Rust submodules - run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk + run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk vendor/tinybus vendor/tinymemory - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Build image (no push) diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 9a0d42c92b..6ee0cd87f7 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -215,10 +215,10 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Init tinycortex submodule + - name: Init tinycortex and tinymemory submodules run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule update --init --recursive vendor/tinycortex + git submodule update --init --recursive vendor/tinycortex vendor/tinymemory - name: Cache TinyCortex build artifacts uses: Swatinem/rust-cache@v2 diff --git a/AGENTS.md b/AGENTS.md index 1716b8306b..e7b8376324 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,38 @@ Embedded provider webviews **must not** grow new JS injection. No new `.js` unde ## Rust core (`src/`) +### Memory — `src/openhuman/memory/` over `tinymemory` and `tinycortex` + +The memory subsystem sits on two vendored crates with different jobs: + +- **[`tinycortex`](https://github.com/tinyhumansai/tinycortex)** (`vendor/tinycortex`) — the + **engine**: content store, chunks, vectors, summary trees, retrieval, scoring, the job model. + Reached through the seam at `src/openhuman/memory/tinycortex/`. +- **[`tinymemory`](https://github.com/tinyhumansai/tinymemory)** (`vendor/tinymemory`) — the + **engine-neutral layer**: the contract, driver admission, the shared mandatory capability + families, and one adapter per engine. This is the layer a second engine (Supermemory, mem0, an + HTTP backend) would enter through. Path dependency, no `[patch.crates-io]` entry, so + `app/src-tauri/Cargo.toml` needs no `../../` twin. + +Today the host uses `tinymemory::registry` for driver admission and re-exports +`TINYCORTEX_DRIVER_ID` from it, so the driver id has one definition. **The host's own contract is +still `tinycortex-api`**, not `tinymemory-api`: the two are distinct crates describing the same +values, and `tinymemory-tinycortex` converts between them at one seam. Do not mix the two type +sets in host code — if a `tinymemory_api::` type appears outside `memory/driver/`, that is the +boundary leaking. Plan and rationale: +[`docs/specs/plan-memory.md`](docs/specs/plan-memory.md) (see the 2026-08-10 amendment). + +**Adding a memory engine** means an adapter crate in `tinymemory`, not a branch in host code. The +contract's optional families default to absent, so an engine advertises only what it can actually +answer — and `audit_provider` fails the bind if the advertised set and the reachable accessors +disagree. + +**`admit` rules live in `tinymemory::registry`.** A built-in driver id's class is fixed and an +explicit `class` line may only confirm it; an unknown id is refused rather than guessed; an +external driver is fail-closed on trust. Only `class` and `trust_state` cross into the crate — +never `credential_ref` or `endpoint` — so an operator-facing refusal structurally cannot carry a +secret. + ### Backend API access — `src/api/` over `tinyhumans-sdk` Calls to the TinyHumans cloud backend go through the vendored diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index a6151895bc..f7bff76a84 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5263,6 +5263,9 @@ dependencies = [ "tinyflows", "tinyhumans-sdk", "tinyjuice", + "tinymemory", + "tinymemory-api", + "tinymemory-tinycortex", "tinyplace", "tokio", "tokio-stream", @@ -8496,6 +8499,43 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "tinymemory" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "log", + "serde", + "serde_json", + "tinymemory-api", +] + +[[package]] +name = "tinymemory-api" +version = "0.1.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid 1.23.1", +] + +[[package]] +name = "tinymemory-tinycortex" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "tinycortex", + "tinymemory", + "tinymemory-api", +] + [[package]] name = "tinyplace" version = "2.0.4" diff --git a/docs/specs/plan-memory.md b/docs/specs/plan-memory.md index f8bab33562..2c1f297442 100644 --- a/docs/specs/plan-memory.md +++ b/docs/specs/plan-memory.md @@ -6,6 +6,50 @@ --- +## Amendment 2026-08-10 — the destination is `tinymemory`, not `vendor/tinycortex` + +This plan was written when TinyCortex was the only engine in view, so it named +`vendor/tinycortex` as the destination for everything that moves. That is now split in two, +because "engine-specific" and "engine-neutral" are different destinations: + +| Kind of code | Destination | Why | +| --- | --- | --- | +| TinyCortex's own storage, retrieval, tree, queue and sync internals (§6.2) | `vendor/tinycortex` — unchanged | It is the implementation of *one* engine. | +| The contract, capability negotiation, driver admission, the conformance corpus, the shared mandatory families, and one adapter per engine | **`vendor/tinymemory`** (new submodule) | A second engine cannot be reached through a crate named after the first. | + +**What landed (2026-08-10).** + +- `tinymemory-api` — the contract, moved out of `tinycortex-api` byte-identical. §3.1's carve-out + still describes how it was *created*; it now lives one repository over. +- `tinymemory::registry` — driver admission, lifted out of `memory/binding.rs`. §4's driver table + is where new ids get reserved. +- `tinymemory::mandatory` — the three mandatory families composed once over the `Memory` storage + trait, so a new engine inherits the four easy-to-get-wrong parts (taint routing, all-namespace + list, scoped-recall refusal, import provenance) instead of re-deriving them. +- `tinymemory-tinycortex` — the seam, with exhaustively-destructuring conversions. +- `vendor/tinycortex` is pinned as a nested submodule of `tinymemory`. Adapters name their engine + by **version requirement**, so a host's own `[patch.crates-io]` unifies everything onto one + engine copy; a path dependency would give the host two engines with two incompatible `Memory` + traits. + +**Two contracts, converted at one seam — a deliberate deviation.** `tinycortex-api` was *not* +turned into a re-export of `tinymemory-api`. The two therefore remain distinct Rust types that +describe the same values, and `tinymemory-tinycortex` converts between them. The cost is real and +should be stated: a value crossing the seam is rebuilt, and the two contracts can drift. The +mitigation is that every conversion destructures exhaustively, so a field added on either side is +a compile error naming the field rather than a silently dropped value. + +The consequence for this plan: **openhuman stays on `tinycortex-api`** as its contract. §6.7's grep +invariant is unchanged. A future phase that unifies the two contracts would re-point the host's +~200 `tinycortex_api::` references in one mechanical pass; until then, the seam is the boundary. + +**Sequencing unchanged.** M8a (the `source_scope` inversion) is still the hard gate before any +`memory_tree/retrieval` file moves, and M8b's per-module moves still go to `vendor/tinycortex`. +This amendment changes *where the engine-neutral layer lives*, not the order in which the engine +moves. + +--- + ## 1. Goals 1. **Memory becomes an API, not an implementation.** The kernel owns a versioned memory contract;