Skip to content

Unify the memory APIs into one schema, abstract the engine, and feature-gate every backend #18

Description

@senamakel

Summary

An audit of the workspace as it stands (api/, src/, core/, adapters/*, crates/tinymemory-module) finds that the repository ships three parallel memory APIs, that tinymemory-core is coupled to TinyCortex in 97 of its 223 source files, that the driver-admission machinery the README describes is never invoked at runtime, and that no engine is feature-gated — so every consumer compiles SQLite, libgit2 (optionally), reqwest, axum, tinyagents and the whole TinyCortex engine whether it uses them or not.

This issue proposes one unified memory schema, a fully abstracted memory surface, the relocation of provider sync (Composio and friends) onto that surface, hard containment of everything TinyCortex-specific behind a feature-gated module, and the feature/E2E test matrix that would keep all of it honest.

No code changes are proposed here — this is the design and scope record.


Part 1 — Audit findings

1.1 Three memory APIs describe the same thing

Surface Where Shape Who implements it
tinymemory_api::traits::Memory api/src/traits.rs 10 async fn, anyhow-typed TinycortexMemory, the three remote adapters
tinymemory_api::provider::MemoryProvider api/src/provider/ 13 capability families, MemoryError-typed NullMemoryProvider, MemoryTraitProvider (3 families), ModuleMemoryProvider (13 families)
tinycortex::memory::Memory + tinycortex::memory::* vendor/tinycortex the engine's own trait and ~15 submodules UnifiedMemory, and 97 files in core/ that call it directly

core/src/traits.rs re-exports the TinyCortex trait, not the TinyMemory one:

pub use tinycortex::memory::{
    Memory, MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts,
};

So tinymemory_core::Memory and tinymemory_api::traits::Memory are two distinct Rust traits with identical method sets, and tinymemory_core::MemoryEntry and tinymemory_api::types::MemoryEntry are two distinct structs with identical fields. adapters/tinycortex/src/convert.rs exists solely to rebuild values field-by-field across that seam, and its own module docs say so:

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.

The file is careful and exhaustively destructures on purpose, which is the right mitigation — but the duplication it mitigates is the actual defect. Every new field is now a three-place edit (tinycortex-api, tinymemory-api, convert.rs) plus a conversion test.

1.2 The abstraction is bypassed by the code that matters

MemoryProvider advertises 13 families. Outside of NullMemoryProvider (the /dev/null reference) and ModuleMemoryProvider (which lives in an excluded, non-member crate), nothing implements the ten optional families:

  • adapters/tinycortex deliberately implements the mandatory three only, and documents why.
  • adapters/remote (Supermemory, Mem0, Cognee) implement the mandatory three only.
  • core/ implements the substance of trees, chunks, entities, graph, diff, goals, tool-memory, ingestion and sources — against tinycortex::memory::* directly, never through MemoryProvider.

Net effect: the contract is real for out-of-process module consumers and decorative for everyone in-tree. A second engine "binds in its place without the host learning anything new" (README) only for store/recall/export; every other feature of the product would silently vanish.

1.3 Driver admission is dead code

src/registry/ implements class reservation, config labels, and a fail-closed external-driver gate. Grepping the whole workspace for callers:

  • DriverRegistry::admit — referenced only in a doc comment in src/lib.rs.
  • MemoryHostConfig::memory_provider() — defined on the config seam, implemented by TestHostConfig and ModuleConfig, read by nobody.

The engine is chosen by calling create_memory_client_with_local_ai in core/src/store/factories.rs, which constructs TinyCortex unconditionally. Configuration cannot select an engine today.

1.4 Composio sync is welded to the engine

core/src/sync/composio/ is presented as the host-side sync layer, but the execution path is crate::tinycortex::run_composio_connection*:

  • core/src/sync/composio/mod.rs:164
  • core/src/sync/composio/providers/traits.rs:62
  • core/src/sync/composio/providers/gmail/provider.rs:155
  • core/src/sync/composio/providers/notion/provider.rs:282
  • core/src/sync/composio/providers/slack/provider.rs:236

Sync state (DailyBudget, SyncState, KV_NAMESPACE), the payload normalisers, and the source readers (core/src/sources/readers/*.rs) are all TinyCortex re-exports or thin Config adapters over TinyCortex readers. The same holds for core/src/sources/types.rs.

Consequence: a Mem0 or Supermemory deployment gets zero Composio sync, and the MemorySourceSink / MemoryIngest families — which exist precisely to express "pull upstream data into memory" — are unused by the sync layer that needs them.

1.5 Engine-specific concerns leak out of the engine module

core/src/tinycortex/ holds 11 files. TinyCortex-shaped concerns living outside it include:

  • core/src/people/ — resolver, store, and the macOS CNContactStore address-book reader (contacts feature). Contacts are an engine/product concern, not part of a memory contract.
  • core/src/persona-adjacent config (core/src/tinycortex/persona.rs is in the right place, but its dirs-based vault default leaks into the crate's dependency set).
  • core/src/diff/ — ops and stubs shaped around the TinyCortex git ledger.
  • core/src/tree/, core/src/store/chunks/, core/src/queue/ — 96 references to tinycortex::memory::tree, 67 to ::chunks, 29 to ::queue.

1.6 Nothing is feature-gated, so nothing is small

core/Cargo.toml has exactly three features: memory-git, test-support, contacts. There is no feature for an engine. Unconditional, non-optional dependencies include tinycortex (with obsidian, persona, sync on), tinycortex-api, tinyagents (with sqlite), rusqlite (bundled — compiles SQLite from source), reqwest (+rustls), axum, tokio (full), regex, walkdir, uuid, sha2, chrono, dirs, rand, url.

A host that wants Mem0 over HTTP still compiles bundled SQLite and the entire embedded engine. The facade crate (tinymemory) is dependency-light and fine; tinymemory-core is the problem, and it is what the module and every real consumer depend on.

api/Cargo.toml documents a forbidden-dependency guard as a comment with a suggested cargo tree invocation. It is not enforced anywhere in CI.

1.7 Test coverage is lopsided, and there is no cross-crate E2E

1,163 test attributes across 128 files, distributed:

Crate #[test] / #[tokio::test]
core 879
api 186
crates/tinymemory-module 44
src (facade) 33
adapters/tinycortex 18
adapters/remote 3

Structural gaps:

  • No tests/ directory at the workspace root, and none in api/, adapters/*, or the facade. AGENTS.md mandates tests/ for public-API integration tests; only core/tests/ exists, and it contains fixtures only, no test files.
  • No examples/ at the root (AGENTS.md requires runnable, CI-compiled examples and the README's cargo run --example basic does not exist). The only example is adapters/remote/examples/conformance.rs.
  • Three tests total for the remote adapters — one happy-path round trip per engine, against a hand-rolled axum double. No error mapping, no pagination, no taint preservation, no Unsupported behaviour.
  • No conformance suite any driver can be run through. audit_provider checks advertised-vs-reachable but nothing checks behavioural equivalence across engines.
  • No feature-matrix CI. CI runs --all-features and default only. memory-git off/on, contacts, and test-support are never exercised in isolated combinations.
  • The integration/remote-engines/ Docker harness is documented but is not run by any workflow.
  • docs/specs/ contains one spec (tinybus-module.md) and docs/plans/ is empty, contrary to AGENTS.md's spec-then-plan rule.

1.8 Smaller findings

  • rust_out and tmp — two 3.6 MB executables — are committed at the repository root.
  • worktrees/cognee-supermemory/ is present in the working tree with its own target/.
  • MSRV is inconsistent: 1.96 for the facade and the module, 1.85 for api, core, and both adapters.
  • README.md documents a layout (src/registry/, src/mandatory/, adapters/, vendor/) that omits core/ entirely — the largest crate in the repository is undocumented in the README.
  • tinymemory-core's doc comment still frames the crate as "extracted from OpenHuman's src/openhuman/memory/", and default_openhuman_dir() hardcodes ~/.openhuman in an engine-neutral crate.

Part 2 — Proposed work

A. One schema

A1. Delete the duplicate value types. Pick tinymemory-api as the single source of truth for MemoryEntry, MemoryCategory, MemoryTaint, NamespaceSummary, RecallOpts, Capability, and the provider::types set. tinycortex-api either depends on tinymemory-api and re-exports, or is retired in favour of it. Either way adapters/tinycortex/src/convert.rs collapses to nothing.

A2. One Memory trait. tinymemory_api::traits::Memory is the storage trait; tinymemory_core::traits::Memory becomes a re-export of it, not of the engine's. UnifiedMemory implements the TinyMemory trait directly.

A3. MemoryProvider becomes the only in-tree memory surface. Everything in core/ that today calls tinycortex::memory::* goes through a &dyn MemoryProvider (or a family accessor). Where a family is missing a method the product needs, the fix is to widen the family — not to reach past it.

A4. Retire the anyhow / MemoryError split at the boundary: one error type across the contract, so "unsupported" is distinguishable from "failed" on every path, not just the provider one.

A5. Wire the registry. MemoryHostConfig::memory_provider() selects the driver; DriverRegistry::admit gates it; create_memory_* in core/src/store/factories.rs returns a bound Arc<dyn MemoryProvider> chosen by that decision instead of a hardcoded TinyCortex client. Until this lands, src/registry/ is untested-in-anger code.

B. Sync moves up, onto the memory API

B1. core/src/sync/ (Composio, workspace watcher, MCP) is rewritten against MemoryIngest, MemorySourceSink, MemoryDocuments and the KV surface. No crate::tinycortex::* call from any file under core/src/sync/.

B2. Sync state (DailyBudget, SyncState, budget accounting, the KV namespace) becomes engine-neutral, persisted through the provider's KV surface rather than re-exported from tinycortex::memory::sync.

B3. Payload normalisers (normalize::{github,linear,notion,gmail,slack}) are pure Value → Value transforms with no engine dependency. Move them back into a tinymemory-sync crate — or a sync module of core — so a non-TinyCortex engine gets Composio sync for free.

B4. core/src/sources/readers/* reader implementations likewise: a reader produces SourceItems and hands them to MemoryIngest. core/src/sources/types.rs stops being a TinyCortex re-export shim.

B5. Acceptance test for this section: Composio Gmail sync completes end to end against a driver that is not TinyCortex.

C. TinyCortex contained

C1. Everything engine-specific lives under core/src/tinycortex/ (or, better, moves wholesale into adapters/tinycortex), behind a tinycortex feature. Nothing outside that module names the tinycortex crate. Target: 0 of the current 97 files.

C2. Contacts / people (core/src/people/, including the macOS CNContactStore reader) move behind the engine module and stay gated by contacts, which in turn requires tinycortex. Same for persona, obsidian vault handling, and the git diff ledger (memory-git).

C3. adapters/tinycortex grows implementations of the optional families it can actually serve — tree, documents, ingest, entities, graph, diff, tool-memory, sources, maintenance — so MemoryTraitProvider's three-family ceiling stops being the reason core/ bypasses the contract. Much of ModuleMemoryProvider (crates/tinymemory-module/src/provider.rs, 1,197 lines) is exactly this code and should be lifted down into the adapter, leaving the module crate a thin bus transport.

C4. default_openhuman_dir() and the OpenHuman framing come out of tinymemory-core.

D. Feature-gated engines

D1. Engine features on the facade and on core:

[features]
default = []
tinycortex = ["dep:tinymemory-tinycortex", "dep:tinycortex"]
supermemory = ["dep:tinymemory-remote"]
mem0        = ["dep:tinymemory-remote"]
cognee      = ["dep:tinymemory-remote"]
# capability add-ons, each requiring the engine that serves it
memory-git  = ["tinycortex", "tinycortex/git-diff", "tinycortex/wiki-git"]
contacts    = ["tinycortex", ...]
sync-composio = []   # engine-neutral once B lands
embeddings-local = ["dep:tinyagents"]

D2. Every heavy dependency in core/Cargo.toml becomes optional = true and is reachable only through a feature: rusqlite, tinyagents, axum, reqwest, walkdir, dirs, rand, regex. Default features build the contract, the registry, the mandatory composition, and nothing that links C.

D3. With --no-default-features, the crate must still compile and bind NullMemoryProvider.

D4. Enforce api/'s forbidden-dependency rule in CI with the forward-form cargo tree check its own manifest already spells out, rather than leaving it as a comment.

D5. Publish a size/timing budget in CI: dependency count and cold build time for --no-default-features, for --features mem0, and for --features tinycortex, so a regression is visible on the PR that causes it.

E. Test plan

E1. Driver conformance suite — a tinymemory-conformance crate exposing one function that takes an Arc<dyn MemoryProvider> and asserts the contract:

  • mandatory three: store/get/forget/list/namespaces; recall ordering and limit; export pagination and import round-trip.
  • store_with_taint preserves ExternalSync (the failure this repo already calls out as security-relevant).
  • upsert semantics on (namespace, key).
  • unicode, empty-string, and oversized keys/content.
  • every unadvertised family returns MemoryError::Unsupported, never Ok.
  • audit_provider reports no advertised-but-unreachable family.

Run it against: NullMemoryProvider, an in-memory reference driver, the TinyCortex adapter, and each of the three remote adapters (against the integration/remote-engines/ Docker harness, gated as live_*). This is what makes "swap the engine" a claim with evidence.

E2. Feature-matrix CI job — build + test each of:

--no-default-features · --features tinycortex · --features tinycortex,memory-git · --features tinycortex,contacts (macOS runner) · --features mem0 · --features supermemory · --features cognee · --features sync-composio · --all-features

Plus a cargo hack --feature-powerset --depth 2 check-only pass to catch feature-unification breakage.

E3. Workspace-level integration tests (tests/ at the root, public API only):

  • driver_selection.rs — config names an engine; the registry admits it; the bound provider's driver_id() matches; an unreserved external id is refused fail-closed.
  • capability_negotiation.rs — a host filters its surface from the cached capability set; a driver that lies fails the audit.
  • taint_end_to_end.rs — external content stored through the sync path arrives with ExternalSync at every engine.
  • null_provider.rs — the compiled-out configuration is usable and returns Unsupported rather than panicking.

E4. Sync E2E — with a mock Composio API and a mock provider API:

  • Gmail / Slack / Notion / GitHub / Linear / ClickUp: fetch → normalise → ingest → recall the ingested content, asserted through MemoryProvider only.
  • incremental sync respects the persisted cursor.
  • daily budget exhaustion stops the sync and records the reason.
  • the same suite runs against two different drivers and asserts identical observable outcomes.

E5. Module E2E — keep the existing one-process-per-test loader job, and extend it to cover every family the module advertises (today the E2E is a single 569-line file), plus a shutdown/restart cycle.

E6. Property and failure-path tests:

  • round-trip properties on the wire types (api/src/wire.rs) and on export/import.
  • MemoryTaint::from_db_str fails closed on arbitrary input.
  • Capability::parse / as_str round-trip for all 13, with the serde strings pinned (partially covered today).
  • backend error, timeout, and partial-page responses on every remote adapter — currently zero coverage.

E7. Docs and examples as tests — restore cargo run --example basic referenced by the README, add one example per engine, and keep cargo doc -D warnings green (already in CI).

E8. Coverage gateAGENTS.md asks for 80% of meaningful library behaviour; nothing measures it. Add cargo llvm-cov reporting, with adapters/remote (3 tests) as the first target.

F. Housekeeping

  • Remove the committed rust_out and tmp binaries; add them to .gitignore.
  • Remove worktrees/ from the working tree, or ignore it.
  • Align MSRV to one value across all six crates.
  • Document core/ in README.md.
  • Write the spec for this change into docs/specs/ and the ordered plan into docs/plans/, per AGENTS.md.

Suggested sequencing

Each step should land green on its own.

  1. F — housekeeping, so the diff of everything after it is readable.
  2. E1 + E3 — write the conformance suite and workspace integration tests against the current behaviour first. They are the safety net for everything below, and they will immediately document which families the TinyCortex adapter does not serve.
  3. A1 + A2 — collapse the duplicate types and traits; delete convert.rs.
  4. C3 — lift the optional-family implementations from crates/tinymemory-module into adapters/tinycortex.
  5. A3 + A4 + A5 — route core/ through MemoryProvider; wire the registry; unify the error type.
  6. B — move sync up onto the memory API. Gated on 5.
  7. C1 + C2 + C4 — containment; verified by the "0 files reference tinycortex outside the engine module" check.
  8. D — feature-gate the engines and the heavy dependencies. This is only safe once 5–7 have removed the unconditional call sites.
  9. E2 + E4–E8 — the full test and CI matrix.

Acceptance criteria

  • grep -rl tinycortex core/src --include='*.rs' matches only files under the engine module (today: 97 files outside it).
  • cargo build --no-default-features links no SQLite, no libgit2, no reqwest, no async runtime beyond what the contract needs.
  • cargo tree -p tinymemory-api -e normal,build --prefix none | grep -Ei 'rusqlite|libsqlite|git2|reqwest|regex|tokio' is empty, checked in CI.
  • One Memory trait and one set of value types in the workspace; adapters/tinycortex/src/convert.rs is deleted.
  • The conformance suite passes for TinyCortex and all three remote adapters.
  • Composio sync completes end to end against a non-TinyCortex driver.
  • The feature matrix in E2 is green in CI.
  • config.memory_provider() selects the engine and DriverRegistry::admit gates it, both covered by tests.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions