From 34cbb6cfa91ea74d62605bd57790782b0c748556 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 17 Aug 2026 19:17:49 +0530 Subject: [PATCH 1/2] Re-export the TinyMemory contract instead of duplicating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tinymemory#18 §A1. This crate and `tinymemory-api` described the same memory value types — `MemoryEntry`, `MemoryCategory`, `MemoryTaint`, `NamespaceSummary`, `RecallOpts`, `Capability`, the `provider::types` set — because the latter was extracted from the former and held byte-identical. They were the same code and nominally distinct types, so `adapters/tinycortex/src/convert.rs` in tinymemory had to translate between them on every call, and a field added to one had to be added to the other and to the conversion, in three places, or a value was silently dropped. `api/` now re-exports `tinymemory-api` and defines nothing of its own: 33 files deleted, `lib.rs` left as the re-export. Every existing path still resolves — `tinycortex_api::types::MemoryEntry` now *is* `tinymemory_api::types::MemoryEntry` — so no dependant needs an edit to gain the single type set. Two things change on purpose, both checked against the engine before doing this: - The capability vocabulary grows from thirteen families to eighteen. The engine matches on `Capability` in zero places, so nothing here is affected. - `CONTRACT_VERSION` becomes the contract's `(2, 2)` rather than this crate's `(1, 0)`. That value was already stale — hosts bind against the contract, and the engine never reads it. The enums the engine *does* match on are variant-identical (`MemoryCategory` at 16 sites, `MemoryTaint` at 9), so no match arm changes. The dependency is by git, which requires this crate not to publish — hence `publish = false` here and on the engine. That records the state the repository has actually been in rather than changing it: since `api/` was split out, `cargo package -p tinycortex` has failed with `no matching package named 'tinycortex-api' found`, because that crate was never published. The last release predates the split. A host that vendors both patches the git entry to its own checkout, so a build never resolves two copies. Validation, all from a clean clone at 5fdeac9: - `cargo check --workspace --all-targets` — clean - `cargo test --workspace` — 1259 passed, 0 failed - `cargo fmt --all -- --check` — clean - `cargo clippy --all-targets -- -D warnings` (the CI gate) — clean, 0 before and after - `cargo clippy --workspace --all-targets --all-features -- -D warnings` — 6 errors before and 6 after, all pre-existing in `persona/` and `sync/composio/` and untouched by this change Refs tinyhumansai/tinymemory#18 (§A1) --- Cargo.lock | 18 ++ Cargo.toml | 8 + api/Cargo.toml | 11 + api/src/capabilities.rs | 397 ---------------------------- api/src/capabilities_tests.rs | 320 ----------------------- api/src/chunks.rs | 416 ----------------------------- api/src/chunks_tests.rs | 208 --------------- api/src/error.rs | 104 -------- api/src/error_tests.rs | 59 ----- api/src/goals.rs | 131 ---------- api/src/goals_tests.rs | 22 -- api/src/health.rs | 119 --------- api/src/health_tests.rs | 90 ------- api/src/lib.rs | 45 +++- api/src/null.rs | 445 -------------------------------- api/src/null_tests.rs | 242 ----------------- api/src/provider/audit.rs | 132 ---------- api/src/provider/audit_tests.rs | 195 -------------- api/src/provider/content.rs | 167 ------------ api/src/provider/driver.rs | 197 -------------- api/src/provider/knowledge.rs | 176 ------------- api/src/provider/mandatory.rs | 184 ------------- api/src/provider/mod.rs | 73 ------ api/src/provider/records.rs | 167 ------------ api/src/provider/types.rs | 393 ---------------------------- api/src/provider/types_tests.rs | 133 ---------- api/src/recall.rs | 157 ----------- api/src/recall_tests.rs | 158 ------------ api/src/tool_memory.rs | 162 ------------ api/src/tool_memory_tests.rs | 128 --------- api/src/traits.rs | 155 ----------- api/src/tree.rs | 205 --------------- api/src/tree_tests.rs | 86 ------ api/src/types.rs | 435 ------------------------------- api/src/types_tests.rs | 234 ----------------- api/src/version.rs | 91 ------- api/src/version_tests.rs | 83 ------ 37 files changed, 68 insertions(+), 6278 deletions(-) delete mode 100644 api/src/capabilities.rs delete mode 100644 api/src/capabilities_tests.rs delete mode 100644 api/src/chunks.rs delete mode 100644 api/src/chunks_tests.rs delete mode 100644 api/src/error.rs delete mode 100644 api/src/error_tests.rs delete mode 100644 api/src/goals.rs delete mode 100644 api/src/goals_tests.rs delete mode 100644 api/src/health.rs delete mode 100644 api/src/health_tests.rs delete mode 100644 api/src/null.rs delete mode 100644 api/src/null_tests.rs delete mode 100644 api/src/provider/audit.rs delete mode 100644 api/src/provider/audit_tests.rs delete mode 100644 api/src/provider/content.rs delete mode 100644 api/src/provider/driver.rs delete mode 100644 api/src/provider/knowledge.rs delete mode 100644 api/src/provider/mandatory.rs delete mode 100644 api/src/provider/mod.rs delete mode 100644 api/src/provider/records.rs delete mode 100644 api/src/provider/types.rs delete mode 100644 api/src/provider/types_tests.rs delete mode 100644 api/src/recall.rs delete mode 100644 api/src/recall_tests.rs delete mode 100644 api/src/tool_memory.rs delete mode 100644 api/src/tool_memory_tests.rs delete mode 100644 api/src/traits.rs delete mode 100644 api/src/tree.rs delete mode 100644 api/src/tree_tests.rs delete mode 100644 api/src/types.rs delete mode 100644 api/src/types_tests.rs delete mode 100644 api/src/version.rs delete mode 100644 api/src/version_tests.rs diff --git a/Cargo.lock b/Cargo.lock index e2740cda..b77e5cef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1760,6 +1760,24 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.19", + "tinymemory-api", + "uuid", +] + +[[package]] +name = "tinymemory-api" +version = "0.1.1" +source = "git+https://github.com/tinyhumansai/tinymemory#4549cda222de3891b95e2fa58e2565bb2c194328" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "log", + "schemars", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.19", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 3a737ce9..9d5ad0ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,14 @@ exclude = ["vendor"] [package] name = "tinycortex" +# Not published. The engine's contract crate now depends on `tinymemory-api` by +# git, and cargo refuses a git dependency in a published crate. Publishing was +# already broken rather than merely unused: since `api/` was split out, +# `cargo package` has failed with `no matching package named 'tinycortex-api' +# found`, because that crate was never published either. This records the state +# the repository has actually been in, and buys the git dependency that lets the +# duplicate contract go away (tinymemory#18 §A1). +publish = false version = "0.1.1" edition = "2021" license = "MIT" diff --git a/api/Cargo.toml b/api/Cargo.toml index cf4a1e80..593bf3bb 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,5 +1,7 @@ [package] name = "tinycortex-api" +# Not published, for the same reason as the engine above. +publish = false version = "0.1.1" edition = "2021" license = "MIT" @@ -33,6 +35,15 @@ description = "Stable public contracts for the TinyCortex memory system" # scope and prints the whole-workspace inverse tree, so it exits 0 and looks # clean even when this crate is the one pulling the dependency in. [dependencies] +# The contract itself. This crate no longer defines the memory value types; it +# re-exports them, so `tinycortex_api::types::MemoryEntry` and +# `tinymemory_api::types::MemoryEntry` are one type rather than two that a +# conversion layer has to keep in step (tinymemory#18 §A1). +# +# By git rather than by version because neither crate is published. A host that +# vendors both patches this entry to its own checkout — see the `[patch]` table +# in the workspace root — so a build never pulls two copies. +tinymemory-api = { git = "https://github.com/tinyhumansai/tinymemory" } anyhow = "1" async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } diff --git a/api/src/capabilities.rs b/api/src/capabilities.rs deleted file mode 100644 index 7c1e7d30..00000000 --- a/api/src/capabilities.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! Capability families a memory driver may advertise, and the set type used to -//! negotiate them. -//! -//! ## Why capabilities exist -//! -//! A memory driver is not required to implement the whole surface. The kernel -//! asks a driver which families it supports **once**, at bind time, caches the -//! answer, and then unregisters the RPC methods and omits the agent tools that -//! belong to an unadvertised family. Absence beats a registered handler that -//! returns "not implemented": a present-but-failing method teaches a model that -//! the capability exists and makes it retry. -//! -//! Calling an unadvertised capability is therefore a *kernel* bug, not a driver -//! error. [`crate::error::MemoryError::Unsupported`] exists for the one case the -//! kernel cannot pre-empt: an out-of-process driver that answers `501` for a -//! family its handshake claimed. -//! -//! ## Mandatory families -//! -//! [`Capability::Core`], [`Capability::Recall`], and [`Capability::Portability`] -//! are mandatory. Without core and recall a driver is not a memory backend at -//! all; without portability a user cannot leave it, which makes the binding a -//! one-way door. [`Capabilities::validate`] is the single place that rule is -//! encoded — call it at bind time and refuse the bind on `Err`. -//! -//! ## Wire stability -//! -//! The set crosses the process boundary in the driver handshake -//! (`POST /v1/handshake` → `{ contract_version, driver_id, capabilities[] }`), -//! so the serialized form is a JSON **array of stable snake_case strings**, not -//! discriminant integers — inserting a variant in the middle of the enum must -//! not silently re-map an already-deployed driver's advertised set. -//! [`Capability::as_str`] is the authority for those strings and is pinned -//! against the serde derive by a test. -//! -//! ## Deliberately not `#[non_exhaustive]` -//! -//! Adding a family is a [`crate::CONTRACT_VERSION`] **minor** bump and should -//! break every exhaustive `match` in every host that filters registration by -//! family — that compile error is the mechanism which guarantees the new family -//! is actually wired somewhere. Marking this enum `#[non_exhaustive]` would -//! convert that compile-time guarantee into a silent fall-through at the crate -//! boundary (the failure mode recorded for `DataSource` during the M0 -//! carve-out). If a future family must be added without breaking downstream -//! matches, bump the **major** version instead. - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::error::MemoryError; - -/// One capability family a memory driver may advertise. -/// -/// The variants are exactly the thirteen families of the memory contract. Each -/// maps to a trait family in the contract, a group of RPC methods, and a group -/// of agent tools; a driver that does not advertise a family simply has that -/// surface absent. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Capability { - /// Store / get / forget / list / namespaces. **Mandatory.** - Core, - /// Ranked retrieval for a query. **Mandatory.** - Recall, - /// Document and chat ingestion — the driver owns chunking and embedding. - Ingest, - /// The namespace-document tier: put / get / query documents. - Documents, - /// Summary-tree query, drill-down, seal, and cascade. - Tree, - /// Entity index, entity edges, and hotness. - Entities, - /// Key/value graph read and write. - Graph, - /// Snapshot capture and change computation. - Diff, - /// Goal extraction and goal records. - Goals, - /// Per-tool learned memory. - ToolMemory, - /// Accepting synced source items; the host still owns credentials and - /// scheduling. - Sources, - /// Re-embed, compact, consolidate ("dream"), and doctor. - Maintenance, - /// Export and import of the whole store as a stream. **Mandatory.** - Portability, -} - -impl Capability { - /// Every family, in declaration order. - /// - /// Declaration order is also bit order in [`Capabilities`] and iteration - /// order in its serialized form, so this slice is the single ordering - /// authority for the whole module. - pub const ALL: [Capability; 13] = [ - Capability::Core, - Capability::Recall, - Capability::Ingest, - Capability::Documents, - Capability::Tree, - Capability::Entities, - Capability::Graph, - Capability::Diff, - Capability::Goals, - Capability::ToolMemory, - Capability::Sources, - Capability::Maintenance, - Capability::Portability, - ]; - - /// The families a driver must advertise to be bindable at all. - /// - /// See the module docs for why these three and not others. - pub const MANDATORY: [Capability; 3] = [ - Capability::Core, - Capability::Recall, - Capability::Portability, - ]; - - /// Every family, in declaration order. Slice form of [`Self::ALL`], for - /// callers that want to iterate without naming the array length. - pub fn all() -> &'static [Capability] { - &Self::ALL - } - - /// Stable snake_case identifier used on the wire, in config, and in logs. - /// - /// This is the authority for the serialized form; the serde derive is - /// pinned against it by `capability_as_str_matches_serde_representation`. - /// Changing a string here is a breaking change for every already-deployed - /// driver and requires a [`crate::CONTRACT_VERSION`] major bump. - pub fn as_str(self) -> &'static str { - match self { - Self::Core => "core", - Self::Recall => "recall", - Self::Ingest => "ingest", - Self::Documents => "documents", - Self::Tree => "tree", - Self::Entities => "entities", - Self::Graph => "graph", - Self::Diff => "diff", - Self::Goals => "goals", - Self::ToolMemory => "tool_memory", - Self::Sources => "sources", - Self::Maintenance => "maintenance", - Self::Portability => "portability", - } - } - - /// Parse back from the on-wire form. - /// - /// # Errors - /// - /// Returns the unrecognised input in an error message. An unknown string is - /// expected in practice: a driver speaking a newer minor contract version - /// may advertise a family this build has never heard of. Callers - /// negotiating a handshake should **skip** unknown families rather than - /// fail the bind — an unknown family is one this kernel would never call. - pub fn parse(raw: &str) -> Result { - Self::ALL - .iter() - .copied() - .find(|cap| cap.as_str() == raw) - .ok_or_else(|| format!("unknown memory capability: {raw}")) - } - - /// Whether this family is mandatory for every driver. - pub fn is_mandatory(self) -> bool { - Self::MANDATORY.contains(&self) - } - - /// Position of this family in [`Self::ALL`]; also its bit index in - /// [`Capabilities`]. - fn index(self) -> u16 { - match self { - Self::Core => 0, - Self::Recall => 1, - Self::Ingest => 2, - Self::Documents => 3, - Self::Tree => 4, - Self::Entities => 5, - Self::Graph => 6, - Self::Diff => 7, - Self::Goals => 8, - Self::ToolMemory => 9, - Self::Sources => 10, - Self::Maintenance => 11, - Self::Portability => 12, - } - } - - /// Single-bit mask for this family within a [`Capabilities`] set. - fn bit(self) -> u64 { - 1u64 << self.index() - } -} - -impl std::fmt::Display for Capability { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -impl std::str::FromStr for Capability { - type Err = String; - - fn from_str(raw: &str) -> Result { - Self::parse(raw) - } -} - -/// A driver's advertised capability set. -/// -/// Internally a bitset, so `contains` is a single mask test on the hot path and -/// the type is `Copy`. Externally it serializes as a JSON array of -/// [`Capability::as_str`] strings in [`Capability::ALL`] order — duplicates in -/// the input collapse, and ordering in the input is not preserved, because a -/// set has neither. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub struct Capabilities { - bits: u64, -} - -impl Capabilities { - /// The empty default capability set. The `null` driver advertises - /// [`Self::mandatory`] via its [`MemoryProvider::capabilities`](crate::provider::MemoryProvider::capabilities) - /// implementation, not this. - pub const fn empty() -> Self { - Self { bits: 0 } - } - - /// Every family. Advertised by the embedded `tinycortex` driver. - pub fn all() -> Self { - Capability::ALL.into_iter().collect() - } - - /// Exactly the mandatory families — the minimum bindable set. - pub fn mandatory() -> Self { - Capability::MANDATORY.into_iter().collect() - } - - /// Whether `capability` is advertised. - pub fn contains(&self, capability: Capability) -> bool { - self.bits & capability.bit() != 0 - } - - /// Whether every family in `other` is advertised here. - pub fn contains_all(&self, other: Capabilities) -> bool { - self.bits & other.bits == other.bits - } - - /// Adds `capability` in place. Idempotent. - pub fn insert(&mut self, capability: Capability) { - self.bits |= capability.bit(); - } - - /// Removes `capability` in place. Idempotent. - pub fn remove(&mut self, capability: Capability) { - self.bits &= !capability.bit(); - } - - /// Builder form of [`Self::insert`]. - pub fn with(mut self, capability: Capability) -> Self { - self.insert(capability); - self - } - - /// Builder form of [`Self::remove`]. - pub fn without(mut self, capability: Capability) -> Self { - self.remove(capability); - self - } - - /// Advertised families in [`Capability::ALL`] order. - pub fn iter(&self) -> impl Iterator + '_ { - Capability::ALL - .into_iter() - .filter(move |cap| self.contains(*cap)) - } - - /// Number of advertised families. - pub fn len(&self) -> usize { - self.bits.count_ones() as usize - } - - /// Whether no family is advertised. - pub fn is_empty(&self) -> bool { - self.bits == 0 - } - - /// Mandatory families this set is missing, in [`Capability::ALL`] order. - /// Empty when the set is bindable. - pub fn missing_mandatory(&self) -> Vec { - Capability::MANDATORY - .into_iter() - .filter(|cap| !self.contains(*cap)) - .collect() - } - - /// Rejects a set that is missing any mandatory family. - /// - /// Call this at bind time; on `Err` refuse the bind and fall back to the - /// embedded default rather than binding a driver a user could not leave. - /// - /// # Errors - /// - /// Returns [`MissingMandatoryCapabilities`] listing **every** missing - /// mandatory family, not just the first, so the operator sees the whole gap - /// in one message. - pub fn validate(&self) -> Result<(), MissingMandatoryCapabilities> { - let missing = self.missing_mandatory(); - if missing.is_empty() { - Ok(()) - } else { - Err(MissingMandatoryCapabilities { missing }) - } - } -} - -impl FromIterator for Capabilities { - fn from_iter>(iter: I) -> Self { - let mut set = Self::empty(); - for capability in iter { - set.insert(capability); - } - set - } -} - -impl Extend for Capabilities { - fn extend>(&mut self, iter: I) { - for capability in iter { - self.insert(capability); - } - } -} - -impl Serialize for Capabilities { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.collect_seq(self.iter()) - } -} - -impl<'de> Deserialize<'de> for Capabilities { - /// Skips any family string this build does not recognise, rather than - /// failing the whole deserialize. - /// - /// A remote driver speaking a newer minor contract version may advertise a - /// family this build has never heard of — see [`Capability::parse`] and the - /// module-level "wire stability" docs. Rejecting the whole handshake on one - /// unknown string would refuse an otherwise-compatible driver; the correct - /// behaviour is to drop the family this kernel could never call anyway. - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let raw = Vec::::deserialize(deserializer)?; - let families = raw - .into_iter() - .filter_map(|family| Capability::parse(&family).ok()); - Ok(families.collect()) - } -} - -/// A driver advertised a capability set missing at least one mandatory family. -/// -/// Carries the missing families rather than a formatted string so the caller -/// can report them structurally (status RPC, bind-failure event) as well as in -/// a log line. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -#[error( - "memory driver advertises an incomplete capability set; missing mandatory families: {}", - .missing.iter().map(|c| c.as_str()).collect::>().join(", ") -)] -pub struct MissingMandatoryCapabilities { - /// Mandatory families absent from the advertised set, in - /// [`Capability::ALL`] order. Never empty. - pub missing: Vec, -} - -impl From for MemoryError { - /// An incomplete advertised set is a caller/config error, not an - /// unsupported call: the driver said something invalid about itself, which - /// is why this maps to [`MemoryError::Invalid`] and not - /// [`MemoryError::Unsupported`]. - fn from(value: MissingMandatoryCapabilities) -> Self { - MemoryError::Invalid(value.to_string()) - } -} - -#[cfg(test)] -#[path = "capabilities_tests.rs"] -mod tests; diff --git a/api/src/capabilities_tests.rs b/api/src/capabilities_tests.rs deleted file mode 100644 index 1e5e550e..00000000 --- a/api/src/capabilities_tests.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! Unit tests for the capability vocabulary in [`super`]. -//! -//! Three properties are load-bearing and each has its own test: -//! -//! 1. the enum has exactly the thirteen contract families and no more; -//! 2. the serialized form is stable snake_case **strings**, never discriminant -//! integers — a driver deployed against an older build must keep advertising -//! the same set after a variant is inserted mid-enum; -//! 3. [`super::Capabilities::validate`] rejects a set missing **any** of the -//! three mandatory families, checked one family at a time. - -use super::*; -use serde_json::json; - -#[test] -fn capability_has_exactly_the_thirteen_contract_families() { - assert_eq!(Capability::ALL.len(), 13); - assert_eq!(Capability::all().len(), 13); - - let names: Vec<&str> = Capability::ALL.iter().map(|c| c.as_str()).collect(); - assert_eq!( - names, - vec![ - "core", - "recall", - "ingest", - "documents", - "tree", - "entities", - "graph", - "diff", - "goals", - "tool_memory", - "sources", - "maintenance", - "portability", - ] - ); -} - -#[test] -fn capability_all_has_no_duplicates() { - let mut seen = std::collections::BTreeSet::new(); - for capability in Capability::ALL { - assert!( - seen.insert(capability.as_str()), - "duplicate capability in ALL: {capability}" - ); - } -} - -#[test] -fn capability_as_str_matches_serde_representation() { - // The wire form is the stable contract; `as_str` is the authority and the - // derive must agree with it for every variant. - for capability in Capability::ALL { - assert_eq!( - serde_json::to_value(capability).unwrap(), - json!(capability.as_str()), - "serde form drifted from as_str for {capability}" - ); - } -} - -#[test] -fn capability_serializes_as_a_string_not_an_integer() { - // Guards the specific regression the string form exists to prevent: - // inserting a variant must not re-map an already-deployed driver's set. - for capability in Capability::ALL { - assert!( - serde_json::to_value(capability).unwrap().is_string(), - "{capability} did not serialize as a string" - ); - } -} - -#[test] -fn capability_parse_round_trips_every_variant() { - for capability in Capability::ALL { - assert_eq!(Capability::parse(capability.as_str()), Ok(capability)); - assert_eq!( - capability.as_str().parse::(), - Ok(capability), - "FromStr disagreed with parse for {capability}" - ); - let decoded: Capability = - serde_json::from_value(json!(capability.as_str())).expect("known family decodes"); - assert_eq!(decoded, capability); - } -} - -#[test] -fn capability_parse_rejects_unknown_family() { - let err = Capability::parse("quantum_recall").expect_err("unknown family must not parse"); - assert!(err.contains("quantum_recall"), "unhelpful error: {err}"); -} - -#[test] -fn mandatory_families_are_core_recall_and_portability() { - assert_eq!( - Capability::MANDATORY, - [ - Capability::Core, - Capability::Recall, - Capability::Portability - ] - ); - for capability in Capability::ALL { - assert_eq!( - capability.is_mandatory(), - matches!( - capability, - Capability::Core | Capability::Recall | Capability::Portability - ), - "wrong mandatory classification for {capability}" - ); - } -} - -#[test] -fn capabilities_all_contains_every_family() { - let all = Capabilities::all(); - assert_eq!(all.len(), Capability::ALL.len()); - for capability in Capability::ALL { - assert!(all.contains(capability), "all() is missing {capability}"); - } - assert!(!all.is_empty()); -} - -#[test] -fn capabilities_empty_contains_nothing() { - let none = Capabilities::empty(); - assert!(none.is_empty()); - assert_eq!(none.len(), 0); - for capability in Capability::ALL { - assert!(!none.contains(capability)); - } - // The default capability set is empty (the null driver itself advertises - // `Capabilities::mandatory()`, not the default). - assert_eq!(Capabilities::default(), none); -} - -#[test] -fn capabilities_bit_width_has_room_well_beyond_the_current_thirteen_families() { - // A `u16` bitset (the original representation) has exactly 16 bit - // positions, leaving room for only 3 more families before a family's - // `1 << index` bit-shift overflows. Pin the wider `u64` representation so - // a future family addition doesn't have to rediscover that ceiling. - assert!(std::mem::size_of::() * 8 >= 64); -} - -#[test] -fn capabilities_insert_and_remove_are_idempotent() { - let mut set = Capabilities::empty(); - set.insert(Capability::Tree); - set.insert(Capability::Tree); - assert_eq!(set.len(), 1); - assert!(set.contains(Capability::Tree)); - assert!(!set.contains(Capability::Graph)); - - set.remove(Capability::Tree); - set.remove(Capability::Tree); - assert!(set.is_empty()); -} - -#[test] -fn capabilities_builder_forms_mirror_insert_and_remove() { - let set = Capabilities::empty() - .with(Capability::Core) - .with(Capability::Recall) - .without(Capability::Recall); - assert!(set.contains(Capability::Core)); - assert!(!set.contains(Capability::Recall)); -} - -#[test] -fn capabilities_contains_all_checks_subsets() { - let full = Capabilities::all(); - let mandatory = Capabilities::mandatory(); - - assert!(full.contains_all(mandatory)); - assert!(!mandatory.contains_all(full)); - assert!(mandatory.contains_all(mandatory)); - assert!(full.contains_all(Capabilities::empty())); -} - -#[test] -fn capabilities_iterates_in_declaration_order() { - let set: Capabilities = [ - Capability::Portability, - Capability::Core, - Capability::Tree, - Capability::Recall, - ] - .into_iter() - .collect(); - - assert_eq!( - set.iter().collect::>(), - vec![ - Capability::Core, - Capability::Recall, - Capability::Tree, - Capability::Portability - ] - ); -} - -#[test] -fn capabilities_serde_round_trips_and_uses_a_string_array() { - let set = Capabilities::mandatory().with(Capability::ToolMemory); - let encoded = serde_json::to_value(set).unwrap(); - - // Declaration order, snake_case strings — the `capabilities[]` handshake - // field. - assert_eq!( - encoded, - json!(["core", "recall", "tool_memory", "portability"]) - ); - - let decoded: Capabilities = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, set); -} - -#[test] -fn capabilities_full_set_serde_round_trips() { - let all = Capabilities::all(); - let encoded = serde_json::to_string(&all).unwrap(); - let decoded: Capabilities = serde_json::from_str(&encoded).unwrap(); - assert_eq!(decoded, all); -} - -#[test] -fn capabilities_deserialization_collapses_duplicates_and_ignores_order() { - let decoded: Capabilities = - serde_json::from_value(json!(["portability", "core", "core", "recall"])).unwrap(); - assert_eq!(decoded, Capabilities::mandatory()); - assert_eq!(decoded.len(), 3); -} - -#[test] -fn capabilities_deserialization_skips_an_unknown_family() { - // A remote driver speaking a newer minor contract version may advertise a - // family this build has never heard of (see the module docs' "wire - // stability" section and `Capability::parse`). The handshake must still - // decode — with the unknown family dropped — rather than failing the bind - // outright. - let decoded: Capabilities = - serde_json::from_value(json!(["core", "warp_drive", "recall"])).unwrap(); - assert_eq!( - decoded, - Capabilities::empty() - .with(Capability::Core) - .with(Capability::Recall) - ); -} - -#[test] -fn validate_accepts_the_minimum_bindable_set() { - assert_eq!(Capabilities::mandatory().validate(), Ok(())); - assert_eq!(Capabilities::all().validate(), Ok(())); -} - -#[test] -fn validate_rejects_a_set_missing_core() { - let set = Capabilities::all().without(Capability::Core); - let err = set.validate().expect_err("missing core must be rejected"); - assert_eq!(err.missing, vec![Capability::Core]); - assert!(err.to_string().contains("core"), "{err}"); -} - -#[test] -fn validate_rejects_a_set_missing_recall() { - let set = Capabilities::all().without(Capability::Recall); - let err = set.validate().expect_err("missing recall must be rejected"); - assert_eq!(err.missing, vec![Capability::Recall]); - assert!(err.to_string().contains("recall"), "{err}"); -} - -#[test] -fn validate_rejects_a_set_missing_portability() { - // Portability is mandatory because without it a bind is a one-way door. - let set = Capabilities::all().without(Capability::Portability); - let err = set - .validate() - .expect_err("missing portability must be rejected"); - assert_eq!(err.missing, vec![Capability::Portability]); - assert!(err.to_string().contains("portability"), "{err}"); -} - -#[test] -fn validate_reports_every_missing_mandatory_family_at_once() { - let err = Capabilities::empty() - .validate() - .expect_err("the null set must be rejected"); - assert_eq!( - err.missing, - vec![ - Capability::Core, - Capability::Recall, - Capability::Portability - ] - ); -} - -#[test] -fn missing_mandatory_converts_to_an_invalid_memory_error() { - let err = Capabilities::empty().validate().unwrap_err(); - let message = err.to_string(); - let converted: MemoryError = err.into(); - // An incomplete advertised set is a bad claim about the driver, not an - // unsupported call. - assert!(matches!(converted, MemoryError::Invalid(ref m) if *m == message)); -} - -#[test] -fn missing_mandatory_is_empty_for_a_valid_set() { - assert!(Capabilities::mandatory().missing_mandatory().is_empty()); - assert!(Capabilities::all().missing_mandatory().is_empty()); -} diff --git a/api/src/chunks.rs b/api/src/chunks.rs deleted file mode 100644 index 7770adb4..00000000 --- a/api/src/chunks.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Core types for the memory chunk layer. -//! -//! This module defines the canonical [`Chunk`] representation produced by the -//! ingestion pipeline along with its provenance [`Metadata`] and back-pointer -//! [`SourceRef`]. -//! -//! All chunk IDs are deterministic: `sha256(source_kind | "\0" | source_id | -//! "\0" | seq | "\0" | content)` truncated to 32 hex chars so re-ingest of the -//! same source material yields stable IDs and idempotent upserts. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -/// Which kind of upstream source produced a chunk. -/// -/// Used both as a metadata discriminator and as the routing key for the -/// canonicaliser dispatch in the ingest pipeline. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SourceKind { - /// Chat transcript scoped by channel or group (Slack, Discord, Telegram, WhatsApp…). - Chat, - /// Email thread (Gmail and generic IMAP). - Email, - /// Standalone document (Notion page, Drive doc, meeting note, uploaded file…). - Document, -} - -impl SourceKind { - /// Stable string representation for DB storage and RPC surfaces. - pub fn as_str(self) -> &'static str { - match self { - SourceKind::Chat => "chat", - SourceKind::Email => "email", - SourceKind::Document => "document", - } - } - - /// Parse back from the on-wire / on-disk string form. - pub fn parse(s: &str) -> Result { - match s { - "chat" => Ok(SourceKind::Chat), - "email" => Ok(SourceKind::Email), - "document" => Ok(SourceKind::Document), - other => Err(format!("unknown source kind: {other}")), - } - } -} - -/// Concrete upstream provider the content came from. -/// -/// Each variant maps to exactly one [`SourceKind`] via [`Self::kind`]. Wire -/// form is snake_case (see [`Self::as_str`] / [`Self::parse`]) so it is stable -/// across DB rows, JSON-RPC payloads, and logs. -/// -/// Marked `#[non_exhaustive]` so new providers can be added in later phases -/// without breaking downstream pattern matches. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum DataSource { - // ── Chat transcripts (grouped by channel/group) ──────────────────── - /// Discord channel/server messages. Feeds [`SourceKind::Chat`]. - Discord, - /// Telegram chat/group messages. Feeds [`SourceKind::Chat`]. - Telegram, - /// WhatsApp chat/group messages. Feeds [`SourceKind::Chat`]. - Whatsapp, - - // ── Agent conversations (stored as durable memory) ──────────────── - /// Agent conversation transcripts persisted as durable memory. Feeds [`SourceKind::Chat`]. - Conversation, - - // ── Email threads (grouped by thread) ────────────────────────────── - /// Gmail thread. Feeds [`SourceKind::Email`]. - Gmail, - /// Catch-all for non-Gmail providers (Outlook, FastMail, generic IMAP, …). - OtherEmail, - - // ── Documents (no grouping) ──────────────────────────────────────── - /// Notion page. Feeds [`SourceKind::Document`]. - Notion, - /// Meeting notes document. Feeds [`SourceKind::Document`]. - MeetingNotes, - /// Google Drive document. Feeds [`SourceKind::Document`]. - DriveDocs, -} - -impl DataSource { - /// Which [`SourceKind`] this provider feeds into. - pub fn kind(self) -> SourceKind { - match self { - Self::Discord | Self::Telegram | Self::Whatsapp | Self::Conversation => { - SourceKind::Chat - } - Self::Gmail | Self::OtherEmail => SourceKind::Email, - Self::Notion | Self::MeetingNotes | Self::DriveDocs => SourceKind::Document, - } - } - - /// Stable snake_case identifier for DB storage, RPC payloads, and logs. - pub fn as_str(self) -> &'static str { - match self { - Self::Discord => "discord", - Self::Telegram => "telegram", - Self::Whatsapp => "whatsapp", - Self::Conversation => "conversation", - Self::Gmail => "gmail", - Self::OtherEmail => "other_email", - Self::Notion => "notion", - Self::MeetingNotes => "meeting_notes", - Self::DriveDocs => "drive_docs", - } - } - - /// Parse back from the on-wire / on-disk string form. - pub fn parse(s: &str) -> Result { - match s { - "discord" => Ok(Self::Discord), - "telegram" => Ok(Self::Telegram), - "whatsapp" => Ok(Self::Whatsapp), - "conversation" => Ok(Self::Conversation), - "gmail" => Ok(Self::Gmail), - "other_email" => Ok(Self::OtherEmail), - "notion" => Ok(Self::Notion), - "meeting_notes" => Ok(Self::MeetingNotes), - "drive_docs" => Ok(Self::DriveDocs), - other => Err(format!("unknown data source: {other}")), - } - } - - /// Every known variant, in declaration order. Useful for tests, CLI - /// completion, and enumerating supported providers in diagnostic output. - pub fn all() -> &'static [DataSource] { - &[ - Self::Discord, - Self::Telegram, - Self::Whatsapp, - Self::Conversation, - Self::Gmail, - Self::OtherEmail, - Self::Notion, - Self::MeetingNotes, - Self::DriveDocs, - ] - } -} - -/// A concrete pointer back to where a chunk originated — used for citation, -/// drill-down, and deduplication at re-ingest time. -/// -/// Consumers should treat this as an opaque, source-specific reference. The -/// shape depends on [`SourceKind`]: -/// - **Chat**: `{platform}://{channel}/{message_id}` or `{permalink}` -/// - **Email**: message-id header (``) or provider URL -/// - **Document**: file path, Notion page URL, Drive file id -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct SourceRef { - /// Opaque provider-specific identifier for the exact source record. - pub value: String, -} - -impl SourceRef { - /// Wrap an opaque provider-specific identifier as a [`SourceRef`]. - pub fn new(value: impl Into) -> Self { - Self { - value: value.into(), - } - } -} - -/// Provenance metadata captured per chunk at ingest time. -/// -/// Captures at minimum: source type, source identifier, owner/account, -/// timestamps, and tags/labels when available. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct Metadata { - /// Which upstream source kind produced this chunk. - pub source_kind: SourceKind, - /// Stable logical id for the ingestion group (channel id, thread id, doc id). - /// - /// Chat: channel/group id. Email: thread id. Document: doc id. - pub source_id: String, - /// Account or user the content belongs to. Empty string for anonymous / system sources. - pub owner: String, - /// Point-in-time timestamp for ordering within a source. - /// - /// For chats = message time; for emails = message sent time; - /// for documents = last-modified or ingest time. - #[serde(with = "chrono::serde::ts_milliseconds")] - pub timestamp: DateTime, - /// Covering time range the chunk spans. For a single leaf it usually equals - /// `(timestamp, timestamp)`; for later summary nodes it widens to cover all - /// children. - #[serde(with = "time_range_serde")] - pub time_range: (DateTime, DateTime), - /// Arbitrary labels / tags carried through from the source (e.g. Gmail labels, - /// Slack reactions, Notion tags). Ingest does not interpret these. - #[serde(default)] - pub tags: Vec, - /// Opaque pointer back to the raw source record for drill-down / citation. - pub source_ref: Option, - /// When set, overrides `source_id` for the chunk file path so multiple - /// items share one directory. `source_id` remains the dedup key. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path_scope: Option, -} - -impl Metadata { - /// Convenience constructor used by canonicalisers: point timestamp, - /// `time_range = (timestamp, timestamp)`. - pub fn point_in_time( - source_kind: SourceKind, - source_id: impl Into, - owner: impl Into, - timestamp: DateTime, - ) -> Self { - Self { - source_kind, - source_id: source_id.into(), - owner: owner.into(), - timestamp, - time_range: (timestamp, timestamp), - tags: Vec::new(), - source_ref: None, - path_scope: None, - } - } -} - -/// A single ingested chunk — the atomic persistence unit. -/// -/// In the design this is the leaf of a source tree. Later phases build summary -/// nodes on top of these leaves; here they live standalone. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct Chunk { - /// Deterministic id derived from (source_kind, source_id, seq_in_source, content). - pub id: String, - /// Canonical Markdown content. - pub content: String, - /// Provenance metadata. - pub metadata: Metadata, - /// Token count (rough heuristic — 1 token ≈ 4 chars). - pub token_count: u32, - /// Sequence number of this chunk inside its logical source. Stable and - /// starts at 0 for the first chunk of a source. - pub seq_in_source: u32, - /// When this chunk was persisted to the local store. - #[serde(with = "chrono::serde::ts_milliseconds")] - pub created_at: DateTime, - /// True when this chunk is a sub-split of a single logical unit (e.g. a - /// chat message or email body that exceeded `max_tokens`). Each piece - /// carries this flag so downstream scorers can lower its weight relative to - /// whole-unit chunks. - #[serde(default)] - pub partial_message: bool, -} - -/// A chunk staged for the MD-content write path: a [`Chunk`] whose full body -/// lives on disk at `content_path` (with `content_sha256` for integrity), while -/// the SQLite `content` column carries only a ≤500-char preview. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StagedChunk { - /// The chunk being persisted. - pub chunk: Chunk, - /// Forward-slash relative path (under the content root) where the full body lives. - pub content_path: String, - /// Hex SHA-256 of the on-disk body, recorded for integrity checks. - pub content_sha256: String, -} - -/// Deterministic chunk id. -/// -/// `sha256(source_kind | "\0" | source_id | "\0" | seq | "\0" | content)` -/// hex-encoded, first 32 chars (128 bits of collision resistance). -/// -/// Content is included so multiple ingest calls that share a `source_id` don't -/// collide on `seq=0,1,2,…`. Re-ingesting the same canonical content under the -/// same `(source_id, seq)` still produces the same id, so upserts stay -/// idempotent. -pub fn chunk_id( - source_kind: SourceKind, - source_id: &str, - seq_in_source: u32, - content: &str, -) -> String { - let mut hasher = Sha256::new(); - hasher.update(source_kind.as_str().as_bytes()); - hasher.update([0u8]); - hasher.update(source_id.as_bytes()); - hasher.update([0u8]); - hasher.update(seq_in_source.to_be_bytes()); - hasher.update([0u8]); - hasher.update(content.as_bytes()); - let digest = hasher.finalize(); - let hex = digest.iter().fold(String::with_capacity(64), |mut acc, b| { - use std::fmt::Write; - let _ = write!(acc, "{b:02x}"); - acc - }); - hex[..32].to_string() -} - -/// Approximate token count (GPT-family heuristic: 1 token ≈ 4 chars). -pub fn approx_token_count(text: &str) -> u32 { - // saturating_add guards against absurdly long inputs - let chars = text.chars().count() as u32; - chars.saturating_add(3) / 4 -} - -/// Per-character weight in **quarter-token** units for -/// [`conservative_token_estimate`]. Deliberately pessimistic so the chunker and -/// the embed backstop never under-split: real SentencePiece/WordPiece output for -/// hash-, code-, and markdown-dense text approaches ~1 token/char — far above -/// the `chars/4` GPT heuristic in [`approx_token_count`]. -fn char_token_quarters(ch: char) -> u32 { - if ch.is_ascii_alphanumeric() { - 2 // 0.50 token/char — alphanumeric runs pack ~2-4 chars per token - } else if ch.is_whitespace() { - 1 // 0.25 token/char — whitespace usually merges into adjacent pieces - } else { - 4 // 1.00 token/char — ASCII punctuation/symbols AND all non-ASCII - // (Hebrew/CJK/emoji), which tokenise ~1 piece per char or worse - } -} - -/// Conservative (over-estimating) token count, for embed-safety decisions only. -/// -/// [`approx_token_count`] (`chars/4`) under-counts dense markdown/hash/code by -/// ~5×. This weights characters by class so the result is an upper-ish bound on -/// real tokeniser output. It does **not** replace `approx_token_count`, which -/// still drives summariser/seal token budgeting. -pub fn conservative_token_estimate(text: &str) -> u32 { - let quarters: u64 = text - .chars() - .map(|c| u64::from(char_token_quarters(c))) - .sum(); - let tokens = quarters.div_ceil(4); // ceil(quarters / 4) - tokens.min(u64::from(u32::MAX)) as u32 -} - -/// Largest leading slice of `text` whose [`conservative_token_estimate`] is -/// ≤ `budget`, ending on a UTF-8 char boundary. Returns the whole string when -/// already within budget. Used as the embed-path backstop so an over-long body -/// can never be sent to the embedder above its input limit. -pub fn truncate_to_conservative_tokens(text: &str, budget: u32) -> &str { - if conservative_token_estimate(text) <= budget { - return text; - } - let cap = u64::from(budget).saturating_mul(4); // quarter-tokens - let mut acc: u64 = 0; - for (idx, ch) in text.char_indices() { - let q = u64::from(char_token_quarters(ch)); - if acc + q > cap { - return &text[..idx]; - } - acc += q; - } - text -} - -/// `serde(with = ...)` shim for `(DateTime, DateTime)`. -/// -/// Chrono has no built-in serde helper for a *pair* of timestamps, so this -/// mirrors `chrono::serde::ts_milliseconds` but for a 2-tuple: each endpoint -/// round-trips through millisecond-since-epoch integers under the field -/// names `start_ms` / `end_ms`. -mod time_range_serde { - use chrono::{DateTime, TimeZone, Utc}; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - - /// On-wire shape: millisecond-since-epoch pair. - #[derive(Serialize, Deserialize)] - struct Wire { - start_ms: i64, - end_ms: i64, - } - - /// Serialize a `(start, end)` UTC timestamp pair as `{start_ms, end_ms}`. - pub fn serialize( - value: &(DateTime, DateTime), - serializer: S, - ) -> Result { - Wire { - start_ms: value.0.timestamp_millis(), - end_ms: value.1.timestamp_millis(), - } - .serialize(serializer) - } - - /// Deserialize a `{start_ms, end_ms}` pair back into UTC timestamps. - /// - /// # Errors - /// Returns a `serde` custom error if either millisecond value does not - /// map to a valid `DateTime` (chrono's `timestamp_millis_opt` fails, - /// e.g. out-of-range values). - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result<(DateTime, DateTime), D::Error> { - let wire = Wire::deserialize(deserializer)?; - let start = Utc - .timestamp_millis_opt(wire.start_ms) - .single() - .ok_or_else(|| serde::de::Error::custom("invalid start_ms"))?; - let end = Utc - .timestamp_millis_opt(wire.end_ms) - .single() - .ok_or_else(|| serde::de::Error::custom("invalid end_ms"))?; - Ok((start, end)) - } -} - -#[cfg(test)] -#[path = "chunks_tests.rs"] -mod tests; diff --git a/api/src/chunks_tests.rs b/api/src/chunks_tests.rs deleted file mode 100644 index e7db31ff..00000000 --- a/api/src/chunks_tests.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Unit tests for the chunk model (`super`). - -use super::*; -use chrono::TimeZone; - -#[test] -fn chunk_id_is_deterministic() { - let a = chunk_id(SourceKind::Chat, "slack:#eng", 0, "hello"); - let b = chunk_id(SourceKind::Chat, "slack:#eng", 0, "hello"); - assert_eq!(a, b); - assert_eq!(a.len(), 32); -} - -#[test] -fn conservative_estimate_weights_by_char_class() { - assert_eq!(conservative_token_estimate("abcd"), 2); // 4 alnum × 2q / 4 - assert_eq!(conservative_token_estimate(" "), 1); // 4 ws × 1q / 4 - assert_eq!(conservative_token_estimate("....,,,,"), 8); // 8 punct × 4q / 4 - assert_eq!(conservative_token_estimate("שלום"), 4); // 4 non-ascii × 4q / 4 - assert_eq!(conservative_token_estimate(""), 0); -} - -#[test] -fn conservative_estimate_exceeds_approx_for_dense_content() { - let dense = "claude-memory:openhuman:MEMORY.md:67d6fe2727d431b16d41630babfdcf1cdf61bda7b9ba\n" - .repeat(40); - assert!( - conservative_token_estimate(&dense) > approx_token_count(&dense), - "conservative estimate must exceed chars/4 on dense content", - ); -} - -#[test] -fn truncate_respects_budget_and_char_boundaries() { - let text = "שלום עולם ".repeat(100); // Hebrew, ~1 token/char - let out = truncate_to_conservative_tokens(&text, 10); - assert!(conservative_token_estimate(out) <= 10); - assert!(text.starts_with(out)); // valid prefix on a char boundary - assert!(out.len() < text.len()); -} - -#[test] -fn truncate_is_noop_within_budget() { - let text = "short and sweet"; - assert_eq!(truncate_to_conservative_tokens(text, 1000), text); -} - -#[test] -fn chunk_id_varies_with_seq() { - let a = chunk_id(SourceKind::Chat, "slack:#eng", 0, "hello"); - let b = chunk_id(SourceKind::Chat, "slack:#eng", 1, "hello"); - assert_ne!(a, b); -} - -#[test] -fn chunk_id_varies_with_source_kind() { - let a = chunk_id(SourceKind::Chat, "foo", 0, "hello"); - let b = chunk_id(SourceKind::Email, "foo", 0, "hello"); - assert_ne!(a, b); -} - -#[test] -fn chunk_id_varies_with_source_id() { - let a = chunk_id(SourceKind::Chat, "x", 0, "hello"); - let b = chunk_id(SourceKind::Chat, "y", 0, "hello"); - assert_ne!(a, b); -} - -#[test] -fn chunk_id_varies_with_content() { - let a = chunk_id(SourceKind::Chat, "slack:c1", 0, "bucket A content"); - let b = chunk_id(SourceKind::Chat, "slack:c1", 0, "bucket B content"); - assert_ne!(a, b); -} - -#[test] -fn source_kind_round_trip() { - for kind in [SourceKind::Chat, SourceKind::Email, SourceKind::Document] { - assert_eq!(SourceKind::parse(kind.as_str()).unwrap(), kind); - } -} - -#[test] -fn data_source_round_trip() { - for ds in DataSource::all() { - assert_eq!(DataSource::parse(ds.as_str()).unwrap(), *ds); - } -} - -#[test] -fn data_source_has_all_variants() { - assert_eq!(DataSource::all().len(), 9); -} - -#[test] -fn data_source_kind_mapping() { - use DataSource::*; - for ds in [Discord, Telegram, Whatsapp, Conversation] { - assert_eq!(ds.kind(), SourceKind::Chat); - } - for ds in [Gmail, OtherEmail] { - assert_eq!(ds.kind(), SourceKind::Email); - } - for ds in [Notion, MeetingNotes, DriveDocs] { - assert_eq!(ds.kind(), SourceKind::Document); - } -} - -#[test] -fn data_source_parse_rejects_unknown() { - assert!(DataSource::parse("nope").is_err()); - assert!(DataSource::parse("Discord").is_err()); // case-sensitive - assert!(DataSource::parse("drive docs").is_err()); // no spaces -} - -#[test] -fn data_source_serde_is_snake_case() { - let ds = DataSource::MeetingNotes; - let json = serde_json::to_string(&ds).unwrap(); - assert_eq!(json, "\"meeting_notes\""); - let parsed: DataSource = serde_json::from_str("\"meeting_notes\"").unwrap(); - assert_eq!(parsed, ds); -} - -#[test] -fn approx_token_count_scales_linearly() { - assert_eq!(approx_token_count(""), 0); - assert_eq!(approx_token_count("a"), 1); // 1→1 - assert_eq!(approx_token_count("abcd"), 1); // 4→1 - assert_eq!(approx_token_count("abcde"), 2); // 5→2 - assert_eq!(approx_token_count(&"x".repeat(400)), 100); -} - -#[test] -fn source_kind_parse_rejects_unknown_wire_values() { - assert_eq!( - SourceKind::parse("video").unwrap_err(), - "unknown source kind: video" - ); -} - -#[test] -fn metadata_constructor_and_source_ref_fill_documented_defaults() { - let timestamp = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap(); - let mut metadata = Metadata::point_in_time(SourceKind::Document, "doc-1", "alice", timestamp); - metadata.source_ref = Some(SourceRef::new("notion://doc-1")); - - assert_eq!(metadata.source_id, "doc-1"); - assert_eq!(metadata.owner, "alice"); - assert_eq!(metadata.time_range, (timestamp, timestamp)); - assert!(metadata.tags.is_empty()); - assert_eq!(metadata.source_ref.unwrap().value, "notion://doc-1"); -} - -#[test] -fn chunk_json_round_trips_millisecond_time_range_and_partial_default() { - let timestamp = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap(); - let chunk = Chunk { - id: "chunk".into(), - content: "body".into(), - metadata: Metadata::point_in_time(SourceKind::Chat, "channel", "alice", timestamp), - token_count: 1, - seq_in_source: 0, - created_at: timestamp, - partial_message: true, - }; - let encoded = serde_json::to_value(&chunk).unwrap(); - assert_eq!( - encoded["metadata"]["time_range"]["start_ms"], - timestamp.timestamp_millis() - ); - assert_eq!(serde_json::from_value::(encoded).unwrap(), chunk); - - let mut legacy = serde_json::to_value(&chunk).unwrap(); - legacy.as_object_mut().unwrap().remove("partial_message"); - assert!( - !serde_json::from_value::(legacy) - .unwrap() - .partial_message - ); -} - -#[test] -fn chunk_json_rejects_out_of_range_time_range_endpoints() { - let timestamp = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap(); - let chunk = Chunk { - id: "chunk".into(), - content: "body".into(), - metadata: Metadata::point_in_time(SourceKind::Chat, "channel", "alice", timestamp), - token_count: 1, - seq_in_source: 0, - created_at: timestamp, - partial_message: false, - }; - let mut encoded = serde_json::to_value(chunk).unwrap(); - encoded["metadata"]["time_range"]["start_ms"] = serde_json::json!(i64::MAX); - assert!(serde_json::from_value::(encoded.clone()) - .unwrap_err() - .to_string() - .contains("invalid start_ms")); - - encoded["metadata"]["time_range"]["start_ms"] = serde_json::json!(0); - encoded["metadata"]["time_range"]["end_ms"] = serde_json::json!(i64::MAX); - assert!(serde_json::from_value::(encoded) - .unwrap_err() - .to_string() - .contains("invalid end_ms")); -} diff --git a/api/src/error.rs b/api/src/error.rs deleted file mode 100644 index c9e64056..00000000 --- a/api/src/error.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Engine-level error type shared by ported modules that want a typed error -//! surface. Modules that mirror OpenHuman's `anyhow`-based signatures may keep -//! using `anyhow::Result`; this enum is for contracts that benefit from -//! matchable variants (validation, not-found, taint, IO). -//! -//! `?` converts `std::io::Error` and `serde_json::Error` into -//! [`MemoryError::Io`] / [`MemoryError::Serde`] automatically via the derived -//! `#[from]` impls, and any `anyhow::Error` (including one produced by `?` on -//! a foreign error type inside an `anyhow`-returning function) into -//! [`MemoryError::Other`]. The purpose-built variants ([`MemoryError::NotFound`], -//! [`MemoryError::Invalid`], [`MemoryError::BudgetExceeded`], -//! [`MemoryError::PathEscape`]) are constructed explicitly by callers that want -//! matchable, typed failure — they are never inferred from a foreign error. -//! -//! [`MemoryError::Unsupported`] is the one variant that belongs to the *driver -//! contract* rather than the engine: it is what a caller gets when a bound -//! driver does not implement the capability family a call needs. See its docs -//! for why that should be rare. - -use thiserror::Error; - -use crate::capabilities::Capability; - -/// Errors surfaced by the memory engine. -#[derive(Debug, Error)] -pub enum MemoryError { - /// A requested record / source / node was not found. - #[error("not found: {0}")] - NotFound(String), - /// Caller-supplied input failed validation. - #[error("invalid input: {0}")] - Invalid(String), - /// A configured budget (tokens, cost, depth) was exceeded. - #[error("budget exceeded: {0}")] - BudgetExceeded(String), - /// A path escaped the workspace sandbox (symlink / traversal). - #[error("path escapes workspace: {0}")] - PathEscape(String), - /// Underlying IO failure. - #[error("io error: {0}")] - Io(#[from] std::io::Error), - /// Serialization / deserialization failure. - #[error("serde error: {0}")] - Serde(#[from] serde_json::Error), - /// The bound driver does not implement the named capability family. - /// - /// This should be **rare**, because capabilities are negotiated once at - /// bind time and the kernel unregisters the RPC methods and omits the agent - /// tools of every unadvertised family. Reaching this variant means one of: - /// - /// - an out-of-process driver answered `501` for a family its handshake - /// claimed (the case [`crate::capabilities`] cannot pre-empt); - /// - a caller bypassed the capability filter — a kernel bug. - /// - /// ## Why the payload is an owned `String` and not a [`Capability`] - /// - /// The transport adapter constructs this from a wire response, where the - /// family is a runtime string that may not be a known [`Capability`] at all - /// — a driver speaking a newer minor contract version, a vendor extension, - /// or simply a typo in a third-party backend. A `Capability` field would - /// force the adapter to drop that information or fail parsing, and a - /// `&'static str` cannot be produced from a runtime value without leaking - /// memory. An owned `String` is the only representation that round-trips - /// every case. - /// - /// Construct it with [`MemoryError::unsupported`] when the family is known - /// (that path yields the canonical [`Capability::as_str`] spelling) and - /// with [`MemoryError::unsupported_raw`] when it came off the wire. - #[error("unsupported capability: {capability}")] - Unsupported { - /// Wire name of the capability family that is not supported — - /// [`Capability::as_str`] when known, otherwise the raw string the - /// driver reported. - capability: String, - }, - /// Catch-all wrapping an opaque lower-level error. - #[error(transparent)] - Other(#[from] anyhow::Error), -} - -impl MemoryError { - /// Builds [`MemoryError::Unsupported`] for a family this build knows, - /// using its canonical [`Capability::as_str`] spelling. - pub fn unsupported(capability: Capability) -> Self { - Self::Unsupported { - capability: capability.as_str().to_string(), - } - } - - /// Builds [`MemoryError::Unsupported`] from a family name that came off the - /// wire and may not correspond to any known [`Capability`]. - pub fn unsupported_raw(capability: impl Into) -> Self { - Self::Unsupported { - capability: capability.into(), - } - } -} - -/// Convenience result alias for engine-level fallible operations. -pub type MemoryEngineResult = Result; - -#[cfg(test)] -#[path = "error_tests.rs"] -mod tests; diff --git a/api/src/error_tests.rs b/api/src/error_tests.rs deleted file mode 100644 index 75d72bc0..00000000 --- a/api/src/error_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Unit tests for [`super::MemoryError`], focused on the `Unsupported` variant -//! added for the driver contract. The older variants are exercised where they -//! are constructed, in the engine crate. - -use super::*; -use crate::capabilities::Capability; - -#[test] -fn unsupported_from_a_known_capability_uses_the_canonical_wire_name() { - for capability in Capability::ALL { - let err = MemoryError::unsupported(capability); - match err { - MemoryError::Unsupported { - capability: ref got, - } => { - assert_eq!(got, capability.as_str()); - } - other => panic!("expected Unsupported, got {other:?}"), - } - } -} - -#[test] -fn unsupported_raw_preserves_a_family_this_build_does_not_know() { - // The reason the payload is an owned `String`: a driver speaking a newer - // minor contract version can name a family that is not a `Capability` here, - // and the adapter must be able to report it verbatim. - let err = MemoryError::unsupported_raw("holographic_recall"); - match err { - MemoryError::Unsupported { ref capability } => { - assert_eq!(capability, "holographic_recall"); - assert!(Capability::parse(capability).is_err()); - } - other => panic!("expected Unsupported, got {other:?}"), - } -} - -#[test] -fn unsupported_display_names_the_capability() { - assert_eq!( - MemoryError::unsupported(Capability::Tree).to_string(), - "unsupported capability: tree" - ); - assert_eq!( - MemoryError::unsupported(Capability::ToolMemory).to_string(), - "unsupported capability: tool_memory" - ); -} - -#[test] -fn unsupported_is_distinguishable_from_the_other_variants() { - // A transport adapter maps `501` to `Unsupported` and everything else - // elsewhere, so the variant must not collide with `Invalid` / `NotFound`. - let unsupported = MemoryError::unsupported(Capability::Diff); - assert!(matches!(unsupported, MemoryError::Unsupported { .. })); - - let invalid = MemoryError::Invalid("diff".to_string()); - assert!(!matches!(invalid, MemoryError::Unsupported { .. })); -} diff --git a/api/src/goals.rs b/api/src/goals.rs deleted file mode 100644 index 697a8678..00000000 --- a/api/src/goals.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Domain types for the agent's long-term goals list. -//! -//! Goals are a small, ordered list of durable objectives the agent holds when -//! interacting with the user. They are persisted as a compact markdown document -//! (`MEMORY_GOALS.md`) by the engine crate's `memory::goals::store` and -//! surfaced over RPC + agent tools. Each item carries a stable short id so -//! edit/delete operations can address a specific line without depending on -//! ordering. -//! -//! This module is **pure data**: it owns the shape, parse, and render only. -//! The validating mutation surface (`add` / `edit` / `delete`) lives next to -//! the `regex`-backed PII/secret predicates it calls, in the engine crate's -//! `memory::goals::store::GoalsDocMutations` trait, so the value types stay -//! free of the safety machinery and of `regex`. The cap-enforcing persistence -//! layer and the reflection apply/dedupe logic live in the engine crate too. - -use serde::{Deserialize, Serialize}; - -/// Markdown header rendered at the top of `MEMORY_GOALS.md`. -pub(crate) const HEADER: &str = "# Long-term Goals"; - -/// A single long-term goal item. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GoalItem { - /// Stable short id (e.g. `g1`). Used as the dedupe/address key for - /// `edit`/`delete`. Rendered inline in the markdown as `- [g1] …`. - pub id: String, - /// The goal text — one concise sentence. - pub text: String, -} - -impl GoalItem { - /// Construct a goal item from an id + text, trimming surrounding - /// whitespace from the text. - pub fn new(id: impl Into, text: impl Into) -> Self { - Self { - id: id.into(), - text: text.into().trim().to_string(), - } - } -} - -/// The full goals document — an ordered list of [`GoalItem`]s. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct GoalsDoc { - /// Ordered goal items. Order is meaningful for rendering and cap trimming - /// (oldest = front). - pub items: Vec, -} - -impl GoalsDoc { - /// Parse a `MEMORY_GOALS.md` body into a [`GoalsDoc`]. - /// - /// Recognised item lines look like `- [g1] do the thing`. Lines that don't - /// match (the header, blank lines, free prose) are ignored so a - /// hand-edited file degrades gracefully rather than erroring. - pub fn parse(body: &str) -> Self { - let mut items = Vec::new(); - for line in body.lines() { - let trimmed = line.trim(); - // Strip the leading list marker, if present. - let rest = match trimmed.strip_prefix("- ") { - Some(r) => r.trim(), - None => continue, - }; - // Expect `[id] text`. - let Some(after_open) = rest.strip_prefix('[') else { - continue; - }; - let Some(close_idx) = after_open.find(']') else { - continue; - }; - let id = after_open[..close_idx].trim(); - let text = after_open[close_idx + 1..].trim(); - if id.is_empty() || text.is_empty() { - continue; - } - items.push(GoalItem::new(id, text)); - } - Self { items } - } - - /// Render the document back to markdown suitable for `MEMORY_GOALS.md`. - /// - /// NOTE: this emits only the header and the recognised `- [id] text` - /// item lines — any free prose, sub-bullets, or other hand-added content - /// a user wrote into the file is not represented in [`GoalsDoc`] and is - /// therefore dropped on the next `parse` → mutate → `render` round-trip - /// (e.g. via `add`/`edit`/`delete`/reflection). Treat this file as - /// machine-owned rather than freely hand-editable. - pub fn render(&self) -> String { - let mut out = String::from(HEADER); - out.push_str("\n\n"); - for item in &self.items { - out.push_str(&format!("- [{}] {}\n", item.id, item.text)); - } - out - } - - /// Whether the list currently has no items. Used to drive the - /// "first run / initial population" reflection behaviour. - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - /// Number of goal items currently held. - pub fn len(&self) -> usize { - self.items.len() - } - - /// Allocate the next free `g` id not already used in the list. - pub fn next_id(&self) -> String { - let mut n = self.items.len() + 1; - loop { - let candidate = format!("g{n}"); - if !self.items.iter().any(|i| i.id == candidate) { - return candidate; - } - n += 1; - } - } - - /// Whether the list already holds `id`. - pub fn contains_id(&self, id: &str) -> bool { - self.items.iter().any(|i| i.id == id) - } -} - -#[cfg(test)] -#[path = "goals_tests.rs"] -mod tests; diff --git a/api/src/goals_tests.rs b/api/src/goals_tests.rs deleted file mode 100644 index e67410ce..00000000 --- a/api/src/goals_tests.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Unit tests for [`super::GoalsDoc`] parse/render — the pure-data half. -//! -//! The validating mutation tests (`add` / `edit` / `delete`, including the -//! secret/PII rejection cases) live in the engine crate next to the -//! `GoalsDocMutations` trait that owns them: `memory::goals::mutations_tests`. - -use super::*; - -#[test] -fn render_starts_with_header() { - let doc = GoalsDoc::default(); - assert!(doc.render().starts_with("# Long-term Goals")); -} - -#[test] -fn parse_ignores_non_item_lines() { - let body = "# Long-term Goals\n\nsome stray prose\n- [g1] real goal\n- malformed line\n"; - let doc = GoalsDoc::parse(body); - assert_eq!(doc.items.len(), 1); - assert_eq!(doc.items[0].id, "g1"); - assert_eq!(doc.items[0].text, "real goal"); -} diff --git a/api/src/health.rs b/api/src/health.rs deleted file mode 100644 index 5a684444..00000000 --- a/api/src/health.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Liveness state a memory driver reports about itself. -//! -//! ## Why this lives in the contract crate and not in the host -//! -//! The OpenHuman kernel has (or will have) a *generic* subsystem-agnostic -//! `DriverHealth` shared by memory, inference, channels, and sandbox. This crate -//! cannot name that type: `tinycortex-api` is the contract a third-party driver -//! compiles against, and a driver must be able to depend on it without pulling -//! in the OpenHuman host — nor should the next subsystem cut over inherit -//! generic kernel vocabulary from a *memory* crate. -//! -//! So the contract carries its own [`MemoryHealth`], and the host's memory -//! adapter converts. The conversion is deliberately trivial and lossless: this -//! is a **small closed enum with a reason string**, shaped one-for-one against -//! the kernel's `Ready | Degraded { reason } | Down { reason }`, not a -//! free-form struct that would need field-by-field mapping and would drift. -//! Keep it that way — if a driver needs to report something richer, it belongs -//! in a driver-specific status payload, not here. -//! -//! ## Wire form -//! -//! Serializes as an internally-tagged object with a stable snake_case `status` -//! discriminant, which is also the shape of the transport adapter's -//! `GET /v1/health` → `{ status, reason }` response: -//! -//! ```json -//! { "status": "ready" } -//! { "status": "degraded", "reason": "vector index rebuilding" } -//! { "status": "down", "reason": "connection refused" } -//! ``` - -use serde::{Deserialize, Serialize}; - -/// Health of a bound memory driver, as the driver reports it. -/// -/// The three states are ordered by severity and mean different things to the -/// kernel: -/// -/// - [`MemoryHealth::Ready`] — serve traffic normally. -/// - [`MemoryHealth::Degraded`] — still serve traffic, but surface the reason -/// in status output; results may be incomplete or slow. -/// - [`MemoryHealth::Down`] — do not serve traffic; the bind should be surfaced -/// as failed and, per the fallback rule, the embedded default rebound. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum MemoryHealth { - /// The driver is reachable and serving requests normally. - Ready, - /// The driver is serving requests, but something is wrong and the caller - /// should surface it. Results may be incomplete, stale, or slow. - Degraded { - /// Operator-facing explanation. Must not contain credentials, tokens, - /// or user memory content — this string is logged and shown in status - /// output. - reason: String, - }, - /// The driver cannot serve requests at all. - Down { - /// Operator-facing explanation, subject to the same redaction rule as - /// [`MemoryHealth::Degraded::reason`]. - reason: String, - }, -} - -impl MemoryHealth { - /// Convenience constructor for [`MemoryHealth::Degraded`]. - pub fn degraded(reason: impl Into) -> Self { - Self::Degraded { - reason: reason.into(), - } - } - - /// Convenience constructor for [`MemoryHealth::Down`]. - pub fn down(reason: impl Into) -> Self { - Self::Down { - reason: reason.into(), - } - } - - /// Stable snake_case discriminant, matching the serialized `status` field. - pub fn as_str(&self) -> &'static str { - match self { - Self::Ready => "ready", - Self::Degraded { .. } => "degraded", - Self::Down { .. } => "down", - } - } - - /// The operator-facing reason, when there is one. `None` for - /// [`MemoryHealth::Ready`]. - pub fn reason(&self) -> Option<&str> { - match self { - Self::Ready => None, - Self::Degraded { reason } | Self::Down { reason } => Some(reason.as_str()), - } - } - - /// Whether the kernel should route traffic to this driver. - /// - /// True for [`MemoryHealth::Ready`] and [`MemoryHealth::Degraded`] — a - /// degraded driver is still the bound driver — and false for - /// [`MemoryHealth::Down`]. - pub fn is_usable(&self) -> bool { - !matches!(self, Self::Down { .. }) - } -} - -impl std::fmt::Display for MemoryHealth { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.reason() { - Some(reason) => write!(f, "{}: {reason}", self.as_str()), - None => f.write_str(self.as_str()), - } - } -} - -#[cfg(test)] -#[path = "health_tests.rs"] -mod tests; diff --git a/api/src/health_tests.rs b/api/src/health_tests.rs deleted file mode 100644 index a31c9651..00000000 --- a/api/src/health_tests.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Unit tests for [`super::MemoryHealth`]. -//! -//! These pin the two properties the host's memory adapter depends on: the -//! variant set is closed and small enough for a lossless `match` into the -//! kernel's generic `DriverHealth`, and the wire form carries a stable -//! `status` discriminant plus a `reason`. - -use super::*; -use serde_json::json; - -#[test] -fn ready_has_no_reason_and_is_usable() { - let health = MemoryHealth::Ready; - assert_eq!(health.as_str(), "ready"); - assert_eq!(health.reason(), None); - assert!(health.is_usable()); - assert_eq!(health.to_string(), "ready"); -} - -#[test] -fn degraded_carries_a_reason_and_is_still_usable() { - let health = MemoryHealth::degraded("vector index rebuilding"); - assert_eq!(health.as_str(), "degraded"); - assert_eq!(health.reason(), Some("vector index rebuilding")); - // A degraded driver is still the bound driver. - assert!(health.is_usable()); - assert_eq!(health.to_string(), "degraded: vector index rebuilding"); -} - -#[test] -fn down_carries_a_reason_and_is_not_usable() { - let health = MemoryHealth::down("connection refused"); - assert_eq!(health.as_str(), "down"); - assert_eq!(health.reason(), Some("connection refused")); - assert!(!health.is_usable()); - assert_eq!(health.to_string(), "down: connection refused"); -} - -#[test] -fn health_serializes_with_a_stable_status_discriminant() { - assert_eq!( - serde_json::to_value(MemoryHealth::Ready).unwrap(), - json!({ "status": "ready" }) - ); - assert_eq!( - serde_json::to_value(MemoryHealth::degraded("slow")).unwrap(), - json!({ "status": "degraded", "reason": "slow" }) - ); - assert_eq!( - serde_json::to_value(MemoryHealth::down("gone")).unwrap(), - json!({ "status": "down", "reason": "gone" }) - ); -} - -#[test] -fn health_round_trips_through_serde() { - for health in [ - MemoryHealth::Ready, - MemoryHealth::degraded("reindexing"), - MemoryHealth::down("auth expired"), - ] { - let encoded = serde_json::to_string(&health).unwrap(); - let decoded: MemoryHealth = serde_json::from_str(&encoded).unwrap(); - assert_eq!(decoded, health); - } -} - -#[test] -fn health_constructors_match_their_variants() { - assert_eq!( - MemoryHealth::degraded("x"), - MemoryHealth::Degraded { - reason: "x".to_string() - } - ); - assert_eq!( - MemoryHealth::down("y"), - MemoryHealth::Down { - reason: "y".to_string() - } - ); -} - -#[test] -fn degraded_without_a_reason_is_rejected_on_the_wire() { - // `reason` is mandatory: a degraded/down driver that explains nothing is - // useless in status output, so the contract refuses to decode it. - assert!(serde_json::from_value::(json!({ "status": "degraded" })).is_err()); - assert!(serde_json::from_value::(json!({ "status": "down" })).is_err()); -} diff --git a/api/src/lib.rs b/api/src/lib.rs index a0c37345..d9b32e5e 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,5 +1,13 @@ //! Stable public contracts for the TinyCortex memory system. //! +//! # This crate is now a re-export +//! +//! Every item here comes from [`tinymemory_api`], which was extracted from this +//! crate and is its single source of truth (tinymemory#18 §A1). The paths are +//! unchanged and the types are the same types, so an existing dependant needs +//! no edit; what goes away is the second, nominally-distinct copy that a +//! conversion layer had to keep in step by hand. +//! //! This crate holds the value types, error enum, capability vocabulary, and //! storage trait that both the `tinycortex` engine and its embedding hosts //! compile against. It is deliberately dependency-light (serde / serde_json / @@ -52,18 +60,27 @@ //! - [`tool_memory`]: tool-scoped rule contracts ([`tool_memory::ToolMemoryRule`], …). //! - [`goals`]: the long-term goals document ([`goals::GoalsDoc`], [`goals::GoalItem`]). -pub mod capabilities; -pub mod chunks; -pub mod error; -pub mod goals; -pub mod health; -pub mod null; -pub mod provider; -pub mod recall; -pub mod tool_memory; -pub mod traits; -pub mod tree; -pub mod types; -pub mod version; +// Every module below is `tinymemory-api`'s, re-exported. This crate defined its +// own copies until tinymemory#18 §A1; they were the same types by construction — +// `tinymemory-api` was extracted from this crate and held byte-identical — but +// being *nominally* distinct meant `adapters/tinycortex/src/convert.rs` had to +// translate between them on every call, and a field added to one had to be +// added to the other and to the conversion, in three places, or a value was +// silently dropped. +// +// Re-exporting keeps every existing path working: `tinycortex_api::types:: +// MemoryEntry` still resolves, and now resolves to the same type the contract +// names. Nothing downstream has to be rewritten to gain that. +// +// Two things the re-export changes on purpose. The capability vocabulary grows +// from thirteen families to eighteen — the engine matches on none of them, so +// nothing here is affected — and `CONTRACT_VERSION` becomes the contract's own +// `(2, 2)` rather than this crate's stale `(1, 0)`. That version was already +// wrong: hosts bind against the contract, and this crate has not been the thing +// they compile against for some time. The engine never reads it. +pub use tinymemory_api::{ + capabilities, chunks, error, goals, health, null, provider, recall, tool_memory, traits, tree, + types, version, +}; -pub use version::{is_compatible, CONTRACT_VERSION}; +pub use tinymemory_api::{is_compatible, CONTRACT_VERSION}; diff --git a/api/src/null.rs b/api/src/null.rs deleted file mode 100644 index 482bc5c0..00000000 --- a/api/src/null.rs +++ /dev/null @@ -1,445 +0,0 @@ -//! [`NullMemoryProvider`] — the reference driver that stores nothing. -//! -//! ## What it is for -//! -//! A memory subsystem that is compiled out, disabled by configuration, or -//! explicitly bound to `driver = "null"` still has to bind *something*: the -//! kernel's registry holds exactly one driver per slot, and code that reaches -//! the slot must find a value rather than an `Option` it has to unwrap at every -//! call site. This is that value. It replaces the hand-written per-domain -//! `stub.rs` files with one generic answer. -//! -//! It is also the fixture the capability-degradation tests bind: with it in the -//! slot, the ten optional families are unadvertised, so their RPC methods are -//! unregistered and their agent tools are absent — and the core still boots. -//! -//! And it is the existence proof for the mandatory set: if -//! [`crate::provider::MemoryCore`], [`crate::provider::MemoryRecall`], and -//! [`crate::provider::MemoryPortability`] could not be implemented without a -//! storage engine, they would be the wrong three to have made mandatory. -//! -//! ## `/dev/null` semantics, and what that costs -//! -//! Writes are **accepted and discarded**; reads return empty. This mirrors the -//! Unix device the driver is named after, and it is the only behaviour that -//! lets the mandatory three be advertised honestly: a `store` that returned -//! [`crate::error::MemoryError::Unsupported`] would contradict advertising -//! [`crate::capabilities::Capability::Core`], and one that returned a hard -//! error would turn every optional auto-capture into a user-visible failure. -//! -//! The cost is real: content written here is gone. That is acceptable for a -//! subsystem the operator turned off, and unacceptable as a fallback for a -//! driver that failed to bind — **that** case falls back to the embedded -//! default, never to this. Do not wire it as a general-purpose failure mode. -//! -//! ## Why it implements all thirteen families but advertises three -//! -//! The ten optional families are implemented and every method returns -//! [`crate::error::MemoryError::Unsupported`] naming its family, but the -//! `as_*` accessors return `None` and -//! [`crate::provider::MemoryProvider::capabilities`] lists only the mandatory -//! three. So: -//! -//! - through `&dyn MemoryProvider` — the only way product code sees a driver — -//! an unadvertised family is simply **unreachable**, which is the intended -//! degradation; -//! - through the concrete type, a direct call yields a typed, *named* -//! `Unsupported` error, which is what makes the contract's error mapping -//! testable without writing a second mock. -//! -//! [`crate::provider::audit_provider`] confirms the two views agree. - -use async_trait::async_trait; - -use crate::capabilities::{Capabilities, Capability}; -use crate::error::MemoryError; -use crate::goals::GoalsDoc; -use crate::health::MemoryHealth; -use crate::provider::types::{ - DiffReport, EntityHit, ExportPage, ExportRecord, ImportOutcome, IngestItem, IngestOutcome, - MaintenanceReport, SnapshotRef, SourceItem, SourceScope, -}; -use crate::provider::{ - MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryGoals, MemoryGraph, - MemoryIngest, MemoryMaintenance, MemoryPortability, MemoryProvider, MemoryRecall, - MemorySourceSink, MemoryToolMemory, MemoryTree, -}; -use crate::recall::OwnedRecallOpts; -use crate::tool_memory::ToolMemoryRule; -use crate::tree::{IngestRequest, QueryResult, TreeStatus}; -use crate::types::{ - GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, - NamespaceDocumentInput, NamespaceRetrievalContext, NamespaceSummary, StoredMemoryDocument, -}; - -/// The [`driver_id`](MemoryProvider::driver_id) this driver reports. -pub const NULL_DRIVER_ID: &str = "null"; - -/// Shorthand for the `Unsupported` error every unadvertised family returns. -fn unsupported(capability: Capability) -> Result { - Err(MemoryError::unsupported(capability)) -} - -/// A driver that accepts every write, discards it, and returns nothing. -/// -/// See the module documentation for what it is for, why writes are silently -/// dropped, and why it implements ten families it does not advertise. -#[derive(Debug, Clone, Copy, Default)] -pub struct NullMemoryProvider; - -impl NullMemoryProvider { - /// Construct the null driver. It holds no state, so every instance is - /// interchangeable. - pub const fn new() -> Self { - Self - } -} - -#[async_trait] -impl MemoryProvider for NullMemoryProvider { - fn driver_id(&self) -> &str { - NULL_DRIVER_ID - } - - /// Exactly the mandatory three. The ten optional families are implemented - /// below but deliberately not advertised, so they stay unreachable through - /// the trait object. - fn capabilities(&self) -> Capabilities { - Capabilities::mandatory() - } - - /// Always [`MemoryHealth::Ready`]: a driver with no backing store has - /// nothing that can be unreachable, and reporting `Degraded` would make - /// every status view of a deliberately-disabled subsystem look broken. - async fn health(&self) -> MemoryHealth { - MemoryHealth::Ready - } - - // The `as_*` accessors are all left at their `None` defaults: nothing - // optional is reachable through the trait object. That absence is the whole - // point of this driver, so overriding any of them would be the bug. -} - -#[async_trait] -impl MemoryCore for NullMemoryProvider { - /// Accepts and discards. See the module docs on `/dev/null` semantics. - async fn store( - &self, - _namespace: &str, - _key: &str, - _content: &str, - _category: MemoryCategory, - _session_id: Option<&str>, - _taint: MemoryTaint, - ) -> Result<(), MemoryError> { - Ok(()) - } - - async fn get(&self, _namespace: &str, _key: &str) -> Result, MemoryError> { - Ok(None) - } - - /// Always `Ok(false)`: nothing was ever stored, so nothing existed to - /// forget. Consistent with the idempotence the family requires. - async fn forget(&self, _namespace: &str, _key: &str) -> Result { - Ok(false) - } - - async fn list( - &self, - _namespace: Option<&str>, - _category: Option<&MemoryCategory>, - _session_id: Option<&str>, - ) -> Result, MemoryError> { - Ok(Vec::new()) - } - - async fn namespaces(&self) -> Result, MemoryError> { - Ok(Vec::new()) - } -} - -#[async_trait] -impl MemoryRecall for NullMemoryProvider { - async fn recall( - &self, - _query: &str, - _limit: usize, - _opts: &OwnedRecallOpts, - _scope: Option<&SourceScope>, - ) -> Result, MemoryError> { - Ok(Vec::new()) - } -} - -#[async_trait] -impl MemoryPortability for NullMemoryProvider { - /// One empty, terminal page: no records and no continuation cursor, so a - /// caller's export loop terminates on the first iteration. - /// - /// This driver never issues a cursor (every page is the first and only - /// page), so any `Some(_)` cursor a caller passes back is necessarily one - /// this driver did not hand out — reject it rather than silently treating - /// it as a valid terminal page. - async fn export_page( - &self, - cursor: Option<&str>, - _limit: usize, - ) -> Result { - if cursor.is_some() { - return Err(MemoryError::Invalid( - "null provider does not issue export cursors".into(), - )); - } - - Ok(ExportPage::default()) - } - - /// Counts every record as skipped rather than imported. Reporting them as - /// imported would tell a migration its data landed somewhere it did not. - async fn import_records( - &self, - records: Vec, - ) -> Result { - Ok(ImportOutcome { - imported: 0, - skipped: u32::try_from(records.len()).unwrap_or(u32::MAX), - failed: 0, - errors: Vec::new(), - }) - } -} - -#[async_trait] -impl MemoryIngest for NullMemoryProvider { - async fn ingest_document(&self, _item: IngestItem) -> Result { - unsupported(Capability::Ingest) - } - - async fn ingest_chat(&self, _messages: Vec) -> Result { - unsupported(Capability::Ingest) - } -} - -#[async_trait] -impl MemoryDocuments for NullMemoryProvider { - async fn put_document(&self, _input: NamespaceDocumentInput) -> Result { - unsupported(Capability::Documents) - } - - async fn get_document( - &self, - _namespace: &str, - _key: &str, - ) -> Result, MemoryError> { - unsupported(Capability::Documents) - } - - async fn query_documents( - &self, - _namespace: &str, - _query: &str, - _limit: usize, - ) -> Result { - unsupported(Capability::Documents) - } -} - -#[async_trait] -impl MemoryTree for NullMemoryProvider { - async fn append(&self, _request: IngestRequest) -> Result<(), MemoryError> { - unsupported(Capability::Tree) - } - - async fn query_source( - &self, - _namespace: &str, - _source_id: &str, - _limit: usize, - _scope: Option<&SourceScope>, - ) -> Result, MemoryError> { - unsupported(Capability::Tree) - } - - async fn drill_down( - &self, - _namespace: &str, - _node_id: &str, - ) -> Result { - unsupported(Capability::Tree) - } - - async fn seal(&self, _namespace: &str) -> Result { - unsupported(Capability::Tree) - } - - async fn cascade(&self, _namespace: &str) -> Result { - unsupported(Capability::Tree) - } -} - -#[async_trait] -impl MemoryEntities for NullMemoryProvider { - async fn entities( - &self, - _namespace: &str, - _query: Option<&str>, - _limit: usize, - ) -> Result, MemoryError> { - unsupported(Capability::Entities) - } - - async fn entity_edges( - &self, - _namespace: &str, - _entity_id: &str, - _limit: usize, - ) -> Result, MemoryError> { - unsupported(Capability::Entities) - } - - async fn touch_entities( - &self, - _namespace: &str, - _entity_ids: &[String], - ) -> Result<(), MemoryError> { - unsupported(Capability::Entities) - } -} - -#[async_trait] -impl MemoryGraph for NullMemoryProvider { - async fn kv_get( - &self, - _namespace: Option<&str>, - _key: &str, - ) -> Result, MemoryError> { - unsupported(Capability::Graph) - } - - async fn kv_put( - &self, - _namespace: Option<&str>, - _key: &str, - _value: serde_json::Value, - ) -> Result<(), MemoryError> { - unsupported(Capability::Graph) - } - - async fn kv_list( - &self, - _namespace: Option<&str>, - _prefix: Option<&str>, - _limit: usize, - ) -> Result, MemoryError> { - unsupported(Capability::Graph) - } - - async fn relations( - &self, - _namespace: Option<&str>, - _subject: Option<&str>, - _predicate: Option<&str>, - _limit: usize, - ) -> Result, MemoryError> { - unsupported(Capability::Graph) - } - - async fn put_relation(&self, _relation: GraphRelationRecord) -> Result<(), MemoryError> { - unsupported(Capability::Graph) - } -} - -#[async_trait] -impl MemoryDiff for NullMemoryProvider { - async fn capture_snapshot(&self, _source_id: &str) -> Result { - unsupported(Capability::Diff) - } - - async fn snapshots( - &self, - _source_id: &str, - _limit: usize, - ) -> Result, MemoryError> { - unsupported(Capability::Diff) - } - - async fn diff( - &self, - _source_id: &str, - _from: Option<&str>, - _to: &str, - ) -> Result { - unsupported(Capability::Diff) - } -} - -#[async_trait] -impl MemoryGoals for NullMemoryProvider { - async fn goals(&self) -> Result { - unsupported(Capability::Goals) - } - - async fn set_goals(&self, _goals: GoalsDoc) -> Result<(), MemoryError> { - unsupported(Capability::Goals) - } -} - -#[async_trait] -impl MemoryToolMemory for NullMemoryProvider { - async fn tool_rules(&self, _tool_name: &str) -> Result, MemoryError> { - unsupported(Capability::ToolMemory) - } - - async fn put_tool_rule(&self, _rule: ToolMemoryRule) -> Result<(), MemoryError> { - unsupported(Capability::ToolMemory) - } - - async fn delete_tool_rule( - &self, - _tool_name: &str, - _rule_id: &str, - ) -> Result { - unsupported(Capability::ToolMemory) - } -} - -#[async_trait] -impl MemorySourceSink for NullMemoryProvider { - async fn accept_source_items( - &self, - _source_id: &str, - _source_kind: &str, - _items: Vec, - _taint: MemoryTaint, - ) -> Result { - unsupported(Capability::Sources) - } - - async fn forget_source(&self, _source_id: &str) -> Result { - unsupported(Capability::Sources) - } -} - -#[async_trait] -impl MemoryMaintenance for NullMemoryProvider { - async fn reembed(&self) -> Result { - unsupported(Capability::Maintenance) - } - - async fn compact(&self) -> Result { - unsupported(Capability::Maintenance) - } - - async fn consolidate(&self) -> Result { - unsupported(Capability::Maintenance) - } - - async fn doctor(&self) -> Result { - unsupported(Capability::Maintenance) - } -} - -#[cfg(test)] -#[path = "null_tests.rs"] -mod tests; diff --git a/api/src/null_tests.rs b/api/src/null_tests.rs deleted file mode 100644 index 573ee90b..00000000 --- a/api/src/null_tests.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Tests for the reference null driver. -//! -//! These pin three separate contracts: -//! -//! 1. the mandatory-three set is genuinely implementable without a store; -//! 2. an unadvertised family is **unreachable** through the trait object, which -//! is the degradation behaviour the kernel relies on; -//! 3. a direct call to an unadvertised family yields a typed `Unsupported` -//! error that **names** the family, which is what the transport adapter's -//! `501` mapping is checked against. -//! -//! ## No async runtime here, on purpose -//! -//! `tinycortex-api` must not depend on tokio (or any executor) — that is the -//! whole point of the crate. Every future in this module completes on its first -//! poll, so a six-line std-only [`block_on`] is sufficient and adds no -//! dependency. - -use std::future::Future; -use std::pin::pin; -use std::task::{Context, Poll}; - -use super::*; -use crate::provider::audit_provider; -use crate::types::MemoryCategory; - -/// Drive a future that is ready on first poll to completion, without an -/// executor. Panics rather than spinning if a future ever returns `Pending`, -/// because in this module that would mean a supposedly-inert implementation -/// started doing real work. -fn block_on(future: F) -> F::Output { - let mut future = pin!(future); - let mut context = Context::from_waker(std::task::Waker::noop()); - match future.as_mut().poll(&mut context) { - Poll::Ready(value) => value, - Poll::Pending => panic!("null driver future must complete on first poll"), - } -} - -#[test] -fn null_driver_advertises_exactly_the_mandatory_families() { - let driver = NullMemoryProvider::new(); - let capabilities = driver.capabilities(); - - assert_eq!(driver.driver_id(), NULL_DRIVER_ID); - assert_eq!(capabilities.len(), 3); - for capability in Capability::MANDATORY { - assert!( - capabilities.contains(capability), - "{capability} must be advertised" - ); - } -} - -#[test] -fn null_driver_passes_capability_validation() { - // The mandatory-three set is the minimum bindable set, so the reference - // driver must be bindable. If this ever fails, either the mandatory list - // grew or the null driver stopped implementing it. - let driver = NullMemoryProvider::new(); - assert_eq!(driver.capabilities().validate(), Ok(())); -} - -#[test] -fn null_driver_is_self_consistent() { - assert_eq!(audit_provider(&NullMemoryProvider::new()), Ok(())); -} - -#[test] -fn null_driver_reports_ready() { - let health = block_on(NullMemoryProvider::new().health()); - assert_eq!(health, MemoryHealth::Ready); - assert!(health.is_usable()); -} - -#[test] -fn null_driver_shutdown_is_an_idempotent_no_op() { - let driver = NullMemoryProvider::new(); - assert!(block_on(driver.shutdown()).is_ok()); - assert!(block_on(driver.shutdown()).is_ok()); -} - -#[test] -fn mandatory_core_accepts_writes_and_reads_back_empty() { - let driver = NullMemoryProvider::new(); - - block_on(driver.store( - "global", - "k", - "v", - MemoryCategory::Core, - None, - MemoryTaint::ExternalSync, - )) - .expect("null store must accept the write"); - - assert!(block_on(driver.get("global", "k")) - .expect("get must succeed") - .is_none()); - assert!(!block_on(driver.forget("global", "k")).expect("forget must succeed")); - assert!(block_on(driver.list(None, None, None)) - .expect("list must succeed") - .is_empty()); - assert!(block_on(driver.namespaces()) - .expect("namespaces must succeed") - .is_empty()); -} - -#[test] -fn mandatory_recall_returns_no_hits() { - let driver = NullMemoryProvider::new(); - let hits = block_on(driver.recall("anything", 10, &OwnedRecallOpts::default(), None)) - .expect("recall must succeed"); - assert!(hits.is_empty()); -} - -#[test] -fn mandatory_portability_round_trips_as_an_empty_store() { - let driver = NullMemoryProvider::new(); - - let page = block_on(driver.export_page(None, 100)).expect("export must succeed"); - assert!(page.records.is_empty()); - assert!( - page.next_cursor.is_none(), - "the absent cursor is what terminates the caller's export loop" - ); - - let outcome = block_on(driver.import_records(vec![ExportRecord { - kind: "entry".to_string(), - id: "rec-1".to_string(), - namespace: None, - taint: MemoryTaint::Internal, - payload: serde_json::Value::Null, - }])) - .expect("import must succeed"); - - // Skipped, never imported: reporting an import would tell a migration its - // data landed somewhere it did not. - assert_eq!(outcome.imported, 0); - assert_eq!(outcome.skipped, 1); - assert_eq!(outcome.failed, 0); -} - -#[test] -fn export_page_rejects_a_cursor_it_never_issued() { - let driver = NullMemoryProvider::new(); - - let err = block_on(driver.export_page(Some("unexpected"), 100)) - .expect_err("a cursor this driver never issued must be rejected, not silently accepted"); - assert!( - matches!(err, MemoryError::Invalid(_)), - "expected MemoryError::Invalid, got {err:?}" - ); -} - -#[test] -fn every_unadvertised_family_is_unreachable_through_the_trait_object() { - let driver = NullMemoryProvider::new(); - let provider: &dyn MemoryProvider = &driver; - - assert!(provider.as_ingest().is_none()); - assert!(provider.as_documents().is_none()); - assert!(provider.as_tree().is_none()); - assert!(provider.as_entities().is_none()); - assert!(provider.as_graph().is_none()); - assert!(provider.as_diff().is_none()); - assert!(provider.as_goals().is_none()); - assert!(provider.as_tool_memory().is_none()); - assert!(provider.as_sources().is_none()); - assert!(provider.as_maintenance().is_none()); -} - -#[test] -fn advertised_and_reachable_agree_for_every_family() { - // The invariant that keeps the capability set honest, checked family by - // family rather than only through the aggregate audit. - let driver = NullMemoryProvider::new(); - let provider: &dyn MemoryProvider = &driver; - let advertised = provider.capabilities(); - - for capability in Capability::ALL { - assert_eq!( - advertised.contains(capability), - provider.provides(capability), - "{capability}: advertised and reachable must agree" - ); - } -} - -/// Assert a result is `Unsupported` and names the expected family. -fn assert_unsupported(result: Result, expected: Capability) { - match result { - Err(MemoryError::Unsupported { capability }) => { - assert_eq!(capability, expected.as_str()); - } - other => panic!("expected Unsupported({expected}), got {other:?}"), - } -} - -#[test] -fn unadvertised_families_return_unsupported_naming_their_capability() { - let driver = NullMemoryProvider::new(); - - assert_unsupported(block_on(driver.ingest_chat(Vec::new())), Capability::Ingest); - assert_unsupported( - block_on(driver.get_document("global", "k")), - Capability::Documents, - ); - assert_unsupported(block_on(driver.seal("global")), Capability::Tree); - assert_unsupported( - block_on(driver.entities("global", None, 10)), - Capability::Entities, - ); - assert_unsupported(block_on(driver.kv_get(None, "k")), Capability::Graph); - assert_unsupported( - block_on(driver.capture_snapshot("src-abc")), - Capability::Diff, - ); - assert_unsupported(block_on(driver.goals()), Capability::Goals); - assert_unsupported(block_on(driver.tool_rules("shell")), Capability::ToolMemory); - assert_unsupported( - block_on(driver.forget_source("src-abc")), - Capability::Sources, - ); - assert_unsupported(block_on(driver.doctor()), Capability::Maintenance); -} - -#[test] -fn provider_is_usable_as_a_shared_trait_object() { - // The registry binds `Arc`, so the trait object must be - // `Send + Sync` and every family trait must be object-safe. This test fails - // to *compile* rather than to run if that ever regresses. - fn assert_send_sync(_value: &T) {} - - let provider: std::sync::Arc = - std::sync::Arc::new(NullMemoryProvider::new()); - assert_send_sync(&provider); - assert_eq!(provider.driver_id(), NULL_DRIVER_ID); - assert!(block_on(provider.list(None, None, None)) - .expect("list through the trait object") - .is_empty()); -} diff --git a/api/src/provider/audit.rs b/api/src/provider/audit.rs deleted file mode 100644 index b215c4e8..00000000 --- a/api/src/provider/audit.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! The honesty check: does a driver's advertised capability set match the -//! surface it actually exposes? -//! -//! [`MemoryProvider::capabilities`] is a *claim*, and the kernel acts on it — -//! it registers RPC methods and assembles agent tools from the advertised set -//! and never re-checks. A driver that advertises a family it does not implement -//! therefore produces a surface that exists in `/schema`, appears in the agent's -//! tool list, and fails on first use. That is precisely the -//! "registered-but-failing" outcome the degradation design exists to avoid. -//! -//! [`audit_provider`] compares the claim against -//! [`MemoryProvider::provides`] — which is derived from the accessors, so it -//! cannot drift from reality — and reports both directions of mismatch. Run it -//! at bind time next to [`crate::capabilities::Capabilities::validate`], and in -//! every driver's own test suite. -//! -//! The two directions mean different things: -//! -//! - **Advertised but absent** is a bug that will surface as a failing call. It -//! should refuse the bind. -//! - **Present but unadvertised** is dead surface: the family works but the -//! kernel unregistered it, so nothing can reach it. Usually a forgotten -//! entry in the driver's `capabilities()` list. - -use std::fmt; - -use crate::capabilities::Capability; -use crate::error::MemoryError; -use crate::provider::driver::MemoryProvider; - -/// A disagreement between what a driver advertises and what it implements. -/// -/// Carries the families structurally rather than as a formatted string so a -/// caller can report them in a status payload or a bind-failure event as well -/// as in a log line. At least one of the two vectors is non-empty. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapabilityAudit { - /// Families the driver advertises but does not expose. These will fail on - /// first call; refuse the bind. - pub advertised_but_absent: Vec, - /// Families the driver exposes but does not advertise. These are - /// unreachable, because the kernel filters from the advertised set. - pub present_but_unadvertised: Vec, -} - -impl fmt::Display for CapabilityAudit { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut parts = Vec::new(); - if !self.advertised_but_absent.is_empty() { - parts.push(format!( - "advertised but not implemented: {}", - join(&self.advertised_but_absent) - )); - } - if !self.present_but_unadvertised.is_empty() { - parts.push(format!( - "implemented but not advertised: {}", - join(&self.present_but_unadvertised) - )); - } - write!(f, "memory driver capability mismatch; {}", parts.join("; ")) - } -} - -impl std::error::Error for CapabilityAudit {} - -impl From for MemoryError { - /// A mismatch is the driver saying something untrue about itself, which is - /// a configuration/implementation error rather than an unsupported call — - /// hence [`MemoryError::Invalid`] and not - /// [`MemoryError::Unsupported`]. Same reasoning as - /// [`crate::capabilities::MissingMandatoryCapabilities`]. - fn from(value: CapabilityAudit) -> Self { - MemoryError::Invalid(value.to_string()) - } -} - -fn join(families: &[Capability]) -> String { - families - .iter() - .map(|cap| cap.as_str()) - .collect::>() - .join(", ") -} - -/// Compare a driver's advertised capability set against its reachable surface. -/// -/// Walks every [`Capability`] in declaration order, so the returned vectors are -/// in that order too. -/// -/// # Errors -/// -/// Returns [`CapabilityAudit`] when the two disagree in either direction. A -/// driver that agrees with itself returns `Ok(())`. -/// -/// # Examples -/// -/// ``` -/// # use tinycortex_api::null::NullMemoryProvider; -/// # use tinycortex_api::provider::audit_provider; -/// // The reference null driver is self-consistent. -/// assert!(audit_provider(&NullMemoryProvider::new()).is_ok()); -/// ``` -pub fn audit_provider(provider: &dyn MemoryProvider) -> Result<(), CapabilityAudit> { - let advertised = provider.capabilities(); - let mut advertised_but_absent = Vec::new(); - let mut present_but_unadvertised = Vec::new(); - - for capability in Capability::ALL { - match ( - advertised.contains(capability), - provider.provides(capability), - ) { - (true, false) => advertised_but_absent.push(capability), - (false, true) => present_but_unadvertised.push(capability), - _ => {} - } - } - - if advertised_but_absent.is_empty() && present_but_unadvertised.is_empty() { - Ok(()) - } else { - Err(CapabilityAudit { - advertised_but_absent, - present_but_unadvertised, - }) - } -} - -#[cfg(test)] -#[path = "audit_tests.rs"] -mod tests; diff --git a/api/src/provider/audit_tests.rs b/api/src/provider/audit_tests.rs deleted file mode 100644 index 07ec80e2..00000000 --- a/api/src/provider/audit_tests.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Tests for the advertised-vs-implemented honesty check. -//! -//! Two deliberately dishonest fixtures sit here — one that over-claims and one -//! that under-claims — because the whole value of [`audit_provider`] is -//! catching drivers that disagree with themselves, and neither direction is -//! reachable from an honest driver. - -use async_trait::async_trait; - -use super::*; -use crate::capabilities::Capabilities; -use crate::health::MemoryHealth; -use crate::null::NullMemoryProvider; -use crate::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; -use crate::provider::{MemoryCore, MemoryPortability, MemoryRecall, MemoryTree}; -use crate::recall::OwnedRecallOpts; -use crate::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; - -/// A provider that forwards the mandatory three to [`NullMemoryProvider`] so -/// each fixture below only has to describe the thing it is lying about. -struct Fixture { - inner: NullMemoryProvider, - advertised: Capabilities, - expose_tree: bool, -} - -impl Fixture { - fn new(advertised: Capabilities, expose_tree: bool) -> Self { - Self { - inner: NullMemoryProvider::new(), - advertised, - expose_tree, - } - } -} - -#[async_trait] -impl MemoryCore for Fixture { - async fn store( - &self, - namespace: &str, - key: &str, - content: &str, - category: MemoryCategory, - session_id: Option<&str>, - taint: MemoryTaint, - ) -> Result<(), MemoryError> { - self.inner - .store(namespace, key, content, category, session_id, taint) - .await - } - - async fn get(&self, namespace: &str, key: &str) -> Result, MemoryError> { - self.inner.get(namespace, key).await - } - - async fn forget(&self, namespace: &str, key: &str) -> Result { - self.inner.forget(namespace, key).await - } - - async fn list( - &self, - namespace: Option<&str>, - category: Option<&MemoryCategory>, - session_id: Option<&str>, - ) -> Result, MemoryError> { - self.inner.list(namespace, category, session_id).await - } - - async fn namespaces(&self) -> Result, MemoryError> { - self.inner.namespaces().await - } -} - -#[async_trait] -impl MemoryRecall for Fixture { - async fn recall( - &self, - query: &str, - limit: usize, - opts: &OwnedRecallOpts, - scope: Option<&SourceScope>, - ) -> Result, MemoryError> { - self.inner.recall(query, limit, opts, scope).await - } -} - -#[async_trait] -impl MemoryPortability for Fixture { - async fn export_page( - &self, - cursor: Option<&str>, - limit: usize, - ) -> Result { - self.inner.export_page(cursor, limit).await - } - - async fn import_records( - &self, - records: Vec, - ) -> Result { - self.inner.import_records(records).await - } -} - -#[async_trait] -impl MemoryProvider for Fixture { - fn driver_id(&self) -> &str { - "fixture" - } - - fn capabilities(&self) -> Capabilities { - self.advertised - } - - async fn health(&self) -> MemoryHealth { - MemoryHealth::Ready - } - - fn as_tree(&self) -> Option<&dyn MemoryTree> { - if self.expose_tree { - Some(&self.inner) - } else { - None - } - } -} - -#[test] -fn honest_driver_passes_the_audit() { - let honest = Fixture::new(Capabilities::mandatory().with(Capability::Tree), true); - assert_eq!(audit_provider(&honest), Ok(())); -} - -#[test] -fn over_claiming_driver_is_reported_as_advertised_but_absent() { - // Advertises everything, exposes no optional accessor. Every one of the ten - // optional families would fail on first call — the exact - // registered-but-failing outcome the capability filter exists to prevent. - let liar = Fixture::new(Capabilities::all(), false); - - let audit = audit_provider(&liar).expect_err("over-claiming driver must fail the audit"); - assert_eq!(audit.present_but_unadvertised, Vec::new()); - assert_eq!(audit.advertised_but_absent.len(), 10); - assert!(audit.advertised_but_absent.contains(&Capability::Tree)); - // The mandatory three are supertraits, so they can never be missing. - assert!(!audit.advertised_but_absent.contains(&Capability::Core)); - assert!(!audit.advertised_but_absent.contains(&Capability::Recall)); - assert!(!audit - .advertised_but_absent - .contains(&Capability::Portability)); -} - -#[test] -fn under_claiming_driver_is_reported_as_present_but_unadvertised() { - // Implements the tree but forgot to list it: the family works and is - // completely unreachable, because the kernel filters from the advertised - // set. - let shy = Fixture::new(Capabilities::mandatory(), true); - - let audit = audit_provider(­).expect_err("under-claiming driver must fail the audit"); - assert_eq!(audit.advertised_but_absent, Vec::new()); - assert_eq!(audit.present_but_unadvertised, vec![Capability::Tree]); -} - -#[test] -fn audit_findings_are_reported_in_declaration_order() { - let liar = Fixture::new(Capabilities::all(), false); - let audit = audit_provider(&liar).expect_err("expected a mismatch"); - - let declaration_order: Vec = Capability::ALL - .into_iter() - .filter(|cap| audit.advertised_but_absent.contains(cap)) - .collect(); - assert_eq!(audit.advertised_but_absent, declaration_order); -} - -#[test] -fn audit_error_names_every_mismatched_family_and_maps_to_invalid() { - let liar = Fixture::new(Capabilities::all(), false); - let audit = audit_provider(&liar).expect_err("expected a mismatch"); - - let rendered = audit.to_string(); - for capability in &audit.advertised_but_absent { - assert!( - rendered.contains(capability.as_str()), - "audit message must name {capability}: {rendered}" - ); - } - - // A driver lying about itself is a config/implementation error, not an - // unsupported call. - let error: MemoryError = audit.into(); - assert!(matches!(error, MemoryError::Invalid(_))); -} diff --git a/api/src/provider/content.rs b/api/src/provider/content.rs deleted file mode 100644 index 9c16e860..00000000 --- a/api/src/provider/content.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Optional families that put content *into* memory and navigate it: -//! [`MemoryIngest`], [`MemoryDocuments`], and [`MemoryTree`]. -//! -//! All three are optional. A driver that advertises none of them is still a -//! memory backend — it just accepts entries only through -//! [`crate::provider::MemoryCore::store`] and has no document tier and no -//! summary tree. The kernel unregisters the matching RPC methods and omits the -//! matching agent tools rather than registering handlers that fail. -//! -//! ## No configuration crosses this boundary -//! -//! Chunk sizes, embedding models, summariser prompts, seal thresholds, and -//! cascade policy are all *driver* concerns. None of them appear in these -//! signatures: the embedded driver reads them from the `MemoryConfig` it -//! already holds, and an external driver has its own. This was the sharpest -//! test of whether the M0 crate carve-out drew the line in the right place — -//! the families that looked most config-dependent turned out not to need any. - -use async_trait::async_trait; - -use crate::chunks::Chunk; -use crate::error::MemoryError; -use crate::provider::types::{IngestItem, IngestOutcome, SourceScope}; -use crate::tree::{IngestRequest, QueryResult, TreeStatus}; -use crate::types::{NamespaceDocumentInput, NamespaceRetrievalContext, StoredMemoryDocument}; - -/// Bulk content ingestion — the driver owns chunking and embedding. -/// -/// The distinction from [`crate::provider::MemoryCore::store`] is ownership of -/// the pipeline: `store` persists exactly one entry the caller has already -/// shaped, whereas ingest hands over raw source material and lets the driver -/// decide how to split, embed, and index it. -#[async_trait] -pub trait MemoryIngest: Send + Sync { - /// Ingest one standalone document. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for content the driver refuses (empty body, - /// unsupported MIME), otherwise backend failures. - async fn ingest_document(&self, item: IngestItem) -> Result; - - /// Ingest a run of chat messages that share a conversation. - /// - /// Taken as a batch rather than one call per message because chat chunking - /// is inherently cross-message: a driver needs neighbouring turns to decide - /// where a chunk boundary belongs. Ordering within `messages` is - /// significant and must be preserved by the caller. - /// - /// # Errors - /// - /// As [`Self::ingest_document`]. Partial success is reported through the - /// counts in [`IngestOutcome`], not as an error. - async fn ingest_chat(&self, messages: Vec) -> Result; -} - -/// The namespace-document tier: whole documents addressed by `(namespace, key)`. -/// -/// Distinct from [`crate::provider::MemoryCore`] in granularity and in what is -/// stored: entries are short facts, documents are bodies with titles, tags, -/// source types, and structured metadata, and they carry their own ranked query -/// surface. -#[async_trait] -pub trait MemoryDocuments: Send + Sync { - /// Upsert a document, returning its driver-assigned id. - /// - /// Keyed by `(namespace, key)` from the input: reusing a key replaces the - /// existing document rather than creating a second one. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a rejected input, otherwise backend - /// failures. - async fn put_document(&self, input: NamespaceDocumentInput) -> Result; - - /// Fetch a document by `(namespace, key)`. - /// - /// # Errors - /// - /// A missing document is `Ok(None)`; `Err` is reserved for backend - /// failures. - async fn get_document( - &self, - namespace: &str, - key: &str, - ) -> Result, MemoryError>; - - /// Run a ranked query over one namespace's documents. - /// - /// Returns both the ranked hits and the driver's rendered context text, so - /// a caller that only wants something injectable does not have to - /// re-assemble it (and re-assemble it differently from every other caller). - /// - /// # Errors - /// - /// Backend failures only; a query that matches nothing returns an empty - /// hit list. - async fn query_documents( - &self, - namespace: &str, - query: &str, - limit: usize, - ) -> Result; -} - -/// The time-ordered summary tree: buffered leaves rolled up into hour → day → -/// month → year → root summaries. -/// -/// Sealing and cascading are exposed as explicit calls rather than happening -/// implicitly on ingest because the **host** owns scheduling. A driver runs one -/// step when asked; it does not get to install its own background loop. This is -/// the same rule as the engine's `queue::run_once`. -#[async_trait] -pub trait MemoryTree: Send + Sync { - /// Append raw content to the ingestion buffer for later sealing. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a rejected request, otherwise backend - /// failures. - async fn append(&self, request: IngestRequest) -> Result<(), MemoryError>; - - /// Retrieve the chunks a single logical source contributed, newest first. - /// - /// `scope` is the per-turn allowlist and must be applied **inside** the - /// driver's query, for the reasons in [`SourceScope`]. `None` means - /// unrestricted. - /// - /// # Errors - /// - /// Backend failures only; an unknown `source_id` yields an empty vector. - async fn query_source( - &self, - namespace: &str, - source_id: &str, - limit: usize, - scope: Option<&SourceScope>, - ) -> Result, MemoryError>; - - /// Fetch one node together with its direct children, for navigation. - /// - /// # Errors - /// - /// [`MemoryError::NotFound`] when `node_id` does not exist in `namespace`. - async fn drill_down(&self, namespace: &str, node_id: &str) -> Result; - - /// Convert buffered content into leaf nodes, returning the resulting tree - /// state. - /// - /// Idempotent when the buffer is empty: sealing nothing is a successful - /// no-op, not an error, so a scheduler may call it unconditionally. - /// - /// # Errors - /// - /// Backend failures only. - async fn seal(&self, namespace: &str) -> Result; - - /// Roll sealed leaves up through the parent levels, returning the resulting - /// tree state. - /// - /// Idempotent for the same reason as [`Self::seal`]. - /// - /// # Errors - /// - /// Backend failures only. - async fn cascade(&self, namespace: &str) -> Result; -} diff --git a/api/src/provider/driver.rs b/api/src/provider/driver.rs deleted file mode 100644 index 842ce38d..00000000 --- a/api/src/provider/driver.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! [`MemoryProvider`] — the single trait a memory driver implements, and the -//! object the kernel binds. -//! -//! ## Self-contained on purpose -//! -//! `MemoryProvider` does **not** extend a host `Driver` trait and names no host -//! type. `tinycortex-api` is what a third-party driver compiles against, so it -//! must not drag in the OpenHuman host; and the generic subsystem vocabulary -//! (`Driver`, `DriverClass`, `SubsystemRegistry`, the policy `Guard`) belongs -//! kernel-side, where inference and channels can share it without importing a -//! *memory* crate. -//! -//! The bridge is the host's memory adapter, which implements the host `Driver` -//! for an `Arc` and converts [`MemoryHealth`] into the -//! kernel's `DriverHealth`. That conversion is trivial by construction — see -//! [`crate::health`]. -//! -//! Driver **class** (embedded / external / null) is deliberately absent from -//! this trait. Class is a fact about how the host bound a driver, recorded in -//! host configuration; a driver self-reporting it would let a misconfigured -//! external backend claim to be embedded and skip the egress and trust checks -//! that class gates. -//! -//! ## The accessor form, and why not `Any` -//! -//! The kernel binds `Arc` and needs per-family access. Two -//! designs were available: downcast through [`std::any::Any`], or one -//! `Option`-returning accessor per optional family. The accessors win: -//! -//! - **No unchecked downcast.** `Any` would require the caller to name a -//! concrete driver type, which defeats the point of binding behind a trait -//! object, or to register type ids, which is the same table with worse -//! ergonomics. -//! - **The capability set and the reachable surface stay provably in sync.** -//! [`crate::provider::audit_provider`] compares [`MemoryProvider::capabilities`] -//! against what the accessors actually return, so "advertised but not -//! implemented" is a detectable, testable mistake instead of a runtime -//! surprise on the first call. -//! - **[`MemoryProvider::provides`] is an exhaustive `match`** over -//! [`Capability`], so adding a family without wiring an accessor fails to -//! compile. -//! -//! The three mandatory families are supertraits rather than accessors, so they -//! are callable directly on the trait object and cannot be absent. -//! -//! ## Object safety -//! -//! Every method here and in every family trait is object-safe: no generic -//! parameters, no `Self` in return position, no associated constants. The -//! `#[async_trait]` attribute rewrites the `async fn`s into boxed futures, -//! which is what makes them dyn-compatible at all. - -use async_trait::async_trait; - -use crate::capabilities::{Capabilities, Capability}; -use crate::error::MemoryError; -use crate::health::MemoryHealth; -use crate::provider::content::{MemoryDocuments, MemoryIngest, MemoryTree}; -use crate::provider::knowledge::{MemoryDiff, MemoryEntities, MemoryGraph}; -use crate::provider::mandatory::{MemoryCore, MemoryPortability, MemoryRecall}; -use crate::provider::records::{ - MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory, -}; - -/// A bound memory driver. -/// -/// Implementors must also implement the three mandatory families -/// ([`MemoryCore`], [`MemoryRecall`], [`MemoryPortability`]) — they are -/// supertraits, so a driver missing any of them cannot be constructed as a -/// provider at all. -/// -/// The ten optional families are reached through the `as_*` accessors below. -/// Each defaults to `None`, so a minimal driver implements only what it -/// supports and inherits correct absence for everything else. -#[async_trait] -pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'static { - /// Stable identifier for this driver (`tinycortex`, `supermemory`, `null`). - /// - /// Appears in status output, log lines, tracing spans, and audit events, so - /// it must be stable across restarts and must not embed a URL, a token, or - /// anything else user- or deployment-specific. - fn driver_id(&self) -> &str; - - /// The families this driver implements. - /// - /// Asked **once** at bind time and cached: the kernel filters RPC - /// registration and agent-tool assembly from the cached answer, so a set - /// that changes after binding will not be noticed. A driver whose surface - /// genuinely varies must report the union and answer - /// [`MemoryError::Unsupported`] for the gaps. - /// - /// Must be honest: every advertised family must be reachable through its - /// accessor. [`crate::provider::audit_provider`] checks exactly that. - fn capabilities(&self) -> Capabilities; - - /// Current liveness, as the driver reports it. - /// - /// Called on bind and on demand for status output. Implementations should - /// be cheap and must not block indefinitely — a health probe that hangs is - /// indistinguishable from a subsystem that is down, but takes a timeout to - /// find out. - async fn health(&self) -> MemoryHealth; - - /// Release resources ahead of process exit or a rebind. - /// - /// Defaults to a successful no-op, because most drivers have nothing to - /// release; a driver holding a connection pool or a background task should - /// override it. The host's adapter forwards its `Driver::shutdown` here. - /// - /// Must be idempotent: a rebind followed by process exit calls it twice. - /// - /// # Errors - /// - /// Backend failures during teardown. The caller logs and continues — - /// shutdown failure never blocks exit. - async fn shutdown(&self) -> Result<(), MemoryError> { - Ok(()) - } - - /// Bulk ingestion, when advertised. - fn as_ingest(&self) -> Option<&dyn MemoryIngest> { - None - } - - /// The namespace-document tier, when advertised. - fn as_documents(&self) -> Option<&dyn MemoryDocuments> { - None - } - - /// The summary tree, when advertised. - fn as_tree(&self) -> Option<&dyn MemoryTree> { - None - } - - /// The entity index, when advertised. - fn as_entities(&self) -> Option<&dyn MemoryEntities> { - None - } - - /// The key/value and relation graph, when advertised. - fn as_graph(&self) -> Option<&dyn MemoryGraph> { - None - } - - /// Snapshot and change tracking, when advertised. - fn as_diff(&self) -> Option<&dyn MemoryDiff> { - None - } - - /// The long-term goals document, when advertised. - fn as_goals(&self) -> Option<&dyn MemoryGoals> { - None - } - - /// Per-tool learned rules, when advertised. - fn as_tool_memory(&self) -> Option<&dyn MemoryToolMemory> { - None - } - - /// The host-sync write seam, when advertised. - fn as_sources(&self) -> Option<&dyn MemorySourceSink> { - None - } - - /// Scheduler-driven upkeep, when advertised. - fn as_maintenance(&self) -> Option<&dyn MemoryMaintenance> { - None - } - - /// Whether `capability` is actually **reachable** on this driver. - /// - /// This is the implementation-side truth, as opposed to - /// [`Self::capabilities`], which is the advertised claim. The two should - /// agree; [`crate::provider::audit_provider`] is where they are compared. - /// - /// The mandatory three are always `true` because they are supertraits. The - /// remaining ten delegate to their accessor. - /// - /// The `match` is deliberately exhaustive: [`Capability`] is not - /// `#[non_exhaustive]`, so adding a family without adding an accessor and - /// an arm here is a compile error rather than a silent `false`. - fn provides(&self, capability: Capability) -> bool { - match capability { - Capability::Core | Capability::Recall | Capability::Portability => true, - Capability::Ingest => self.as_ingest().is_some(), - Capability::Documents => self.as_documents().is_some(), - Capability::Tree => self.as_tree().is_some(), - Capability::Entities => self.as_entities().is_some(), - Capability::Graph => self.as_graph().is_some(), - Capability::Diff => self.as_diff().is_some(), - Capability::Goals => self.as_goals().is_some(), - Capability::ToolMemory => self.as_tool_memory().is_some(), - Capability::Sources => self.as_sources().is_some(), - Capability::Maintenance => self.as_maintenance().is_some(), - } - } -} diff --git a/api/src/provider/knowledge.rs b/api/src/provider/knowledge.rs deleted file mode 100644 index 45140e58..00000000 --- a/api/src/provider/knowledge.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Optional families that expose *derived structure* over stored memory: -//! [`MemoryEntities`], [`MemoryGraph`], and [`MemoryDiff`]. -//! -//! Each is independently optional. A driver may have a key/value graph but no -//! entity index, or track source snapshots without either. The kernel filters -//! RPC registration and agent-tool assembly per family, so an absent family is -//! invisible rather than present-and-failing. -//! -//! As in [`crate::provider::content`], no configuration crosses this boundary: -//! extraction models, hotness decay curves, and snapshot retention are driver -//! concerns and appear in none of these signatures. - -use async_trait::async_trait; - -use crate::error::MemoryError; -use crate::provider::types::{DiffReport, EntityHit, SnapshotRef}; -use crate::types::{GraphRelationRecord, MemoryKvRecord}; - -/// The entity index: who and what the stored memory is about. -#[async_trait] -pub trait MemoryEntities: Send + Sync { - /// List entities in a namespace, ranked by hotness when `query` is `None` - /// and by match quality otherwise. - /// - /// # Errors - /// - /// Backend failures only; an unknown namespace yields an empty vector. - async fn entities( - &self, - namespace: &str, - query: Option<&str>, - limit: usize, - ) -> Result, MemoryError>; - - /// Edges incident to one entity, most relevant first. - /// - /// Returns [`GraphRelationRecord`] — the same shape [`MemoryGraph`] uses — - /// so a caller that has both families does not have to reconcile two edge - /// representations. - /// - /// # Errors - /// - /// Backend failures only; an unknown `entity_id` yields an empty vector - /// rather than [`MemoryError::NotFound`], because "no edges" and "no such - /// entity" are the same answer to this question. - async fn entity_edges( - &self, - namespace: &str, - entity_id: &str, - limit: usize, - ) -> Result, MemoryError>; - - /// Record that these entities were just observed, updating hotness. - /// - /// Separate from the read path because hotness is a *write* the host - /// triggers at known moments (a turn referenced these entities), not - /// something a driver should infer from being queried — otherwise merely - /// browsing the index would reshape ranking. - /// - /// # Errors - /// - /// Backend failures only. Unknown ids are ignored, not rejected. - async fn touch_entities( - &self, - namespace: &str, - entity_ids: &[String], - ) -> Result<(), MemoryError>; -} - -/// The key/value and relation graph tier. -/// -/// `namespace` is `Option<&str>` throughout: `None` addresses the global, -/// namespace-less slice, matching the storage shape of -/// [`MemoryKvRecord::namespace`] and [`GraphRelationRecord::namespace`]. -#[async_trait] -pub trait MemoryGraph: Send + Sync { - /// Read one key/value record. - /// - /// # Errors - /// - /// A missing key is `Ok(None)`; `Err` is reserved for backend failures. - async fn kv_get( - &self, - namespace: Option<&str>, - key: &str, - ) -> Result, MemoryError>; - - /// Upsert one key/value record. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a rejected key, otherwise backend failures. - async fn kv_put( - &self, - namespace: Option<&str>, - key: &str, - value: serde_json::Value, - ) -> Result<(), MemoryError>; - - /// List key/value records, optionally restricted to a key prefix. - /// - /// # Errors - /// - /// Backend failures only. - async fn kv_list( - &self, - namespace: Option<&str>, - prefix: Option<&str>, - limit: usize, - ) -> Result, MemoryError>; - - /// Query relations, narrowing by subject and/or predicate. - /// - /// Both filters are `None`-able so one method covers "everything about this - /// subject", "every edge of this type", and "the whole slice", instead of - /// three near-identical methods. - /// - /// # Errors - /// - /// Backend failures only. - async fn relations( - &self, - namespace: Option<&str>, - subject: Option<&str>, - predicate: Option<&str>, - limit: usize, - ) -> Result, MemoryError>; - - /// Upsert one relation, keyed by `(namespace, subject, predicate, object)`. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a malformed edge, otherwise backend - /// failures. - async fn put_relation(&self, relation: GraphRelationRecord) -> Result<(), MemoryError>; -} - -/// Snapshot capture and change computation over synced sources. -#[async_trait] -pub trait MemoryDiff: Send + Sync { - /// Capture a snapshot of one source's current items. - /// - /// # Errors - /// - /// [`MemoryError::NotFound`] for an unknown `source_id`, otherwise backend - /// failures. - async fn capture_snapshot(&self, source_id: &str) -> Result; - - /// List snapshots for one source, newest first. - /// - /// # Errors - /// - /// Backend failures only; an unknown `source_id` yields an empty vector. - async fn snapshots( - &self, - source_id: &str, - limit: usize, - ) -> Result, MemoryError>; - - /// Compute the change set between two snapshots of one source. - /// - /// `from` is `Option<&str>` so the first-ever diff — where there is no - /// baseline and every item is an addition — is expressible without a - /// separate method or a sentinel id. - /// - /// # Errors - /// - /// [`MemoryError::NotFound`] when either snapshot id is unknown, otherwise - /// backend failures. - async fn diff( - &self, - source_id: &str, - from: Option<&str>, - to: &str, - ) -> Result; -} diff --git a/api/src/provider/mandatory.rs b/api/src/provider/mandatory.rs deleted file mode 100644 index 48278488..00000000 --- a/api/src/provider/mandatory.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! The three mandatory capability families: [`MemoryCore`], [`MemoryRecall`], -//! and [`MemoryPortability`]. -//! -//! These are supertraits of [`crate::provider::MemoryProvider`], which is what -//! makes "mandatory" a *compile-time* fact rather than a runtime check: a type -//! that does not implement all three cannot be a provider at all, so there is -//! no way to bind a driver that is missing them. -//! -//! The other ten families are reached through `Option`-returning accessors on -//! the provider, so their absence is representable and their presence is not -//! assumed. See [`crate::provider::MemoryProvider`] for that half. -//! -//! ## Why every method returns [`MemoryError`] and not `anyhow::Error` -//! -//! The transport adapter must be able to turn a `501` from an out-of-process -//! driver into [`MemoryError::Unsupported`], and the kernel must be able to -//! tell "this driver cannot do that" apart from "this driver failed". An -//! `anyhow::Error` erases exactly that distinction. The engine's own -//! [`crate::traits::Memory`] trait keeps `anyhow::Result` — it is an internal -//! storage abstraction with existing implementors, not the driver contract. - -use async_trait::async_trait; - -use crate::error::MemoryError; -use crate::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; -use crate::recall::OwnedRecallOpts; -use crate::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; - -/// Store, read, and delete individual memory entries. **Mandatory.** -/// -/// This is the smallest surface that still makes something a memory backend: -/// without it there is nothing to recall from and nothing to export. -#[async_trait] -pub trait MemoryCore: Send + Sync { - /// Upsert an entry, keyed by `(namespace, key)`. - /// - /// ## Taint is an argument, never a decision - /// - /// Unlike the engine's [`crate::traits::Memory`], which has a `store` and a - /// separate `store_with_taint` whose default implementation silently drops - /// the taint, the contract has **one** store and it always takes a - /// [`MemoryTaint`]. Provenance is stamped by the host policy guard before - /// the call; a driver that could default it would be able to launder - /// externally-sourced content into internal-trust content, which is the - /// single failure mode the guard exists to prevent. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for caller input the driver rejects, - /// [`MemoryError::Io`] or [`MemoryError::Other`] for backend failures. - async fn store( - &self, - namespace: &str, - key: &str, - content: &str, - category: MemoryCategory, - session_id: Option<&str>, - taint: MemoryTaint, - ) -> Result<(), MemoryError>; - - /// Fetch the entry for an exact `(namespace, key)`. - /// - /// # Errors - /// - /// A missing entry is `Ok(None)`, never an error; `Err` is reserved for - /// backend failures. - async fn get(&self, namespace: &str, key: &str) -> Result, MemoryError>; - - /// Delete the entry for `(namespace, key)`, reporting whether it existed. - /// - /// Idempotent: forgetting an absent key is `Ok(false)`, so callers may call - /// it unconditionally. - /// - /// # Errors - /// - /// Backend failures only. - async fn forget(&self, namespace: &str, key: &str) -> Result; - - /// List entries, narrowing by namespace, category, and session. - /// - /// Each `Some` filter narrows the result; all `None` lists everything the - /// driver holds. An empty result is `Ok(vec![])`. - /// - /// # Errors - /// - /// Backend failures only. - async fn list( - &self, - namespace: Option<&str>, - category: Option<&MemoryCategory>, - session_id: Option<&str>, - ) -> Result, MemoryError>; - - /// Enumerate namespaces with their aggregate counts, for discovery. - /// - /// # Errors - /// - /// Backend failures only. - async fn namespaces(&self) -> Result, MemoryError>; -} - -/// Ranked retrieval. **Mandatory.** -#[async_trait] -pub trait MemoryRecall: Send + Sync { - /// Return up to `limit` entries relevant to `query`, most relevant first. - /// - /// `opts` is the **owned** [`OwnedRecallOpts`], never the borrowed - /// `RecallOpts<'a>`: a lifetime parameter cannot travel through an - /// object-safe `#[async_trait]` method, and the borrowed form derives no - /// serde impls so it could never be a request body. An embedded driver - /// converts to the borrowed form at its own boundary, which is zero-copy. - /// - /// `scope` is the per-turn source allowlist and is a **query predicate the - /// driver must apply internally** — see [`SourceScope`] for why applying it - /// after the fact is wrong. `None` means unrestricted. - /// - /// An empty or non-matching `query` yields `Ok(vec![])`, not an error. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a malformed filter, otherwise backend - /// failures. - async fn recall( - &self, - query: &str, - limit: usize, - opts: &OwnedRecallOpts, - scope: Option<&SourceScope>, - ) -> Result, MemoryError>; -} - -/// Export and import the whole store. **Mandatory.** -/// -/// Mandatory because binding a memory backend without it is a one-way door: a -/// user who cannot export cannot leave. It is the capability that makes every -/// other binding reversible, which is also why the `mirror` migration driver is -/// expressible at all. -#[async_trait] -pub trait MemoryPortability: Send + Sync { - /// Read one page of the export, continuing from `cursor`. - /// - /// Pass `None` to start. The export is complete when the returned - /// [`ExportPage::next_cursor`] is `None` — an empty `records` vector is - /// **not** a terminator, because a driver may legitimately return an empty - /// page while skipping a range. - /// - /// `limit` is a request, not a guarantee; a driver may return fewer. - /// - /// ## Why pages and not a stream - /// - /// A `Stream` return type would either make the trait non-object-safe or - /// drag an async runtime into a crate that deliberately has none. Paging - /// keeps both properties and still bounds memory, with the caller choosing - /// the bound. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a cursor this driver did not issue, - /// otherwise backend failures. - async fn export_page( - &self, - cursor: Option<&str>, - limit: usize, - ) -> Result; - - /// Write a batch of previously-exported records. - /// - /// Records carry their own [`crate::types::MemoryTaint`]; an importing - /// driver must persist what it is given and must not re-stamp provenance. - /// - /// Partial success is normal and is reported in [`ImportOutcome`] rather - /// than as an error: a migration should not abort a million-record restore - /// because one record was malformed. - /// - /// # Errors - /// - /// Reserved for failures that make the whole batch meaningless (backend - /// unavailable, transaction aborted). Per-record rejection belongs in - /// [`ImportOutcome::failed`]. - async fn import_records( - &self, - records: Vec, - ) -> Result; -} diff --git a/api/src/provider/mod.rs b/api/src/provider/mod.rs deleted file mode 100644 index 5fe65c98..00000000 --- a/api/src/provider/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! The memory driver contract: [`MemoryProvider`] plus the thirteen capability -//! family traits a driver may implement. -//! -//! ## Shape -//! -//! ```text -//! MemoryProvider ── identity, capabilities, health, shutdown -//! : MemoryCore (mandatory — supertrait, always callable) -//! : MemoryRecall (mandatory — supertrait, always callable) -//! : MemoryPortability (mandatory — supertrait, always callable) -//! ├─ as_ingest() -> Option<&dyn MemoryIngest> -//! ├─ as_documents() -> Option<&dyn MemoryDocuments> -//! ├─ as_tree() -> Option<&dyn MemoryTree> -//! ├─ as_entities() -> Option<&dyn MemoryEntities> -//! ├─ as_graph() -> Option<&dyn MemoryGraph> -//! ├─ as_diff() -> Option<&dyn MemoryDiff> -//! ├─ as_goals() -> Option<&dyn MemoryGoals> -//! ├─ as_tool_memory() -> Option<&dyn MemoryToolMemory> -//! ├─ as_sources() -> Option<&dyn MemorySourceSink> -//! └─ as_maintenance() -> Option<&dyn MemoryMaintenance> -//! ``` -//! -//! The mandatory three are supertraits, so "mandatory" is enforced by the type -//! system rather than by a runtime check. The optional ten are accessors that -//! default to `None`, so absence is the default and presence is opt-in. -//! -//! ## Rules that bind every family -//! -//! 1. **Typed errors, always.** Every method returns -//! `Result<_, MemoryError>`. The transport adapter maps an out-of-process -//! `501` onto [`crate::error::MemoryError::Unsupported`], and the kernel -//! distinguishes "cannot" from "failed". `anyhow::Error` would erase that. -//! 2. **No configuration crosses the boundary.** Not one signature names a -//! config type. A driver holds its own configuration; the contract passes -//! domain arguments only. -//! 3. **No host types.** Nothing here names an OpenHuman type, so a -//! third-party driver depends on this crate alone. -//! 4. **The driver never assigns provenance.** [`crate::types::MemoryTaint`] is -//! an argument on every write path and a preserved field on every import. -//! 5. **The host owns the loop.** Sealing, cascading, maintenance, and source -//! sync are all "run one step when asked"; no driver installs a background -//! task or hooks the agent turn. -//! 6. **Object safety throughout.** No generics, no `Self` returns, no -//! associated constants — every family is usable as `&dyn`. -//! -//! ## Reference implementation -//! -//! [`crate::null::NullMemoryProvider`] implements all thirteen families: -//! `/dev/null` semantics for the mandatory three, and -//! [`crate::error::MemoryError::Unsupported`] for the other ten, which it does -//! not advertise. It is what a compiled-out or unconfigured memory subsystem -//! binds to, and it doubles as the proof that the mandatory set is -//! implementable without a storage engine. - -pub mod audit; -pub mod content; -pub mod driver; -pub mod knowledge; -pub mod mandatory; -pub mod records; -pub mod types; - -pub use audit::{audit_provider, CapabilityAudit}; -pub use content::{MemoryDocuments, MemoryIngest, MemoryTree}; -pub use driver::MemoryProvider; -pub use knowledge::{MemoryDiff, MemoryEntities, MemoryGraph}; -pub use mandatory::{MemoryCore, MemoryPortability, MemoryRecall}; -pub use records::{MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory}; -pub use types::{ - ChangeKind, DiffReport, EntityHit, EntityRef, ExportPage, ExportRecord, ImportOutcome, - IngestItem, IngestOutcome, MaintenanceReport, SnapshotRef, SourceChange, SourceItem, - SourceScope, -}; diff --git a/api/src/provider/records.rs b/api/src/provider/records.rs deleted file mode 100644 index 064697fc..00000000 --- a/api/src/provider/records.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! The remaining optional families: [`MemoryGoals`], [`MemoryToolMemory`], -//! [`MemorySourceSink`], and [`MemoryMaintenance`]. -//! -//! Goals and tool memory are small curated record sets the agent reads on -//! nearly every turn. The source sink is the seam the host's sync machinery -//! writes through. Maintenance is the seam the host's scheduler drives. -//! -//! ## The host keeps the loop; the driver runs one step -//! -//! [`MemorySourceSink`] receives already-fetched items — the host owns -//! credentials, OAuth, rate limits, and the schedule. [`MemoryMaintenance`] -//! exposes four operations the host's existing scheduler calls; no driver -//! installs a background task of its own. Both follow the same rule as the -//! engine's `queue::run_once`, and both are why a driver never needs to see -//! configuration or a keychain. - -use async_trait::async_trait; - -use crate::error::MemoryError; -use crate::goals::GoalsDoc; -use crate::provider::types::{IngestOutcome, MaintenanceReport, SourceItem}; -use crate::tool_memory::ToolMemoryRule; -use crate::types::MemoryTaint; - -/// The agent's long-term goals document. -#[async_trait] -pub trait MemoryGoals: Send + Sync { - /// Read the current goals document. - /// - /// A driver with no goals yet returns an empty [`GoalsDoc`], not - /// [`MemoryError::NotFound`] — "no goals" is a valid state, not a missing - /// record. - /// - /// # Errors - /// - /// Backend failures only. - async fn goals(&self) -> Result; - - /// Replace the goals document wholesale. - /// - /// Whole-document replacement rather than per-item add/edit/delete because - /// the validating mutation surface (PII and secret predicates) is **host** - /// policy: the host parses, validates, mutates, and hands back the result. - /// Exposing per-item mutation here would put that policy behind a trait a - /// third-party driver implements, where it could be skipped. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a document the driver refuses (e.g. over - /// its own item cap), otherwise backend failures. - async fn set_goals(&self, goals: GoalsDoc) -> Result<(), MemoryError>; -} - -/// Per-tool learned rules — durable guidance attached to a specific tool. -#[async_trait] -pub trait MemoryToolMemory: Send + Sync { - /// Rules for one tool, highest priority first. - /// - /// # Errors - /// - /// Backend failures only; a tool with no rules yields an empty vector. - async fn tool_rules(&self, tool_name: &str) -> Result, MemoryError>; - - /// Upsert one rule, keyed by [`ToolMemoryRule::id`]. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a malformed rule, otherwise backend - /// failures. - async fn put_tool_rule(&self, rule: ToolMemoryRule) -> Result<(), MemoryError>; - - /// Delete one rule, reporting whether it existed. - /// - /// Idempotent, like [`crate::provider::MemoryCore::forget`]. - /// - /// # Errors - /// - /// Backend failures only. - async fn delete_tool_rule(&self, tool_name: &str, rule_id: &str) -> Result; -} - -/// The write seam for host-driven source sync. -#[async_trait] -pub trait MemorySourceSink: Send + Sync { - /// Accept a batch of items the host fetched from one logical source. - /// - /// `taint` applies to the whole batch and is stamped by the host. Sync - /// paths ingesting third-party content pass - /// [`MemoryTaint::ExternalSync`]; the driver persists what it is given and - /// never assigns provenance itself. - /// - /// `source_kind` is a wire string (`folder`, `composio`, …) rather than an - /// enum because the set of source kinds is owned by the host's sync - /// machinery and grows without a contract change. - /// - /// # Errors - /// - /// [`MemoryError::Invalid`] for a rejected batch, otherwise backend - /// failures. Per-item outcomes are counted in [`IngestOutcome`]. - async fn accept_source_items( - &self, - source_id: &str, - source_kind: &str, - items: Vec, - taint: MemoryTaint, - ) -> Result; - - /// Drop everything the driver holds for one logical source, returning how - /// many units were removed. - /// - /// This is the disconnect path: when a user removes a source, its content - /// must leave memory. Idempotent — an unknown `source_id` returns `Ok(0)`. - /// - /// # Errors - /// - /// Backend failures only. - async fn forget_source(&self, source_id: &str) -> Result; -} - -/// Periodic upkeep the host's scheduler drives. -/// -/// All four operations must be safe to call repeatedly and safe to interrupt: -/// the scheduler may invoke them on a timer, and a desktop process can exit at -/// any point. A driver that cannot bound the work should do a slice per call -/// and report progress in [`MaintenanceReport`]. -#[async_trait] -pub trait MemoryMaintenance: Send + Sync { - /// Recompute embeddings for content whose embedding is missing or stale. - /// - /// # Errors - /// - /// Backend failures, or [`MemoryError::BudgetExceeded`] when an embedding - /// budget is exhausted mid-run. - async fn reembed(&self) -> Result; - - /// Reclaim space: vacuum indexes, drop tombstones, prune dead references. - /// - /// # Errors - /// - /// Backend failures only. - async fn compact(&self) -> Result; - - /// Merge and summarise accumulated memory — the "dream" pass. - /// - /// The embedded driver maps this onto its seal/cascade/reembed cycle; an - /// external driver maps it onto whatever it calls the same idea. The - /// contract deliberately does not specify the mechanism, only that it is - /// the operation a scheduler runs when the system is idle. - /// - /// # Errors - /// - /// Backend failures only. - async fn consolidate(&self) -> Result; - - /// Read-only integrity check. - /// - /// Reports findings in [`MaintenanceReport::findings`] and must change - /// nothing — [`MaintenanceReport::changed`] is always `0`. A driver that - /// repairs as it inspects should expose that as [`Self::compact`] instead, - /// so an operator can diagnose without mutating. - /// - /// # Errors - /// - /// Backend failures only. A *finding* is not an error: a store with - /// problems still returns `Ok` with the problems listed. - async fn doctor(&self) -> Result; -} diff --git a/api/src/provider/types.rs b/api/src/provider/types.rs deleted file mode 100644 index 1c9c3d25..00000000 --- a/api/src/provider/types.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! Value types that exist only because the *driver contract* needs them. -//! -//! Everything here is inert data: serde-derived, dependency-light, and free of -//! any engine or host type. They are separated from [`crate::types`] because -//! that module carries the historical engine value types (which the engine -//! crate aliases back into `tinycortex::memory::types`), whereas these are new -//! shapes introduced by the provider contract itself. -//! -//! ## Why these types and not the engine's -//! -//! Several families the contract exposes (diff, entities, sources, -//! maintenance) have richer types inside the `tinycortex` engine — for example -//! `memory::diff::types::DiffResult`. Those types are *implementation* shapes: -//! they carry git commit SHAs, ledger paths, and engine-specific enums. A -//! third-party driver cannot produce them and must not be required to. -//! -//! So the contract defines the narrower shape a *caller* actually needs, with -//! wire strings deliberately identical to the engine's where they overlap -//! (`added`/`removed`/`modified`), so the embedded driver's conversion is a -//! field-for-field map rather than a translation. -//! -//! ## What is deliberately absent -//! -//! No type here names a configuration struct. `MemoryConfig` stayed engine-side -//! in the M0 carve-out and stays there: a driver holds its own configuration -//! and the contract passes only domain arguments. If a future method cannot be -//! expressed without configuration, that is a signal the family was designed -//! wrong, not that the contract should widen. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::chunks::{DataSource, SourceRef}; -use crate::types::MemoryTaint; - -/// A per-turn allowlist of memory sources, passed **into** the driver as a -/// query predicate. -/// -/// ## Why this is a parameter and not a post-filter -/// -/// The host computes a per-turn source allowlist from product policy. If that -/// allowlist were applied after the driver returned rows, a `limit` would be -/// consumed by rows the caller is not allowed to see — so a scoped query could -/// return fewer results than it should, or none at all, purely as an artefact -/// of filtering order. Worse, an out-of-process driver would have already been -/// handed a query it should never have answered in full. -/// -/// The predicate therefore travels with the call. `None` means unrestricted; -/// `Some(scope)` means the driver must apply it *inside* its query. -/// -/// ## Matching rule (fail-closed) -/// -/// [`SourceScope::allows_source_id`] encodes the embedded engine's SQL -/// semantics verbatim: a source-attributed id is in scope when it either equals -/// an allowed id outright, or begins with `mem_src:{allowed}:`. An **empty** -/// allow list therefore matches nothing — a scope that lists no sources denies -/// all source-attributed content rather than waving it through. -/// -/// Content that is not attributed to a memory source at all (no -/// `memory_sources` provenance) is outside this predicate's remit; the driver -/// decides that, exactly as the engine's SQL does today. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct SourceScope { - /// Allowed memory-source identifiers. Empty denies all source-attributed - /// content. - pub allow: Vec, -} - -impl SourceScope { - /// Builds a scope from any iterator of source identifiers. - pub fn new(allow: impl IntoIterator>) -> Self { - Self { - allow: allow.into_iter().map(Into::into).collect(), - } - } - - /// Whether this scope lists no sources — in which case it denies all - /// source-attributed content. See the type docs for why that is the - /// fail-closed reading and not "unrestricted". - pub fn is_empty(&self) -> bool { - self.allow.is_empty() - } - - /// Whether `source_id` is in scope, using the engine's equality-or-prefix - /// rule. - /// - /// ``` - /// use tinycortex_api::provider::types::SourceScope; - /// - /// let scope = SourceScope::new(["src-abc"]); - /// assert!(scope.allows_source_id("src-abc")); - /// assert!(scope.allows_source_id("mem_src:src-abc:item-1")); - /// assert!(!scope.allows_source_id("src-xyz")); - /// - /// // An empty scope denies everything. - /// assert!(!SourceScope::default().allows_source_id("src-abc")); - /// ``` - pub fn allows_source_id(&self, source_id: &str) -> bool { - self.allow.iter().any(|allowed| { - source_id == allowed || source_id.starts_with(&format!("mem_src:{allowed}:")) - }) - } -} - -/// One unit of content handed to [`crate::provider::MemoryIngest`]. -/// -/// The driver owns chunking, embedding, and persistence — this type carries -/// only what the driver cannot know: where the content came from, when, who it -/// belongs to, and how far it may be trusted. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct IngestItem { - /// Target namespace; `None` means the driver's default namespace. - #[serde(default)] - pub namespace: Option, - /// Concrete upstream provider the content came from. - pub source: DataSource, - /// Stable logical id for the ingestion group (channel id, thread id, doc - /// id). This is the dedupe key, not a display value. - pub source_id: String, - /// Account or user the content belongs to; empty for anonymous/system - /// sources. - #[serde(default)] - pub owner: String, - /// Opaque pointer back to the raw source record, for citation and - /// drill-down. - #[serde(default)] - pub source_ref: Option, - /// The content itself, already decoded to text. - pub content: String, - /// MIME type of [`Self::content`] when the caller knows it. - #[serde(default)] - pub mime: Option, - /// Event time used for ordering and tree placement; the driver substitutes - /// ingest time when absent. - #[serde(default)] - pub timestamp: Option>, - /// Labels carried through from the source. Ingest does not interpret them. - #[serde(default)] - pub tags: Vec, - /// Provenance taint. The **host** stamps this; a driver must persist what it - /// is given and must never assign or upgrade it. - #[serde(default)] - pub taint: MemoryTaint, - /// Overrides `source_id` for on-disk path grouping only; `source_id` - /// remains the dedupe key. - #[serde(default)] - pub path_scope: Option, -} - -/// What an ingest call actually persisted. -/// -/// Counts rather than content, so the caller can report progress and detect a -/// silently-dropping driver without holding the written material in memory. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct IngestOutcome { - /// Units the driver newly persisted. - pub written: u32, - /// Units the driver recognised as already present and skipped. - pub skipped: u32, - /// Driver-assigned ids for the written units, when the driver exposes them. - /// May be empty even when [`Self::written`] is non-zero — an external - /// backend is not obliged to surface its internal ids. - #[serde(default)] - pub ids: Vec, -} - -/// One line of the portability stream. -/// -/// Export and import are defined over records rather than bytes so the contract -/// stays free of an async runtime and of any streaming abstraction: the host -/// adapter turns a page of records into NDJSON (and back) at the transport -/// boundary. -/// -/// [`Self::kind`] is a driver-defined string rather than an enum. A backend has -/// record kinds this crate has never heard of, and a migration between two -/// backends must round-trip them untouched rather than drop what it cannot -/// classify. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ExportRecord { - /// Driver-defined record kind (e.g. `entry`, `document`, `chunk`). - pub kind: String, - /// Driver-assigned id, unique within [`Self::kind`]. - pub id: String, - /// Owning namespace, when the record has one. - #[serde(default)] - pub namespace: Option, - /// Provenance taint of the record's content. Preserved across - /// export → import; an importing driver must not re-stamp it. - #[serde(default)] - pub taint: MemoryTaint, - /// The record body, in the exporting driver's own shape. - pub payload: serde_json::Value, -} - -/// One page of an export, plus the cursor that continues it. -/// -/// Paging (rather than a stream) keeps [`crate::provider::MemoryPortability`] -/// object-safe and runtime-agnostic while still bounding memory: the caller -/// decides the page size and drives the loop. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct ExportPage { - /// Records in this page. May be empty on the final page. - pub records: Vec, - /// Opaque cursor to pass to the next call. `None` means the export is - /// complete — this, not an empty [`Self::records`], is the terminator. - #[serde(default)] - pub next_cursor: Option, -} - -/// What an import call actually accepted. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ImportOutcome { - /// Records written. - pub imported: u32, - /// Records recognised as already present and skipped. - pub skipped: u32, - /// Records rejected. A non-zero value with an empty [`Self::errors`] is a - /// driver bug: a rejection the operator cannot diagnose. - pub failed: u32, - /// Operator-facing reasons for the failures, bounded by the driver. Must - /// not contain record content or credentials — this is logged. - #[serde(default)] - pub errors: Vec, -} - -/// Identity of an entity in the driver's index. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct EntityRef { - /// Canonical, driver-stable entity id. - pub id: String, - /// Entity kind as a wire string (`person`, `organization`, `topic`, …). - /// A string rather than an enum because the taxonomy is the driver's, and a - /// kind this build does not recognise must still round-trip. - pub kind: String, - /// Display name. - pub name: String, -} - -/// An entity together with its recency/frequency signals. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct EntityHit { - /// The entity itself. - pub entity: EntityRef, - /// Driver-computed hotness, higher is hotter. Not normalised across - /// drivers — compare within one driver's results only. - pub hotness: f64, - /// Number of times the entity was observed. - pub mentions: u32, -} - -/// Identity of a captured snapshot. -/// -/// The engine's own snapshot type additionally carries the git commit SHA and -/// ledger trailers that back it; those are implementation, so the contract -/// exposes only the identity and the counts a caller can act on. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SnapshotRef { - /// Driver-stable snapshot id. - pub id: String, - /// Logical source this snapshot covers. - pub source_id: String, - /// Human-readable source label at capture time. - #[serde(default)] - pub label: String, - /// Number of items materialised into the snapshot. - pub item_count: u32, - /// Capture time in milliseconds since the Unix epoch. - pub taken_at_ms: i64, -} - -/// What happened to one item between two snapshots. -/// -/// Wire strings are identical to the engine's `memory::diff::types::ChangeKind` -/// so the embedded adapter maps rather than translates. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ChangeKind { - /// Present in the later snapshot only. - Added, - /// Present in the earlier snapshot only. - Removed, - /// Present in both, with differing content. - Modified, -} - -impl ChangeKind { - /// Stable wire string. - pub fn as_str(self) -> &'static str { - match self { - Self::Added => "added", - Self::Removed => "removed", - Self::Modified => "modified", - } - } -} - -/// A single item-level change inside a [`DiffReport`]. -/// -/// Item identity is the item id, never the title, so a rename reports as a -/// removal plus an addition rather than a modification. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SourceChange { - /// Stable item id. - pub item_id: String, - /// Display title, or the id when the driver has no better label. - #[serde(default)] - pub title: String, - /// What kind of change occurred. - pub kind: ChangeKind, - /// Content hash on the earlier side; absent for an addition. - #[serde(default)] - pub old_content_hash: Option, - /// Content hash on the later side; absent for a removal. - #[serde(default)] - pub new_content_hash: Option, -} - -/// The result of diffing one source between two snapshots. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct DiffReport { - /// Source this diff covers. - pub source_id: String, - /// Baseline snapshot id; `None` for a first-ever diff, where everything is - /// an addition. - #[serde(default)] - pub from_snapshot_id: Option, - /// Target snapshot id. - pub to_snapshot_id: String, - /// Items added. - pub added: u32, - /// Items removed. - pub removed: u32, - /// Items modified. - pub modified: u32, - /// Items present and unchanged. - pub unchanged: u32, - /// Per-item changes. May be truncated by the driver; the counts above are - /// authoritative. - #[serde(default)] - pub changes: Vec, -} - -/// One item handed to [`crate::provider::MemorySourceSink`] by the host's sync -/// machinery. -/// -/// The host owns credentials, scheduling, and fetching; the driver owns storage -/// and indexing. This type is the whole of what crosses that line. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SourceItem { - /// Stable per-source item id. Dedupe key; not a display value. - pub item_id: String, - /// Display title. - #[serde(default)] - pub title: String, - /// Item body, already decoded to text. - pub content: String, - /// MIME type of [`Self::content`] when known. - #[serde(default)] - pub mime: Option, - /// Canonical URL back to the item, when it has one. - #[serde(default)] - pub url: Option, - /// Upstream last-modified time in milliseconds since the Unix epoch. - #[serde(default)] - pub updated_at_ms: Option, - /// Labels carried through from the source. - #[serde(default)] - pub tags: Vec, -} - -/// Outcome of one maintenance operation. -/// -/// A single shape covers reembed, compact, consolidate, and doctor because the -/// caller does the same thing with all four: report progress and surface -/// findings. A per-operation result type would multiply the contract surface -/// without giving any caller more to act on. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct MaintenanceReport { - /// Which operation ran (`reembed`, `compact`, `consolidate`, `doctor`). - pub operation: String, - /// Units the driver examined. - pub examined: u64, - /// Units the driver changed. Always `0` for `doctor`, which is read-only. - pub changed: u64, - /// Operator-facing findings and notes. Must not contain memory content or - /// credentials — this is logged and shown in status output. - #[serde(default)] - pub findings: Vec, -} - -#[cfg(test)] -#[path = "types_tests.rs"] -mod tests; diff --git a/api/src/provider/types_tests.rs b/api/src/provider/types_tests.rs deleted file mode 100644 index 390e59a2..00000000 --- a/api/src/provider/types_tests.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Tests for the contract-only value types. -//! -//! The focus is the two things a later slice can silently break: the -//! fail-closed reading of an empty [`SourceScope`], and the wire strings / -//! serde defaults that an out-of-process driver depends on. - -use super::*; - -#[test] -fn empty_source_scope_denies_every_source() { - let scope = SourceScope::default(); - assert!(scope.is_empty()); - assert!(!scope.allows_source_id("src-abc")); - assert!(!scope.allows_source_id("mem_src:src-abc:item")); -} - -#[test] -fn source_scope_matches_exact_id_and_mem_src_prefix() { - let scope = SourceScope::new(["src-abc", "src-def"]); - - assert!(scope.allows_source_id("src-abc")); - assert!(scope.allows_source_id("src-def")); - assert!(scope.allows_source_id("mem_src:src-abc:item-1")); - assert!(scope.allows_source_id("mem_src:src-def:nested:item")); - - assert!(!scope.allows_source_id("src-xyz")); - assert!(!scope.allows_source_id("mem_src:src-xyz:item-1")); -} - -#[test] -fn source_scope_prefix_requires_the_trailing_separator() { - // `src-abc` must not smear onto `src-abcdef`: the engine's SQL binds - // `mem_src:{id}:` including the trailing colon, so a longer id that merely - // starts with an allowed one is out of scope. - let scope = SourceScope::new(["src-abc"]); - assert!(!scope.allows_source_id("mem_src:src-abcdef:item")); - assert!(!scope.allows_source_id("src-abcdef")); -} - -#[test] -fn change_kind_wire_strings_match_the_engine() { - // These strings are shared with `memory::diff::types::ChangeKind`, so the - // embedded adapter maps rather than translates. Changing one is a contract - // major bump. - for (kind, expected) in [ - (ChangeKind::Added, "added"), - (ChangeKind::Removed, "removed"), - (ChangeKind::Modified, "modified"), - ] { - assert_eq!(kind.as_str(), expected); - assert_eq!( - serde_json::to_value(kind).expect("serialize change kind"), - serde_json::Value::String(expected.to_string()), - ); - } -} - -#[test] -fn export_page_terminates_on_absent_cursor_not_empty_records() { - let page = ExportPage::default(); - assert!(page.records.is_empty()); - assert!(page.next_cursor.is_none()); - - // An empty page with a cursor is a legitimate mid-export state, so callers - // must not treat "no records" as the terminator. - let midway = ExportPage { - records: Vec::new(), - next_cursor: Some("cursor-2".to_string()), - }; - assert!(midway.next_cursor.is_some()); -} - -#[test] -fn export_record_round_trips_taint_and_opaque_payload() { - let record = ExportRecord { - kind: "vendor_specific_kind".to_string(), - id: "rec-1".to_string(), - namespace: Some("global".to_string()), - taint: MemoryTaint::ExternalSync, - payload: serde_json::json!({ "anything": [1, 2, 3] }), - }; - - let json = serde_json::to_string(&record).expect("serialize record"); - let back: ExportRecord = serde_json::from_str(&json).expect("deserialize record"); - - assert_eq!(back, record); - assert_eq!(back.taint, MemoryTaint::ExternalSync); -} - -#[test] -fn ingest_item_deserializes_from_the_minimal_body() { - // Every optional field carries `#[serde(default)]`, so a caller that knows - // only source, id, and content can still build a valid request. - let item: IngestItem = serde_json::from_value(serde_json::json!({ - "source": "notion", - "source_id": "page-1", - "content": "hello", - })) - .expect("deserialize minimal ingest item"); - - assert_eq!(item.source, DataSource::Notion); - assert_eq!(item.namespace, None); - assert_eq!(item.owner, ""); - assert!(item.tags.is_empty()); - // Provenance defaults to the conservative-for-writes `Internal`; the host - // guard overrides it explicitly on every sync path. - assert_eq!(item.taint, MemoryTaint::Internal); -} - -#[test] -fn maintenance_report_defaults_to_a_clean_read_only_run() { - let report = MaintenanceReport { - operation: "doctor".to_string(), - ..MaintenanceReport::default() - }; - assert_eq!(report.changed, 0); - assert!(report.findings.is_empty()); -} - -#[test] -fn diff_report_expresses_a_first_ever_diff_without_a_sentinel() { - let report = DiffReport { - source_id: "src-abc".to_string(), - from_snapshot_id: None, - to_snapshot_id: "snap-1".to_string(), - added: 3, - ..DiffReport::default() - }; - - let json = serde_json::to_value(&report).expect("serialize diff report"); - assert_eq!(json["from_snapshot_id"], serde_json::Value::Null); - assert_eq!(json["added"], 3); -} diff --git a/api/src/recall.rs b/api/src/recall.rs deleted file mode 100644 index 4d82619f..00000000 --- a/api/src/recall.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Recall filter contracts — the borrowed engine form and the owned -//! contract/wire form, kept side by side so they cannot drift. -//! -//! ## Why there are two -//! -//! [`RecallOpts`] is the historical, engine-facing shape: it borrows its string -//! filters so a hot retrieval path allocates nothing. That makes it unusable as -//! a contract type in two independent ways — it derives no serde impls, so it -//! cannot be a `POST /v1/memory/recall` request body, and its lifetime -//! parameter would have to be threaded through every `#[async_trait]` recall -//! method, which destroys the object safety the whole driver model rests on. -//! -//! [`OwnedRecallOpts`] is the answer: the same five fields, owned, serde- -//! derived. Contract and wire paths use the owned form; the engine path keeps -//! the borrowed one and converts at the boundary via -//! `RecallOpts::from(&owned)`, which is zero-copy for the string fields. -//! -//! ## Field parity is the contract -//! -//! A field added to one form and not the other is a silent contract hole: the -//! wire would accept a filter the engine never applies, or the engine would -//! offer a filter no remote driver can be told about. Two defences are in -//! place, and both must stay: -//! -//! 1. Both [`From`] impls **exhaustively destructure** their source, so adding -//! a field to either struct without handling it fails to compile. -//! 2. `owned_and_borrowed_recall_opts_have_identical_fields` in -//! `recall_tests.rs` round-trips a fully non-default value through both -//! directions, so a field that is merely *dropped* during conversion fails -//! the test. -//! -//! Both types live in this module (rather than in `types.rs`) precisely so the -//! pair is read and edited together. They are re-exported from -//! [`crate::types`], so every historical `types::RecallOpts` path — including -//! the engine crate's `tinycortex::memory::types::` alias — keeps resolving. - -use serde::{Deserialize, Serialize}; - -use crate::types::MemoryCategory; - -/// Optional filters for recall — the **borrowed, engine-facing** form. -/// -/// Borrows its string filters so an engine call path can pass slices of a -/// caller-owned request without allocating. It is deliberately *not* -/// serializable and deliberately *not* used in the driver contract: a lifetime -/// parameter cannot travel through an object-safe `#[async_trait]` method, and -/// a borrowed struct cannot be a request body. -/// -/// Use [`OwnedRecallOpts`] for anything that crosses a trait object or the -/// wire, and convert at the boundary with the [`From`] impl below. The two -/// types carry the same fields; a field added to one and not the other is a -/// silent contract hole, which -/// `owned_and_borrowed_recall_opts_have_identical_fields` exists to catch. -#[derive(Debug, Default, Clone)] -pub struct RecallOpts<'a> { - /// Restrict recall to this namespace; `None` falls back to [`crate::types::GLOBAL_NAMESPACE`]. - pub namespace: Option<&'a str>, - /// Restrict recall to entries of this category. - pub category: Option, - /// Restrict recall to entries scoped to this session. - pub session_id: Option<&'a str>, - /// Drop hits scoring below this threshold (typically 0.0–1.0). - pub min_score: Option, - /// When `true`, include conversational hits from other sessions in the same - /// workspace alongside the namespace recall. - pub cross_session: bool, -} - -/// Optional filters for recall — the **owned, contract-facing** form. -/// -/// This is the type the driver contract and the JSON wire protocol use. It -/// exists because [`RecallOpts`] cannot serve either role: -/// -/// - it derives no `Serialize`/`Deserialize`, so it cannot be a -/// `POST /v1/memory/recall` request body; -/// - it carries a borrow lifetime, which would have to be threaded through -/// every `#[async_trait]` recall method and destroys object safety at the -/// `dyn` boundary the whole driver model rests on. -/// -/// The borrowed form stays for engine-internal use so the embedded driver's -/// hot path allocates nothing: build the owned value once at the contract -/// boundary, then hand `RecallOpts::from(&owned)` down. -/// -/// Every field is `#[serde(default)]` so a minimal request body — even `{}` — -/// deserializes to the same value as [`Default::default`]. -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] -pub struct OwnedRecallOpts { - /// Restrict recall to this namespace; `None` falls back to [`crate::types::GLOBAL_NAMESPACE`]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub namespace: Option, - /// Restrict recall to entries of this category. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub category: Option, - /// Restrict recall to entries scoped to this session. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - /// Drop hits scoring below this threshold (typically 0.0–1.0). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub min_score: Option, - /// When `true`, include conversational hits from other sessions in the same - /// workspace alongside the namespace recall. - #[serde(default)] - pub cross_session: bool, -} - -impl<'a> From<&'a OwnedRecallOpts> for RecallOpts<'a> { - /// Borrows the owned form for an engine call. Zero-copy for the two string - /// fields; [`MemoryCategory`] is cloned because it owns a `String` in its - /// [`MemoryCategory::Custom`] variant and [`RecallOpts`] holds it by value. - /// - /// Exhaustively destructures the source so adding a field to - /// [`OwnedRecallOpts`] without handling it here is a compile error. - fn from(owned: &'a OwnedRecallOpts) -> Self { - let OwnedRecallOpts { - namespace, - category, - session_id, - min_score, - cross_session, - } = owned; - RecallOpts { - namespace: namespace.as_deref(), - category: category.clone(), - session_id: session_id.as_deref(), - min_score: *min_score, - cross_session: *cross_session, - } - } -} - -impl From> for OwnedRecallOpts { - /// Takes ownership of a borrowed form — the direction a transport adapter - /// needs when turning an engine-shaped call into a request body. - /// - /// Exhaustively destructures the source for the same reason as the inverse - /// impl. - fn from(borrowed: RecallOpts<'_>) -> Self { - let RecallOpts { - namespace, - category, - session_id, - min_score, - cross_session, - } = borrowed; - OwnedRecallOpts { - namespace: namespace.map(str::to_string), - category, - session_id: session_id.map(str::to_string), - min_score, - cross_session, - } - } -} - -#[cfg(test)] -#[path = "recall_tests.rs"] -mod tests; diff --git a/api/src/recall_tests.rs b/api/src/recall_tests.rs deleted file mode 100644 index 5adf3175..00000000 --- a/api/src/recall_tests.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Unit tests for the recall filter contracts in [`super`]. -//! -//! The load-bearing test here is -//! `owned_and_borrowed_recall_opts_have_identical_fields`: it is the runtime -//! half of the field-parity defence described in the module docs (the compile -//! half being the exhaustive destructuring inside both `From` impls). - -use super::*; -use serde_json::json; - -/// Every field set to a non-default value, so a conversion that silently drops -/// one is visible. -fn fully_populated_owned() -> OwnedRecallOpts { - OwnedRecallOpts { - namespace: Some("projects".to_string()), - category: Some(MemoryCategory::Custom("field_notes".to_string())), - session_id: Some("session-42".to_string()), - min_score: Some(0.75), - cross_session: true, - } -} - -#[test] -fn owned_and_borrowed_recall_opts_have_identical_fields() { - let owned = fully_populated_owned(); - - // Owned → borrowed. Destructured exhaustively so a new field on - // `RecallOpts` fails to compile here rather than silently going unchecked. - let borrowed = RecallOpts::from(&owned); - let RecallOpts { - namespace, - category, - session_id, - min_score, - cross_session, - } = borrowed.clone(); - assert_eq!(namespace, Some("projects")); - assert_eq!(category, Some(MemoryCategory::Custom("field_notes".into()))); - assert_eq!(session_id, Some("session-42")); - assert_eq!(min_score, Some(0.75)); - assert!(cross_session); - - // Borrowed → owned, and back to the value we started from. A field dropped - // in either direction fails this equality. - let round_tripped = OwnedRecallOpts::from(borrowed); - assert_eq!(round_tripped, owned); -} - -#[test] -fn borrowed_view_is_zero_copy_over_the_owned_strings() { - let owned = fully_populated_owned(); - let borrowed = RecallOpts::from(&owned); - - // The borrowed form points *into* the owned value rather than at a copy; - // that is the whole reason the borrowed form survives. - assert_eq!( - borrowed.namespace.unwrap().as_ptr(), - owned.namespace.as_deref().unwrap().as_ptr() - ); - assert_eq!( - borrowed.session_id.unwrap().as_ptr(), - owned.session_id.as_deref().unwrap().as_ptr() - ); -} - -#[test] -fn owned_recall_opts_defaults_match_borrowed_defaults() { - let owned = OwnedRecallOpts::default(); - let borrowed = RecallOpts::from(&owned); - - assert!(borrowed.namespace.is_none()); - assert!(borrowed.category.is_none()); - assert!(borrowed.session_id.is_none()); - assert!(borrowed.min_score.is_none()); - assert!(!borrowed.cross_session); - - // And the borrowed default converts back to the owned default. - assert_eq!(OwnedRecallOpts::from(RecallOpts::default()), owned); -} - -#[test] -fn owned_recall_opts_serde_round_trips_every_field() { - let owned = fully_populated_owned(); - let encoded = serde_json::to_value(&owned).unwrap(); - - assert_eq!( - encoded, - json!({ - "namespace": "projects", - "category": "custom:field_notes", - "session_id": "session-42", - "min_score": 0.75, - "cross_session": true - }) - ); - - let decoded: OwnedRecallOpts = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, owned); -} - -#[test] -fn empty_recall_body_deserializes_to_the_default() { - // A minimal `POST /v1/memory/recall` body must be accepted: every field is - // `#[serde(default)]`. - let decoded: OwnedRecallOpts = serde_json::from_value(json!({})).unwrap(); - assert_eq!(decoded, OwnedRecallOpts::default()); -} - -#[test] -fn partial_recall_body_leaves_unmentioned_fields_at_default() { - let decoded: OwnedRecallOpts = - serde_json::from_value(json!({ "namespace": "global" })).unwrap(); - assert_eq!(decoded.namespace.as_deref(), Some("global")); - assert!(decoded.category.is_none()); - assert!(decoded.session_id.is_none()); - assert!(decoded.min_score.is_none()); - assert!(!decoded.cross_session); -} - -/// The wire form omits absent filters rather than emitting explicit nulls. -/// -/// `OwnedRecallOpts` is the body of `POST /v1/memory/recall`, which the spec -/// describes as an optional-filters bag. Emitting `"namespace": null` for every -/// unset filter is valid JSON but forces a backend to distinguish "absent" from -/// "explicitly null" for no gain. Pinned here because changing the emitted shape -/// after a driver has shipped is observable to any backend that draws that -/// distinction. -#[test] -fn absent_recall_filters_are_omitted_from_the_wire_form() { - let json = serde_json::to_value(OwnedRecallOpts::default()).expect("serialize"); - assert_eq!( - json, - serde_json::json!({ "cross_session": false }), - "unset optional filters must be omitted, not serialized as null" - ); - - let populated = OwnedRecallOpts { - namespace: Some("work".into()), - ..Default::default() - }; - let json = serde_json::to_value(&populated).expect("serialize"); - assert_eq!( - json, - serde_json::json!({ "namespace": "work", "cross_session": false }) - ); -} - -/// Omitting a filter and sending it as `null` must both decode to `None`, so a -/// backend built against either spelling keeps working. -#[test] -fn omitted_and_explicit_null_recall_filters_both_decode_to_none() { - let omitted: OwnedRecallOpts = serde_json::from_str("{}").expect("decode {}"); - let explicit: OwnedRecallOpts = - serde_json::from_str(r#"{"namespace":null,"category":null,"session_id":null,"min_score":null,"cross_session":false}"#) - .expect("decode explicit nulls"); - assert_eq!(omitted, explicit); - assert_eq!(omitted, OwnedRecallOpts::default()); -} diff --git a/api/src/tool_memory.rs b/api/src/tool_memory.rs deleted file mode 100644 index 8699d6d9..00000000 --- a/api/src/tool_memory.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Domain types for the tool-scoped memory layer. -//! -//! A [`ToolMemoryRule`] is a durable, actionable instruction attached to a -//! specific tool (e.g. `email`, `shell`, `web_search`). Unlike per-tool -//! effectiveness statistics, these rules capture **guidance** — corrections, -//! safety constraints, and learned operational rules that the agent should -//! obey when considering or invoking that tool. -//! -//! Rules carry a [`ToolMemoryPriority`] level so the retrieval pipeline can -//! distinguish safety-critical instructions from soft suggestions: -//! -//! - [`ToolMemoryPriority::Critical`] — pinned into the system prompt and -//! therefore not subject to mid-session context compression. -//! - [`ToolMemoryPriority::High`] — surfaced alongside critical rules at -//! tool-selection time. -//! - [`ToolMemoryPriority::Normal`] — available on demand via the recall -//! APIs, but not eagerly injected. -//! -//! These are pure data contracts: the snake_case wire strings -//! (`normal`/`high`/`critical`, `user_explicit`/`post_turn`/`programmatic`) -//! are preserved verbatim from OpenHuman so serialized rules stay -//! byte-compatible across the boundary. - -use serde::{Deserialize, Serialize}; - -/// Priority/criticality of a [`ToolMemoryRule`]. -/// -/// Used by both storage (to filter what is pinned into the system prompt) -/// and retrieval (to sort high-priority guidance ahead of advisory notes). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum ToolMemoryPriority { - /// Soft suggestion — surfaced on demand, not eagerly injected. - #[default] - Normal, - /// Important guidance — eagerly injected at tool-selection time. - High, - /// Safety-critical rule — pinned into the (compression-resistant) - /// system prompt so it survives the agent's full session. - Critical, -} - -impl ToolMemoryPriority { - /// True for priorities that must be eagerly surfaced to the agent - /// (Critical/High rules are both pinned into the system prompt and - /// prefetched at session start, so they survive context compression). - pub fn is_eager(self) -> bool { - matches!(self, Self::Critical | Self::High) - } -} - -/// Where a [`ToolMemoryRule`] originated from. -/// -/// Recorded for provenance and so consumers (UI / debugging) can tell user -/// edicts apart from auto-captured observations. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum ToolMemorySource { - /// User explicitly asked the agent to remember this rule. - UserExplicit, - /// Captured automatically from a post-turn observation (tool failure, - /// repeated correction, etc.). - PostTurn, - /// Written by another subsystem (e.g. an integration provisioner). - #[default] - Programmatic, -} - -/// A single tool-scoped memory rule. -/// -/// Stored under the `tool-{tool_name}` namespace as an entry keyed by -/// `rule/{rule_id}`. The id is stable across updates so callers can -/// upsert by replaying the same id. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ToolMemoryRule { - /// Stable identifier within `(tool_name)`. Generated by callers via - /// [`ToolMemoryRule::generate_id`] when one is not supplied. - pub id: String, - /// Tool this rule applies to (e.g. `email`, `shell`). - pub tool_name: String, - /// Natural-language guidance that should reach the agent. - pub rule: String, - /// Criticality level for retrieval and compression behaviour. - #[serde(default)] - pub priority: ToolMemoryPriority, - /// Where this rule came from. - #[serde(default)] - pub source: ToolMemorySource, - /// Optional free-form tags for filtering (e.g. `safety`, `permission`). - #[serde(default)] - pub tags: Vec, - /// RFC3339 timestamp of when the rule was first written. - pub created_at: String, - /// RFC3339 timestamp of the last update. - pub updated_at: String, -} - -impl ToolMemoryRule { - /// Build a new rule with a freshly generated id and `created_at` / - /// `updated_at` set to "now". - pub fn new( - tool_name: impl Into, - rule: impl Into, - priority: ToolMemoryPriority, - source: ToolMemorySource, - ) -> Self { - let now = chrono::Utc::now().to_rfc3339(); - Self { - id: Self::generate_id(), - tool_name: tool_name.into(), - rule: rule.into(), - priority, - source, - tags: Vec::new(), - created_at: now.clone(), - updated_at: now, - } - } - - /// Generate a fresh, opaque rule id. - /// - /// Each byte of a v4 UUID is encoded as two lowercase ASCII letters in - /// the `a..=p` range (one per nibble). The result is a separator-free, - /// digit-free token — deliberately shaped so it never trips a PII - /// boundary check when used as a storage key. - pub fn generate_id() -> String { - let mut id = String::with_capacity(33); - id.push('r'); - for byte in uuid::Uuid::new_v4().as_bytes() { - id.push((b'a' + (byte >> 4)) as char); - id.push((b'a' + (byte & 0x0f)) as char); - } - id - } - - /// Storage key used inside the tool namespace. - pub fn storage_key(id: &str) -> String { - format!("rule/{id}") - } -} - -/// Namespace string for a given tool. Trimmed and lower-cased so callers -/// can pass user-supplied tool names without leaking whitespace into -/// downstream queries. -/// -/// The `tool-` prefix is intentionally distinct from `global`, `skill-…` -/// and `tool_effectiveness` so retrieval and clearing operations can -/// reason about the namespace without ambiguity. Always build the -/// namespace through this helper — never hard-code the `tool-` format. -/// -/// The engine crate's `ToolMemoryStore::put_rule` applies the same -/// normalization to the stored rule so namespace and display/grouping identity -/// cannot diverge. -pub fn tool_memory_namespace(tool_name: &str) -> String { - format!("tool-{}", tool_name.trim().to_lowercase()) -} - -#[cfg(test)] -#[path = "tool_memory_tests.rs"] -mod tests; diff --git a/api/src/tool_memory_tests.rs b/api/src/tool_memory_tests.rs deleted file mode 100644 index 821369ea..00000000 --- a/api/src/tool_memory_tests.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Tests for the tool-scoped memory domain types. - -use super::*; - -#[test] -fn priority_default_is_normal() { - assert_eq!(ToolMemoryPriority::default(), ToolMemoryPriority::Normal); -} - -#[test] -fn priority_ordering_puts_critical_above_high() { - assert!(ToolMemoryPriority::Critical > ToolMemoryPriority::High); - assert!(ToolMemoryPriority::High > ToolMemoryPriority::Normal); -} - -#[test] -fn priority_is_eager_for_high_and_critical_only() { - assert!(ToolMemoryPriority::Critical.is_eager()); - assert!(ToolMemoryPriority::High.is_eager()); - assert!(!ToolMemoryPriority::Normal.is_eager()); -} - -#[test] -fn priority_snake_case_serde() { - assert_eq!( - serde_json::to_string(&ToolMemoryPriority::Critical).unwrap(), - "\"critical\"" - ); - assert_eq!( - serde_json::to_string(&ToolMemoryPriority::Normal).unwrap(), - "\"normal\"" - ); -} - -#[test] -fn source_snake_case_serde() { - assert_eq!( - serde_json::to_string(&ToolMemorySource::UserExplicit).unwrap(), - "\"user_explicit\"" - ); - assert_eq!( - serde_json::to_string(&ToolMemorySource::PostTurn).unwrap(), - "\"post_turn\"" - ); - assert_eq!( - serde_json::to_string(&ToolMemorySource::Programmatic).unwrap(), - "\"programmatic\"" - ); -} - -#[test] -fn source_default_is_programmatic() { - assert_eq!(ToolMemorySource::default(), ToolMemorySource::Programmatic); -} - -#[test] -fn rule_new_fills_id_and_timestamps() { - let rule = ToolMemoryRule::new( - "email", - "never email Sarah", - ToolMemoryPriority::Critical, - ToolMemorySource::UserExplicit, - ); - assert!(!rule.id.is_empty()); - assert_eq!(rule.tool_name, "email"); - assert_eq!(rule.rule, "never email Sarah"); - assert_eq!(rule.priority, ToolMemoryPriority::Critical); - assert_eq!(rule.source, ToolMemorySource::UserExplicit); - assert!(rule.created_at == rule.updated_at); -} - -#[test] -fn rule_generate_id_produces_unique_values() { - let a = ToolMemoryRule::generate_id(); - let b = ToolMemoryRule::generate_id(); - assert_ne!(a, b); - assert!(a.starts_with('r')); - assert!(a[1..].chars().all(|c| matches!(c, 'a'..='p'))); -} - -#[test] -fn generated_rule_ids_are_safe_memory_document_keys() { - // Generated ids must be free of digits and separators so the resulting - // storage key never resembles PII (phone numbers, ids, etc.) to a - // boundary check downstream. - for _ in 0..128 { - let id = ToolMemoryRule::generate_id(); - assert!( - id.chars().all(|ch| ch.is_ascii_lowercase()), - "generated id should avoid PII-shaped digits and separators: {id}" - ); - let key = ToolMemoryRule::storage_key(&id); - assert!( - key.bytes().all(|b| b == b'/' || b.is_ascii_lowercase()), - "generated storage key should not contain PII-shaped bytes: {key}" - ); - } -} - -#[test] -fn rule_storage_key_uses_rule_prefix() { - assert_eq!(ToolMemoryRule::storage_key("abc"), "rule/abc"); -} - -#[test] -fn rule_serde_roundtrip_preserves_fields() { - let rule = ToolMemoryRule { - id: "id-1".into(), - tool_name: "shell".into(), - rule: "never run sudo".into(), - priority: ToolMemoryPriority::High, - source: ToolMemorySource::PostTurn, - tags: vec!["safety".into()], - created_at: "2026-05-11T00:00:00Z".into(), - updated_at: "2026-05-11T00:00:01Z".into(), - }; - let json = serde_json::to_string(&rule).unwrap(); - let back: ToolMemoryRule = serde_json::from_str(&json).unwrap(); - assert_eq!(back, rule); -} - -#[test] -fn namespace_uses_tool_prefix_and_trims_whitespace() { - assert_eq!(tool_memory_namespace("email"), "tool-email"); - assert_eq!(tool_memory_namespace(" shell "), "tool-shell"); - assert_eq!(tool_memory_namespace("Send_Email"), "tool-send_email"); - assert_eq!(tool_memory_namespace("WebSearch"), "tool-websearch"); -} diff --git a/api/src/traits.rs b/api/src/traits.rs deleted file mode 100644 index d1b788c8..00000000 --- a/api/src/traits.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! The high-level [`Memory`] trait every storage backend implements. -//! -//! Ported from OpenHuman's `memory::traits`. Backend-specific escape hatches -//! (e.g. raw SQLite connection access) are intentionally omitted here so the -//! trait stays storage-agnostic; concrete backends expose those via their own -//! inherent methods. -//! -//! ## Contract notes -//! -//! - Every method returns `anyhow::Result<_>` rather than a typed error: this -//! trait is a stable abstraction boundary over heterogeneous backends -//! (SQLite, vector DB, in-memory, …), each with its own error domain, so -//! callers should treat a returned `Err` as opaque and log/propagate it -//! rather than match on its variant. Concrete backends document their own -//! failure modes (e.g. IO errors, malformed persisted rows) alongside their -//! inherent methods. -//! - None of these methods are specified to panic; a conforming implementation -//! should convert failures (invalid input, backend errors, poisoned locks) -//! into `Err` instead. -//! - [`Memory::store`] and [`Memory::store_with_taint`] are upserts keyed by -//! `(namespace, key)`: calling them again with the same key replaces the -//! prior entry rather than erroring or duplicating it. - -use async_trait::async_trait; - -use super::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts}; - -/// The core trait for memory storage and retrieval. -/// -/// Any persistence backend (SQLite, Postgres, vector DB, in-memory, …) should -/// implement this to participate in the TinyCortex memory engine. -#[async_trait] -pub trait Memory: Send + Sync { - /// Returns the backend name (e.g. `"sqlite"`, `"vector"`, `"in_memory"`). - fn name(&self) -> &str; - - /// Stores a new memory entry or updates an existing one. - /// - /// Idempotent upsert keyed by `(namespace, key)`: calling this again with - /// the same `namespace`/`key` replaces the previous `content`, `category`, - /// and `session_id` rather than erroring or creating a duplicate. Entries - /// stored this way carry [`MemoryTaint::Internal`] (the default); use - /// [`Self::store_with_taint`] to persist content from an external source. - /// - /// # Errors - /// - /// Returns `Err` on any backend failure (IO, serialization, connection - /// loss); implementations must not panic on caller-controlled input. - async fn store( - &self, - namespace: &str, - key: &str, - content: &str, - category: MemoryCategory, - session_id: Option<&str>, - ) -> anyhow::Result<()>; - - /// Store an entry with explicit provenance taint. - /// - /// Sync paths ingesting third-party text MUST use this with - /// [`MemoryTaint::ExternalSync`]. The default implementation degrades to - /// [`Self::store`] for backends that do not yet persist taint — meaning it - /// silently drops the `taint` argument for any backend that has not - /// overridden this method. Backends whose durability/policy story depends - /// on taint being recorded MUST override this method rather than rely on - /// the default. - async fn store_with_taint( - &self, - namespace: &str, - key: &str, - content: &str, - category: MemoryCategory, - session_id: Option<&str>, - taint: MemoryTaint, - ) -> anyhow::Result<()> { - let _ = taint; - self.store(namespace, key, content, category, session_id) - .await - } - - /// Recalls memories matching a query using keyword or semantic search. - /// - /// `limit` caps the number of returned entries; `opts` narrows the search - /// by namespace, category, session, minimum score, and cross-session - /// inclusion (see [`RecallOpts`]). An empty or non-matching `query` should - /// yield `Ok(vec![])`, not an error. Result ordering is backend-defined - /// (typically most-relevant first) but callers must not assume a stable - /// order across backends. - async fn recall( - &self, - query: &str, - limit: usize, - opts: RecallOpts<'_>, - ) -> anyhow::Result>; - - /// Recall documents whose *vector* similarity alone meets a threshold. - /// - /// Returns `(key, content)` pairs, most-relevant first. Defaults to empty so - /// keyword-only / mock backends opt out; a backend that overrides this - /// should treat `min_vector_similarity` as an inclusive floor (hits scoring - /// strictly below it are dropped) and `limit` as a hard cap on the - /// returned count. - async fn recall_relevant_by_vector( - &self, - namespace: &str, - query: &str, - limit: usize, - min_vector_similarity: f64, - ) -> anyhow::Result> { - let _ = (namespace, query, limit, min_vector_similarity); - Ok(Vec::new()) - } - - /// Retrieves a specific entry by exact `(namespace, key)`. - /// - /// Returns `Ok(None)` — not `Err` — when no entry exists for the pair; - /// `Err` is reserved for backend failures. - async fn get(&self, namespace: &str, key: &str) -> anyhow::Result>; - - /// Lists entries, optionally scoped by namespace, category, and session. - /// - /// Each `Option` filter narrows the result set when `Some`; passing all - /// three as `None` lists every entry the backend holds. An empty result - /// set is `Ok(vec![])`, never an error. - async fn list( - &self, - namespace: Option<&str>, - category: Option<&MemoryCategory>, - session_id: Option<&str>, - ) -> anyhow::Result>; - - /// Deletes the entry for `(namespace, key)`. Returns whether it existed. - /// - /// Idempotent: forgetting an already-absent `(namespace, key)` returns - /// `Ok(false)` rather than erroring, so callers may call this - /// unconditionally without checking existence first. - async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result; - - /// Lists all namespaces with aggregate stats for agent-side discovery. - /// - /// See [`NamespaceSummary`] for the per-namespace count and - /// last-updated timestamp returned. - async fn namespace_summaries(&self) -> anyhow::Result>; - - /// Total count of all entries in the backend, across all namespaces. - async fn count(&self) -> anyhow::Result; - - /// Health check on the underlying storage system. - /// - /// Returns `true` when the backend is reachable and able to serve - /// requests. Unlike the other methods this reports failure as `false` - /// rather than `Err`, so it is safe to call from a liveness probe without - /// error-handling boilerplate. - async fn health_check(&self) -> bool; -} diff --git a/api/src/tree.rs b/api/src/tree.rs deleted file mode 100644 index 068dc007..00000000 --- a/api/src/tree.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Domain types for the markdown time-based summary tree. -//! -//! Organises summaries as a time hierarchy: root → year → month → day → hour -//! (leaf). Ported from OpenHuman's `memory_tree/tree_runtime/types.rs`. - -use chrono::{DateTime, Datelike, Timelike, Utc}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -/// Hierarchical level of a tree node. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NodeLevel { - /// Single tree root; aggregates all years. Wire string `"root"`. - Root, - /// One node per calendar year. Wire string `"year"`. - Year, - /// One node per calendar month. Wire string `"month"`. - Month, - /// One node per calendar day. Wire string `"day"`. - Day, - /// Leaf level; one node per hour, where raw content lands. Wire string `"hour"`. - Hour, -} - -impl NodeLevel { - /// Maximum number of tokens allowed at this level. - pub fn max_tokens(&self) -> u32 { - match self { - Self::Hour => 1_000, - Self::Day => 2_000, - Self::Month => 4_000, - Self::Year => 8_000, - Self::Root => 20_000, - } - } - - /// The level above this one in the hierarchy (`None` for root). - pub fn parent_level(&self) -> Option { - match self { - Self::Hour => Some(Self::Day), - Self::Day => Some(Self::Month), - Self::Month => Some(Self::Year), - Self::Year => Some(Self::Root), - Self::Root => None, - } - } - - /// True only for the leaf level (hour). - pub fn is_leaf(&self) -> bool { - matches!(self, Self::Hour) - } - - /// Parse a level string from YAML frontmatter. - pub fn from_str_label(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "root" => Some(Self::Root), - "year" => Some(Self::Year), - "month" => Some(Self::Month), - "day" => Some(Self::Day), - "hour" => Some(Self::Hour), - _ => None, - } - } - - /// Label for display / frontmatter. - pub fn as_str(&self) -> &'static str { - match self { - Self::Root => "root", - Self::Year => "year", - Self::Month => "month", - Self::Day => "day", - Self::Hour => "hour", - } - } -} - -/// A single node in the summary tree. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TreeNode { - /// Path-style hierarchical id, e.g. `"2024/03/15/09"` or `"root"`. - pub node_id: String, - /// Namespace owning this tree (isolates independent trees). - pub namespace: String, - /// Hierarchical level this node sits at. - pub level: NodeLevel, - /// Id of the parent node; `None` only for the root. - pub parent_id: Option, - /// Rolled-up summary text for this node. - pub summary: String, - /// Estimated token count of [`Self::summary`]; bounded by [`NodeLevel::max_tokens`]. - pub token_count: u32, - /// Number of direct children rolled into this node. - pub child_count: u32, - /// Creation timestamp (UTC). - pub created_at: DateTime, - /// Last-update timestamp (UTC). - pub updated_at: DateTime, - /// Optional opaque metadata blob; omitted from serialization when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -/// Metadata about an entire tree within a namespace. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TreeStatus { - /// Namespace the tree belongs to. - pub namespace: String, - /// Total number of nodes across all levels. - pub total_nodes: u64, - /// Number of populated levels (tree height). - pub depth: u32, - /// Timestamp of the earliest ingested entry, if any. - pub oldest_entry: Option>, - /// Timestamp of the most recent ingested entry, if any. - pub newest_entry: Option>, - /// When the tree was last (re)built or sealed. - pub last_run_at: Option>, -} - -/// Input for appending raw content to the ingestion buffer. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IngestRequest { - /// Target namespace to append content into. - pub namespace: String, - /// Raw content to buffer for summarization. - pub content: String, - /// Event time used to derive the hour leaf; defaults to ingestion time when absent. - #[serde(default)] - pub timestamp: Option>, - /// Optional structured metadata carried alongside the content. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -/// Result of a tree query at a specific node. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QueryResult { - /// The node addressed by the query. - pub node: TreeNode, - /// Direct children of [`Self::node`], for drill-down navigation. - pub children: Vec, -} - -/// Rough token estimate: ~4 characters per token. -pub fn estimate_tokens(text: &str) -> u32 { - (text.len() as u32).div_ceil(4) -} - -/// Derive the parent node ID from a node ID. -pub fn derive_parent_id(node_id: &str) -> Option { - if node_id == "root" { - return None; - } - match node_id.rfind('/') { - Some(pos) => Some(node_id[..pos].to_string()), - None => Some("root".to_string()), - } -} - -/// Determine the `NodeLevel` from a node ID string. -pub fn level_from_node_id(node_id: &str) -> NodeLevel { - if node_id == "root" { - return NodeLevel::Root; - } - match node_id.matches('/').count() { - 0 => NodeLevel::Year, - 1 => NodeLevel::Month, - 2 => NodeLevel::Day, - _ => NodeLevel::Hour, - } -} - -/// Derive all ancestor node IDs from a timestamp (hour through root). -/// Returns `(hour_id, day_id, month_id, year_id, root_id)`. -pub fn derive_node_ids(ts: &DateTime) -> (String, String, String, String, String) { - let year = format!("{}", ts.year()); - let month = format!("{}/{:02}", ts.year(), ts.month()); - let day = format!("{}/{:02}/{:02}", ts.year(), ts.month(), ts.day()); - let hour = format!( - "{}/{:02}/{:02}/{:02}", - ts.year(), - ts.month(), - ts.day(), - ts.hour() - ); - (hour, day, month, year, "root".to_string()) -} - -/// Convert a node ID to a relative file path within the tree directory. -pub fn node_id_to_path(node_id: &str) -> PathBuf { - if node_id == "root" { - return PathBuf::from("root.md"); - } - let level = level_from_node_id(node_id); - if level.is_leaf() { - PathBuf::from(format!("{node_id}.md")) - } else { - PathBuf::from(node_id).join("summary.md") - } -} - -#[cfg(test)] -#[path = "tree_tests.rs"] -mod tests; diff --git a/api/src/tree_tests.rs b/api/src/tree_tests.rs deleted file mode 100644 index bb9965fa..00000000 --- a/api/src/tree_tests.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Tests for the markdown time-tree node types. - -use super::*; -use chrono::TimeZone; -use std::path::PathBuf; - -#[test] -fn node_level_max_tokens() { - assert_eq!(NodeLevel::Hour.max_tokens(), 1_000); - assert_eq!(NodeLevel::Day.max_tokens(), 2_000); - assert_eq!(NodeLevel::Month.max_tokens(), 4_000); - assert_eq!(NodeLevel::Year.max_tokens(), 8_000); - assert_eq!(NodeLevel::Root.max_tokens(), 20_000); -} - -#[test] -fn node_level_parent_chain() { - assert_eq!(NodeLevel::Hour.parent_level(), Some(NodeLevel::Day)); - assert_eq!(NodeLevel::Day.parent_level(), Some(NodeLevel::Month)); - assert_eq!(NodeLevel::Month.parent_level(), Some(NodeLevel::Year)); - assert_eq!(NodeLevel::Year.parent_level(), Some(NodeLevel::Root)); - assert_eq!(NodeLevel::Root.parent_level(), None); -} - -#[test] -fn derive_parent_id_chain() { - assert_eq!(derive_parent_id("2024/03/15/14"), Some("2024/03/15".into())); - assert_eq!(derive_parent_id("2024/03/15"), Some("2024/03".into())); - assert_eq!(derive_parent_id("2024/03"), Some("2024".into())); - assert_eq!(derive_parent_id("2024"), Some("root".into())); - assert_eq!(derive_parent_id("root"), None); -} - -#[test] -fn level_from_node_id_all_levels() { - assert_eq!(level_from_node_id("root"), NodeLevel::Root); - assert_eq!(level_from_node_id("2024"), NodeLevel::Year); - assert_eq!(level_from_node_id("2024/03"), NodeLevel::Month); - assert_eq!(level_from_node_id("2024/03/15"), NodeLevel::Day); - assert_eq!(level_from_node_id("2024/03/15/14"), NodeLevel::Hour); -} - -#[test] -fn derive_node_ids_from_timestamp() { - let ts = Utc.with_ymd_and_hms(2024, 3, 15, 14, 30, 0).unwrap(); - let (hour, day, month, year, root) = derive_node_ids(&ts); - assert_eq!(hour, "2024/03/15/14"); - assert_eq!(day, "2024/03/15"); - assert_eq!(month, "2024/03"); - assert_eq!(year, "2024"); - assert_eq!(root, "root"); -} - -#[test] -fn node_id_to_path_mapping() { - assert_eq!(node_id_to_path("root"), PathBuf::from("root.md")); - assert_eq!(node_id_to_path("2024"), PathBuf::from("2024/summary.md")); - assert_eq!( - node_id_to_path("2024/03"), - PathBuf::from("2024/03/summary.md") - ); - assert_eq!( - node_id_to_path("2024/03/15/14"), - PathBuf::from("2024/03/15/14.md") - ); -} - -#[test] -fn estimate_tokens_rough() { - assert_eq!(estimate_tokens(""), 0); - assert_eq!(estimate_tokens("abcd"), 1); - assert_eq!(estimate_tokens(&"a".repeat(4000)), 1000); -} - -#[test] -fn node_level_roundtrip() { - for level in [ - NodeLevel::Root, - NodeLevel::Year, - NodeLevel::Month, - NodeLevel::Day, - NodeLevel::Hour, - ] { - assert_eq!(NodeLevel::from_str_label(level.as_str()), Some(level)); - } -} diff --git a/api/src/types.rs b/api/src/types.rs deleted file mode 100644 index b683b2f7..00000000 --- a/api/src/types.rs +++ /dev/null @@ -1,435 +0,0 @@ -//! Core public data contracts for the TinyCortex memory engine. -//! -//! These types are the stable surface shared across every layer (storage, -//! ingestion, retrieval, RPC). They are pure data — no storage side effects, -//! no interior mutability, freely `Clone`/`Send`/`Sync` — and are ported -//! faithfully from OpenHuman's `memory` and `memory_store` modules so wire -//! formats (snake_case enum strings, serde defaults) stay byte-compatible when -//! OpenHuman imports this crate. -//! -//! ## Wire-compatibility contract -//! -//! Every `#[serde(rename_all = "snake_case")]` enum here has its variant -//! strings persisted in on-disk indexes (SQLite columns, markdown frontmatter) -//! and/or sent over the RPC boundary. Renaming a variant, or a struct field -//! that lacks `#[serde(default)]`, is a breaking change for any host reading -//! previously-written data. When adding a field, prefer `#[serde(default)]` so -//! older persisted rows continue to deserialize. -//! -//! ## Fail-closed provenance -//! -//! [`MemoryTaint`] is the one field in this module with a safety-relevant -//! default: it decodes unknown/corrupt persisted strings as -//! [`MemoryTaint::ExternalSync`] rather than [`MemoryTaint::Internal`], so a -//! caller that forgets to persist taint, or an index that has drifted, fails -//! toward *more* restrictive tool-use policy rather than less. - -use serde::{Deserialize, Serialize}; - -/// The recall filter contracts live in [`crate::recall`] so the borrowed and -/// owned forms sit next to each other and cannot drift, and are re-exported -/// here so every historical `types::RecallOpts` path — including the engine -/// crate's `tinycortex::memory::types::` alias — keeps resolving unchanged. -pub use crate::recall::{OwnedRecallOpts, RecallOpts}; - -/// Default namespace used when a caller passes no explicit namespace. -pub const GLOBAL_NAMESPACE: &str = "global"; - -/// Provenance / trust signal attached to a memory entry. -/// -/// Drives downstream policy — most importantly whether automation whose context -/// contains this content may invoke external-effect tools. Defaults to -/// [`MemoryTaint::Internal`] so legacy rows (no persisted taint column) and all -/// in-memory defaults are conservatively trusted as user-driven content. -/// -/// Sync paths that ingest text from third-party services (Gmail / Slack / -/// Notion / Composio / MCP / …) MUST set this to [`MemoryTaint::ExternalSync`] -/// at write time so callers can refuse external-effect tools on tainted context. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum MemoryTaint { - /// User-driven memory (chat, manual remember, internal heuristics). - #[default] - Internal, - /// Content ingested from an external sync source. - ExternalSync, -} - -impl Serialize for MemoryTaint { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(self.as_db_str()) - } -} - -impl<'de> Deserialize<'de> for MemoryTaint { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let raw = String::deserialize(deserializer)?; - Ok(Self::from_db_str(&raw)) - } -} - -impl MemoryTaint { - /// Serialised form used by the SQLite `memory_docs.taint` column. - /// - /// # Examples - /// - /// ``` - /// use tinycortex_api::types::MemoryTaint; - /// - /// assert_eq!(MemoryTaint::Internal.as_db_str(), "internal"); - /// assert_eq!(MemoryTaint::ExternalSync.as_db_str(), "external_sync"); - /// ``` - pub fn as_db_str(&self) -> &'static str { - match self { - Self::Internal => "internal", - Self::ExternalSync => "external_sync", - } - } - - /// Reverse of [`Self::as_db_str`]. Unknown values fail closed to the more - /// restrictive [`MemoryTaint::ExternalSync`] so policy gates refuse - /// external-effect tools on content of unknown provenance. - /// - /// Note this is *not* a strict inverse of [`Self::as_db_str`]: it never - /// errors, so a malformed or unexpected `raw` string (empty, wrong case, - /// truncated by a partial write, …) silently maps to - /// [`MemoryTaint::ExternalSync`] rather than surfacing as a parse failure. - /// - /// # Examples - /// - /// ``` - /// use tinycortex_api::types::MemoryTaint; - /// - /// assert_eq!(MemoryTaint::from_db_str("internal"), MemoryTaint::Internal); - /// assert_eq!(MemoryTaint::from_db_str("external_sync"), MemoryTaint::ExternalSync); - /// // Unrecognised input fails closed rather than erroring. - /// assert_eq!(MemoryTaint::from_db_str("garbage"), MemoryTaint::ExternalSync); - /// ``` - pub fn from_db_str(raw: &str) -> Self { - match raw { - "internal" => Self::Internal, - "external_sync" => Self::ExternalSync, - _ => Self::ExternalSync, - } - } -} - -/// Categories used to organize and filter memories by nature and lifecycle. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MemoryCategory { - /// Long-term foundational facts, user preferences, permanent decisions. - Core, - /// Temporal logs reflecting daily activities or ephemeral state. - Daily, - /// Contextual information derived from active conversations. - Conversation, - /// A user- or system-defined custom category. - Custom(String), -} - -/// The stable wire/display representation uses the built-in labels directly -/// and prefixes custom values with `custom:`. The prefix keeps -/// `Custom("core")` distinct from [`MemoryCategory::Core`] and makes Display, -/// serde, and [`std::str::FromStr`] true inverses. -impl std::fmt::Display for MemoryCategory { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Core => write!(f, "core"), - Self::Daily => write!(f, "daily"), - Self::Conversation => write!(f, "conversation"), - Self::Custom(name) => write!(f, "custom:{name}"), - } - } -} - -impl std::str::FromStr for MemoryCategory { - type Err = String; - - fn from_str(value: &str) -> Result { - match value { - "core" => Ok(Self::Core), - "daily" => Ok(Self::Daily), - "conversation" => Ok(Self::Conversation), - value if value.starts_with("custom:") && value.len() > "custom:".len() => { - Ok(Self::Custom(value["custom:".len()..].to_string())) - } - value if !value.is_empty() => Ok(Self::Custom(value.to_string())), - _ => Err(format!("unknown memory category: {value}")), - } - } -} - -impl Serialize for MemoryCategory { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for MemoryCategory { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - value.parse().map_err(serde::de::Error::custom) - } -} - -/// A single stored memory entry with associated metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemoryEntry { - /// Unique identifier (usually a UUID). - pub id: String, - /// Key or title associated with this memory. - pub key: String, - /// Actual content / value of the memory. - pub content: String, - /// Optional namespace for logical separation. - #[serde(default)] - pub namespace: Option, - /// Organizational category. - pub category: MemoryCategory, - /// ISO 8601 timestamp of create / last-update. - pub timestamp: String, - /// Optional session scope. - pub session_id: Option, - /// Optional relevance / confidence score (typically 0.0–1.0). - pub score: Option, - /// Provenance taint (see [`MemoryTaint`]). Absent on legacy JSON, in which - /// case it defaults to [`MemoryTaint::Internal`]; unknown persisted string - /// values decode as [`MemoryTaint::ExternalSync`]. - #[serde(default)] - pub taint: MemoryTaint, -} - -/// Summary row for agent-side namespace discovery. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NamespaceSummary { - /// Namespace identifier. - pub namespace: String, - /// Number of memory entries currently stored in the namespace. - pub count: usize, - /// RFC3339 timestamp of the most recent update in the namespace, if any. - pub last_updated: Option, -} - -/// Input payload for upserting a namespace-scoped memory document. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NamespaceDocumentInput { - /// Target namespace for the document. - pub namespace: String, - /// Stable upsert key; reusing a key updates the existing document. - pub key: String, - /// Human-readable title. - pub title: String, - /// Document body. - pub content: String, - /// Origin of the content (e.g. `chat`, `gmail`, `notion`). - pub source_type: String, - /// Caller-defined priority label. - pub priority: String, - /// Free-form tags for filtering. - #[serde(default)] - pub tags: Vec, - /// Arbitrary structured metadata carried alongside the document. - #[serde(default)] - pub metadata: serde_json::Value, - /// Category label (see [`MemoryCategory`] wire strings). - pub category: String, - /// Optional session scope. - #[serde(default)] - pub session_id: Option, - /// Explicit document id; generated when absent. - #[serde(default)] - pub document_id: Option, - /// Provenance taint; defaults to [`MemoryTaint::Internal`] for legacy JSON - /// missing this field. Unknown persisted string values decode as - /// [`MemoryTaint::ExternalSync`]. - #[serde(default)] - pub taint: MemoryTaint, -} - -/// One ranked retrieval result for a namespace text query. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NamespaceQueryResult { - /// Upsert key of the matched document. - pub key: String, - /// Matched content. - pub content: String, - /// Relevance score for this hit. - pub score: f64, - /// Category label of the matched document. - pub category: String, - /// Provenance taint; unknown persisted values decode as `external_sync`. - #[serde(default)] - pub taint: MemoryTaint, -} - -/// Discriminator for the kind of stored memory item a hit refers to. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MemoryItemKind { - /// A namespace-scoped memory document (`memory_docs` row). - Document, - /// A key/value record. - Kv, - /// An episodic / conversational memory. - Episodic, - /// A discrete event entry. - Event, -} - -/// Persisted form of a memory document as stored in `memory_docs`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredMemoryDocument { - /// Unique document id. - pub document_id: String, - /// Owning namespace. - pub namespace: String, - /// Stable upsert key. - pub key: String, - /// Human-readable title. - pub title: String, - /// Document body. - pub content: String, - /// Origin of the content (e.g. `chat`, `gmail`). - pub source_type: String, - /// Caller-defined priority label. - pub priority: String, - /// Free-form tags. - pub tags: Vec, - /// Arbitrary structured metadata. - pub metadata: serde_json::Value, - /// Category label. - pub category: String, - /// Optional session scope. - pub session_id: Option, - /// Creation time as a Unix timestamp (seconds). - pub created_at: f64, - /// Last-update time as a Unix timestamp (seconds). - pub updated_at: f64, - /// Path, relative to the vault root, of the authoritative markdown file. - pub markdown_rel_path: String, - /// Provenance taint; unknown persisted values decode as `external_sync`. - #[serde(default)] - pub taint: MemoryTaint, -} - -/// A single KV row, namespace-scoped or global (when `namespace` is `None`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemoryKvRecord { - /// Owning namespace, or `None` for a global row. - pub namespace: Option, - /// KV key. - pub key: String, - /// Stored JSON value. - pub value: serde_json::Value, - /// Last-update time as a Unix timestamp (seconds). - pub updated_at: f64, -} - -/// A graph edge (subject — predicate → object) plus accumulated evidence. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GraphRelationRecord { - /// Owning namespace, or `None` for a global relation. - pub namespace: Option, - /// Edge subject (head entity). - pub subject: String, - /// Relation type linking subject to object. - pub predicate: String, - /// Edge object (tail entity). - pub object: String, - /// Arbitrary structured attributes attached to the edge. - pub attrs: serde_json::Value, - /// Last-update time as a Unix timestamp (seconds). - pub updated_at: f64, - /// Number of independent observations supporting this edge. - pub evidence_count: u32, - /// Optional ordering hint among sibling relations. - pub order_index: Option, - /// Documents that contributed evidence for this edge. - pub document_ids: Vec, - /// Chunks that contributed evidence for this edge. - pub chunk_ids: Vec, -} - -/// Per-signal contribution to a hit's final score, for ranking explainers. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RetrievalScoreBreakdown { - /// Lexical / keyword match contribution. - pub keyword_relevance: f64, - /// Vector (cosine) similarity contribution. - pub vector_similarity: f64, - /// Graph-proximity contribution. - pub graph_relevance: f64, - /// Episodic-recall contribution. - pub episodic_relevance: f64, - /// Recency contribution. - pub freshness: f64, - /// Weighted combination of the above signals; the value used for ranking. - pub final_score: f64, -} - -/// A single ranked retrieval hit. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NamespaceMemoryHit { - /// Identifier of the matched item (interpretation depends on [`Self::kind`]). - pub id: String, - /// Which kind of stored item this hit refers to. - pub kind: MemoryItemKind, - /// Owning namespace. - pub namespace: String, - /// Upsert key of the matched item. - pub key: String, - /// Title, when the item has one. - pub title: Option, - /// Matched content. - pub content: String, - /// Category label. - pub category: String, - /// Origin of the content, when known. - pub source_type: Option, - /// Last-update time as a Unix timestamp (seconds). - pub updated_at: f64, - /// Final ranking score; mirrors [`RetrievalScoreBreakdown::final_score`]. - pub score: f64, - /// Per-signal explanation of how [`Self::score`] was derived. - pub score_breakdown: RetrievalScoreBreakdown, - /// Source document id, when the hit resolves to a document. - #[serde(default)] - pub document_id: Option, - /// Source chunk id, when the hit resolves to a chunk. - #[serde(default)] - pub chunk_id: Option, - /// Graph relations that reinforced this hit's ranking. - #[serde(default)] - pub supporting_relations: Vec, - /// Provenance taint; unknown persisted values decode as `external_sync`. - #[serde(default)] - pub taint: MemoryTaint, -} - -/// Aggregated retrieval result for a namespace: rendered context plus hits. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NamespaceRetrievalContext { - /// Namespace the retrieval ran against. - pub namespace: String, - /// Originating query text, if any. - pub query: Option, - /// Rendered, ready-to-inject context assembled from [`Self::hits`]. - pub context_text: String, - /// Ranked hits backing the rendered context. - pub hits: Vec, -} - -#[cfg(test)] -#[path = "types_tests.rs"] -mod tests; diff --git a/api/src/types_tests.rs b/api/src/types_tests.rs deleted file mode 100644 index 5ee61b5a..00000000 --- a/api/src/types_tests.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Unit tests for the core memory data contracts in [`super`]. - -use super::*; -use serde_json::json; - -#[test] -fn global_namespace_constant_is_stable() { - assert_eq!(GLOBAL_NAMESPACE, "global"); -} - -#[test] -fn memory_category_display_outputs_expected_values() { - assert_eq!(MemoryCategory::Core.to_string(), "core"); - assert_eq!(MemoryCategory::Daily.to_string(), "daily"); - assert_eq!(MemoryCategory::Conversation.to_string(), "conversation"); - assert_eq!( - MemoryCategory::Custom("project_notes".into()).to_string(), - "custom:project_notes" - ); -} - -#[test] -fn memory_category_serde_uses_snake_case() { - assert_eq!( - serde_json::to_string(&MemoryCategory::Core).unwrap(), - "\"core\"" - ); - assert_eq!( - serde_json::to_string(&MemoryCategory::Daily).unwrap(), - "\"daily\"" - ); - assert_eq!( - serde_json::to_string(&MemoryCategory::Conversation).unwrap(), - "\"conversation\"" - ); - assert_eq!( - serde_json::to_string(&MemoryCategory::Custom("core".into())).unwrap(), - "\"custom:core\"" - ); - for category in [ - MemoryCategory::Core, - MemoryCategory::Daily, - MemoryCategory::Conversation, - MemoryCategory::Custom("core".into()), - MemoryCategory::Custom("tool_memory".into()), - ] { - assert_eq!( - category.to_string().parse::().unwrap(), - category - ); - let json = serde_json::to_string(&category).unwrap(); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - category - ); - } - assert_eq!( - "project_notes".parse::().unwrap(), - MemoryCategory::Custom("project_notes".into()) - ); -} - -#[test] -fn memory_entry_roundtrip_preserves_optional_fields() { - let entry = MemoryEntry { - id: "id-1".into(), - key: "favorite_language".into(), - content: "Rust".into(), - namespace: Some("global".into()), - category: MemoryCategory::Core, - timestamp: "2026-02-16T00:00:00Z".into(), - session_id: Some("session-abc".into()), - score: Some(0.98), - taint: MemoryTaint::Internal, - }; - let json = serde_json::to_string(&entry).unwrap(); - let parsed: MemoryEntry = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.id, "id-1"); - assert_eq!(parsed.namespace.as_deref(), Some("global")); - assert_eq!(parsed.category, MemoryCategory::Core); - assert_eq!(parsed.session_id.as_deref(), Some("session-abc")); - assert_eq!(parsed.score, Some(0.98)); - assert_eq!(parsed.taint, MemoryTaint::Internal); -} - -#[test] -fn memory_taint_defaults_to_internal_for_legacy_rows() { - let legacy = r#"{ - "id":"x","key":"k","content":"c","namespace":null, - "category":"core","timestamp":"2026-01-01T00:00:00Z", - "session_id":null,"score":null - }"#; - let parsed: MemoryEntry = serde_json::from_str(legacy).unwrap(); - assert_eq!(parsed.taint, MemoryTaint::Internal); -} - -#[test] -fn memory_taint_db_str_roundtrip_and_fails_closed() { - assert_eq!(MemoryTaint::Internal.as_db_str(), "internal"); - assert_eq!(MemoryTaint::ExternalSync.as_db_str(), "external_sync"); - assert_eq!(MemoryTaint::from_db_str("internal"), MemoryTaint::Internal); - assert_eq!( - MemoryTaint::from_db_str("external_sync"), - MemoryTaint::ExternalSync - ); - // Unknown / corrupt values fail closed to the restrictive variant. - assert_eq!(MemoryTaint::from_db_str(""), MemoryTaint::ExternalSync); - assert_eq!( - MemoryTaint::from_db_str("EXTERNAL_SYNC"), - MemoryTaint::ExternalSync - ); - assert_eq!( - MemoryTaint::from_db_str("future"), - MemoryTaint::ExternalSync - ); -} - -#[test] -fn memory_taint_serde_unknown_values_fail_closed() { - assert_eq!( - serde_json::from_str::("\"unexpected\"").unwrap(), - MemoryTaint::ExternalSync - ); - assert_eq!( - serde_json::to_string(&MemoryTaint::ExternalSync).unwrap(), - "\"external_sync\"" - ); -} - -#[test] -fn memory_item_kind_serde_uses_snake_case() { - assert_eq!( - serde_json::to_string(&MemoryItemKind::Document).unwrap(), - "\"document\"" - ); - let decoded: MemoryItemKind = serde_json::from_str("\"episodic\"").unwrap(); - assert_eq!(decoded, MemoryItemKind::Episodic); -} - -#[test] -fn namespace_document_input_defaults_optional_fields() { - let value = json!({ - "namespace": "global", "key": "note-1", "title": "Title", - "content": "Body", "source_type": "manual", "priority": "normal", - "metadata": {}, "category": "core" - }); - let parsed: NamespaceDocumentInput = serde_json::from_value(value).unwrap(); - assert!(parsed.tags.is_empty()); - assert!(parsed.session_id.is_none()); - assert!(parsed.document_id.is_none()); - assert_eq!(parsed.taint, MemoryTaint::Internal); -} - -#[test] -fn namespace_document_input_taint_roundtrips_external_sync() { - let input = NamespaceDocumentInput { - namespace: "skill-gmail".into(), - key: "thread-1".into(), - title: "Subject".into(), - content: "Body".into(), - source_type: "composio-sync".into(), - priority: "medium".into(), - tags: Vec::new(), - metadata: json!({}), - category: "core".into(), - session_id: None, - document_id: None, - taint: MemoryTaint::ExternalSync, - }; - let value = serde_json::to_value(&input).unwrap(); - assert_eq!( - value.get("taint").and_then(|v| v.as_str()), - Some("external_sync") - ); - let parsed: NamespaceDocumentInput = serde_json::from_value(value).unwrap(); - assert_eq!(parsed.taint, MemoryTaint::ExternalSync); -} - -#[test] -fn retrieval_score_breakdown_default_is_zeroed() { - let b = RetrievalScoreBreakdown::default(); - assert_eq!(b.keyword_relevance, 0.0); - assert_eq!(b.vector_similarity, 0.0); - assert_eq!(b.graph_relevance, 0.0); - assert_eq!(b.episodic_relevance, 0.0); - assert_eq!(b.freshness, 0.0); - assert_eq!(b.final_score, 0.0); -} - -#[test] -fn memory_kv_record_roundtrips_with_optional_namespace() { - for record in [ - MemoryKvRecord { - namespace: None, - key: "theme".into(), - value: json!("dark"), - updated_at: 1.5, - }, - MemoryKvRecord { - namespace: Some("project".into()), - key: "state".into(), - value: json!({"open": true}), - updated_at: 2.5, - }, - ] { - let value = serde_json::to_value(&record).unwrap(); - let decoded: MemoryKvRecord = serde_json::from_value(value).unwrap(); - assert_eq!(decoded.namespace, record.namespace); - assert_eq!(decoded.key, record.key); - assert_eq!(decoded.value, record.value); - assert_eq!(decoded.updated_at, record.updated_at); - } -} - -#[test] -fn namespace_memory_hit_defaults_optional_fields_and_taint() { - let hit: NamespaceMemoryHit = serde_json::from_value(json!({ - "id": "hit-1", "kind": "document", "namespace": "global", - "key": "note-1", "title": "Title", "content": "Body", - "category": "core", "source_type": "manual", "updated_at": 3.5, - "score": 0.8, - "score_breakdown": { - "keyword_relevance": 0.5, "vector_similarity": 0.2, - "graph_relevance": 0.0, "episodic_relevance": 0.0, - "freshness": 0.1, "final_score": 0.8 - } - })) - .unwrap(); - assert!(hit.document_id.is_none()); - assert!(hit.chunk_id.is_none()); - assert!(hit.supporting_relations.is_empty()); - assert_eq!(hit.kind, MemoryItemKind::Document); - assert_eq!(hit.taint, MemoryTaint::Internal); -} diff --git a/api/src/version.rs b/api/src/version.rs deleted file mode 100644 index 2ccf329b..00000000 --- a/api/src/version.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! The memory contract version and the compatibility rule that governs it. -//! -//! Re-exported at the crate root, so the canonical paths are -//! [`crate::CONTRACT_VERSION`] and [`crate::is_compatible`]. -//! -//! ## The rule -//! -//! `CONTRACT_VERSION` is `(major, minor)`: -//! -//! - **Minor bump — an addition that capability negotiation alone makes safe.** -//! A new [`crate::capabilities::Capability`] family is the canonical case: an -//! older driver simply never advertises it, the corresponding RPC methods are -//! unregistered, and the kernel never calls in. A new optional field on an -//! existing wire type, or a new error variant an older kernel can treat as -//! opaque, are the same shape — nothing that already compiled stops -//! compiling, and there is no way for an old driver to be asked for -//! something it never claimed to support. -//! - **Major bump — an existing signature changed, OR a method was added to an -//! already-advertised family.** A method's parameters or return type moved, a -//! mandatory family was added or removed, a wire string changed — or a driver -//! advertising an existing family (say [`crate::capabilities::Capability::Core`]) -//! now has to implement one more method on it. That last case looks additive -//! but is not: capability negotiation has **family granularity only** — there -//! is no way to advertise "`Core`, but without the new method" — so an older -//! driver that still advertises `Core` can be called into a method it does -//! not implement. Bump the major half instead, which forces every driver -//! claiming that family to actually implement the new surface before it can -//! bind again. -//! -//! ## Why only the major half gates the bind -//! -//! An out-of-process driver reports the version it speaks in its handshake -//! (`POST /v1/handshake` → `{ contract_version, driver_id, capabilities[] }`). -//! **A major mismatch refuses the bind**; a minor difference in either -//! direction is accepted, because capability negotiation already covers it: -//! -//! - remote minor > local minor — the driver advertises families this build has -//! never heard of. Unknown family strings are skipped during handshake -//! parsing, so this kernel simply never calls them. -//! - remote minor < local minor — the driver is missing families this build -//! knows about. It does not advertise them, so the corresponding RPC methods -//! are unregistered and the agent tools are absent. That is the ordinary -//! degradation path, not an error. -//! -//! Refusing on a minor difference would therefore reject a driver that is -//! perfectly usable, and would make adding a family a fleet-wide breaking -//! change — which is exactly what the major/minor split exists to avoid. -//! -//! Encoding the rule here rather than in prose means a caller cannot get it -//! subtly wrong: the bind path calls [`is_compatible`], never compares tuples -//! by hand. - -/// Version of the memory contract this crate defines, as `(major, minor)`. -/// -/// See the module docs for the bump rule. Bump the **minor** half only for an -/// addition capability negotiation alone makes safe — a new capability family, -/// a new optional wire field, a new opaque-to-old-kernels error variant. Bump -/// the **major** half — and reset the minor to `0` — for an existing signature -/// change, a mandatory family change, a wire string change, **or a new method -/// added to a family a driver may already advertise** (negotiation is -/// family-granular, not method-granular, so that case cannot be made minor-safe -/// by negotiation alone). -pub const CONTRACT_VERSION: (u16, u16) = (1, 0); - -/// Whether a driver speaking `remote` can be bound against this build. -/// -/// Compatible exactly when the major halves match. See the module docs for why -/// the minor half is informational. -/// -/// # Examples -/// -/// ``` -/// use tinycortex_api::{is_compatible, CONTRACT_VERSION}; -/// -/// // The version this build speaks is always compatible with itself. -/// assert!(is_compatible(CONTRACT_VERSION)); -/// -/// // A minor difference in either direction is fine — capability negotiation -/// // covers the delta. -/// assert!(is_compatible((CONTRACT_VERSION.0, CONTRACT_VERSION.1 + 7))); -/// -/// // A major mismatch refuses the bind. -/// assert!(!is_compatible((CONTRACT_VERSION.0 + 1, 0))); -/// ``` -pub fn is_compatible(remote: (u16, u16)) -> bool { - remote.0 == CONTRACT_VERSION.0 -} - -#[cfg(test)] -#[path = "version_tests.rs"] -mod tests; diff --git a/api/src/version_tests.rs b/api/src/version_tests.rs deleted file mode 100644 index 1b3a4c09..00000000 --- a/api/src/version_tests.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Unit tests for the contract version rule in [`super`]. -//! -//! The rule these pin is the one from the kernel design: a **minor** bump means -//! a capability was added and stays compatible; a **major** mismatch refuses -//! the bind. - -use super::*; - -#[test] -fn contract_version_starts_at_one_zero() { - assert_eq!(CONTRACT_VERSION, (1, 0)); -} - -#[test] -fn own_version_is_compatible_with_itself() { - assert!(is_compatible(CONTRACT_VERSION)); -} - -#[test] -fn a_minor_bump_stays_compatible_in_both_directions() { - let (major, minor) = CONTRACT_VERSION; - - // Remote ahead: it advertises families this build does not know. Unknown - // family strings are skipped during handshake parsing. - assert!(is_compatible((major, minor + 1))); - assert!(is_compatible((major, minor + 25))); - assert!(is_compatible((major, u16::MAX))); - - // Remote behind: it lacks families this build knows. Those simply are not - // advertised, so the surface degrades — the ordinary path, not an error. - assert!(is_compatible((major, minor.saturating_sub(1)))); - assert!(is_compatible((major, 0))); -} - -#[test] -fn a_major_mismatch_refuses_the_bind() { - let (major, minor) = CONTRACT_VERSION; - - // Remote ahead by a major: an existing signature changed under us. - assert!(!is_compatible((major + 1, 0))); - assert!(!is_compatible((major + 1, minor))); - assert!(!is_compatible((major + 1, u16::MAX))); - - // Remote behind by a major: same reasoning, other direction. A newer minor - // does not rescue an older major. - assert!(!is_compatible((major - 1, u16::MAX))); - assert!(!is_compatible((0, 0))); -} - -#[test] -fn adding_a_method_to_an_already_advertised_family_requires_a_major_bump() { - // Capability negotiation has family granularity, not method granularity: - // there is no way to advertise "Core, but without the new method". So a - // method added to a family a driver may already advertise (e.g. Core, - // Recall) cannot be made minor-safe by negotiation the way a brand-new - // capability family can — an older driver still advertising that family - // would be called into a method it never implemented. This is why the - // module docs classify that addition as a MAJOR bump, not minor, even - // though it looks additive. This test exists so the rule cannot be - // re-derived from `is_compatible`'s code alone, which only encodes "major - // halves must match" and says nothing about *why* a same-family method - // addition belongs on the major side of that line. - assert!( - !is_compatible((CONTRACT_VERSION.0 + 1, 0)), - "a method added to an existing family must ship as a major bump, \ - which this asserts refuses the bind against an old build" - ); -} - -#[test] -fn compatibility_depends_only_on_the_major_half() { - let (major, _) = CONTRACT_VERSION; - for minor in [0u16, 1, 2, 7, 999, u16::MAX] { - assert!( - is_compatible((major, minor)), - "minor {minor} should not affect compatibility" - ); - assert!( - !is_compatible((major + 1, minor)), - "minor {minor} must not rescue a major mismatch" - ); - } -} From 1144bc0fd7a69a602a5c769264e1cde0d5415a31 Mon Sep 17 00:00:00 2001 From: Shanu Date: Tue, 18 Aug 2026 14:32:53 +0530 Subject: [PATCH 2/2] Address review: pin the contract rev, drop dead deps, correct the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from @sanil-23's review, all valid. **Pin the contract by `rev`.** `4549cda222de3891b95e2fa58e2565bb2c194328` is what the lockfile already resolved, so this is zero behaviour change — checked against `Cargo.lock` rather than taken on faith. What it buys is that bumping the contract becomes a reviewable line in a diff rather than whatever the default branch happened to be when someone rebuilt. **Drop the eight now-dead dependencies.** `api/src/` is one file of `pub use tinymemory_api::{...}`, so anyhow, async-trait, chrono, serde, serde_json, sha2, thiserror and uuid were all unused. They were not free while they stayed: `sha2 = "0.10"` here against `sha2 0.11` in the contract gave `cargo tree -p tinycortex-api` a doubled hash stack. Now one version each — block-buffer 0.12.1, crypto-common 0.2.2, digest 0.11.3, sha2 0.11.0. The comment above `[dependencies]` justified chrono, sha2 and uuid by naming `chunks::Metadata`, `chunks::chunk_id` and `ToolMemoryRule::generate_id`. This crate defines none of those any more, so the comment was asserting something false about the very rule it exists to guard. It now describes the crate as it is, and notes that the no-SQLite/no-git2/no-reqwest rule applies transitively through the contract, which enforces it in its own CI. **Correct the module docs.** `cargo doc` passed because the intra-doc *links* still resolved; it cannot catch prose. The front page said this crate "holds" the value types (it names them), listed a dependency set that is no longer accurate, and said "thirteen capability families" twice while an implementation comment sixty lines below said the re-export grows it to eighteen. This is the first thing a driver author reads. **Fix the `[patch]` pointer.** It sent the reader to "the `[patch]` table in the workspace root" — which in *this* repo is `tinyagents`, unrelated. Worse, a `[patch.crates-io]` entry cannot override a git dependency at all. The comment now spells out the correct `[patch."https://github.com/tinyhumansai/tinymemory"]` form and says it is tinymemory's root that carries it. Validation: - `cargo fmt --all -- --check` — clean - `cargo clippy --all-targets -- -D warnings` (the CI gate) — clean - `cargo check --workspace --all-targets` — clean - `cargo test --workspace` — 1259 passed, 0 failed - `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` — clean - contract purity guard — 0 matches --- Cargo.lock | 10 +-------- api/Cargo.toml | 57 +++++++++++++++++++++++++------------------------- api/src/lib.rs | 17 ++++++++------- 3 files changed, 38 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b77e5cef..f9160471 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,21 +1753,13 @@ dependencies = [ name = "tinycortex-api" version = "0.1.1" dependencies = [ - "anyhow", - "async-trait", - "chrono", - "serde", - "serde_json", - "sha2 0.10.9", - "thiserror 2.0.19", "tinymemory-api", - "uuid", ] [[package]] name = "tinymemory-api" version = "0.1.1" -source = "git+https://github.com/tinyhumansai/tinymemory#4549cda222de3891b95e2fa58e2565bb2c194328" +source = "git+https://github.com/tinyhumansai/tinymemory?rev=4549cda222de3891b95e2fa58e2565bb2c194328#4549cda222de3891b95e2fa58e2565bb2c194328" dependencies = [ "anyhow", "async-trait", diff --git a/api/Cargo.toml b/api/Cargo.toml index 593bf3bb..f18fab65 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -8,25 +8,21 @@ license = "MIT" repository = "https://github.com/tinyhumansai/tinycortex" description = "Stable public contracts for the TinyCortex memory system" -# Deliberately dependency-light: this crate is the stable contract surface that -# hosts compile against, so it must stay free of native, async-runtime, and -# storage dependencies. Anything heavier belongs in the `tinycortex` engine -# crate, never here. -# -# The full set is intentionally small and pure-Rust. Beyond the -# serde/error/async-trait baseline it carries exactly three additions, each -# pulled in by a value type that has to keep behaving identically after the -# move out of the engine crate: -# -# - `chrono` — timestamps on chunk/tree nodes; the `serde` feature backs -# `chunks::Metadata`'s `chrono::serde::ts_milliseconds`. -# - `sha2` — the deterministic `chunks::chunk_id`. -# - `uuid` — `tool_memory::ToolMemoryRule::generate_id` (v4 bytes, nibble -# encoded). Only the `v4` feature is needed here; the engine -# crate additionally enables `serde`. +# One dependency, and that is the whole crate. `api/src/` is a single file of +# `pub use tinymemory_api::{...}`, so the value types, error enum, capability +# vocabulary and storage trait all come from there. +# +# The eight direct dependencies that used to sit here — anyhow, async-trait, +# chrono, serde, serde_json, sha2, thiserror, uuid — were each pulled in by a +# type this crate defined. It defines none of them now, so they are gone. They +# were not free while they stayed: `sha2 = "0.10"` here against `sha2 0.11` in +# `tinymemory-api` gave `cargo tree -p tinycortex-api` a doubled hash stack +# (block-buffer, crypto-common and digest each resolved twice). # # Nothing here may pull in `rusqlite`, `git2`, `reqwest`, `regex`, or an async -# runtime. Guard with the FORWARD form, which is scoped to this package: +# runtime. That rule now applies transitively through the contract rather than +# to a list kept here; `tinymemory-api` carries the same rule and enforces it in +# its own CI. Guard with the FORWARD form, which is scoped to this package: # # cargo tree -p tinycortex-api -e normal,build --prefix none \ # | grep -Ei 'rusqlite|libsqlite|git2|reqwest|regex|tokio' # expect no match @@ -40,15 +36,18 @@ description = "Stable public contracts for the TinyCortex memory system" # `tinymemory_api::types::MemoryEntry` are one type rather than two that a # conversion layer has to keep in step (tinymemory#18 §A1). # -# By git rather than by version because neither crate is published. A host that -# vendors both patches this entry to its own checkout — see the `[patch]` table -# in the workspace root — so a build never pulls two copies. -tinymemory-api = { git = "https://github.com/tinyhumansai/tinymemory" } -anyhow = "1" -async-trait = "0.1" -chrono = { version = "0.4", features = ["serde"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -thiserror = "2" -uuid = { version = "1", features = ["v4"] } +# By git rather than by version because neither crate is published, and pinned +# by `rev` because bumping the contract is a deliberate act that should be a +# reviewable line in a diff rather than whatever the default branch happened to +# be on the day someone rebuilt. +# +# A host that vendors both crates must patch this entry to its own checkout, or +# cargo resolves the git copy alongside the path copy and the two +# `MemoryEntry`s are different types. That needs a git-URL patch table — a +# `[patch.crates-io]` entry cannot override a git dependency: +# +# [patch."https://github.com/tinyhumansai/tinymemory"] +# tinymemory-api = { path = "api" } +# +# tinymemory's own workspace root carries exactly that. +tinymemory-api = { git = "https://github.com/tinyhumansai/tinymemory", rev = "4549cda222de3891b95e2fa58e2565bb2c194328" } diff --git a/api/src/lib.rs b/api/src/lib.rs index d9b32e5e..2115a2b1 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -8,12 +8,12 @@ //! no edit; what goes away is the second, nominally-distinct copy that a //! conversion layer had to keep in step by hand. //! -//! This crate holds the value types, error enum, capability vocabulary, and +//! This crate *names* the value types, error enum, capability vocabulary, and //! storage trait that both the `tinycortex` engine and its embedding hosts -//! compile against. It is deliberately dependency-light (serde / serde_json / -//! chrono / sha2 / anyhow / thiserror / async-trait / uuid only) so depending on -//! the contract never drags in SQLite, git2, reqwest, regex, or an async -//! runtime. +//! compile against — it no longer defines them. Its one dependency is +//! `tinymemory-api`, which holds the same "no SQLite, no git2, no reqwest, no +//! regex, no async runtime" rule and enforces it in its own CI, so depending on +//! this crate still drags none of them in. //! //! ## Self-contained by design //! @@ -43,10 +43,11 @@ //! - [`recall`]: the borrowed [`recall::RecallOpts`] and owned, serde-derived //! [`recall::OwnedRecallOpts`] recall filters (both re-exported from //! [`types`]). -//! - [`capabilities`]: the thirteen [`capabilities::Capability`] families and -//! the [`capabilities::Capabilities`] set negotiated at bind time. +//! - [`capabilities`]: the eighteen [`capabilities::Capability`] families and +//! the [`capabilities::Capabilities`] set negotiated at bind time. Thirteen +//! before the re-export; the extra five come with the contract. //! - [`provider`]: the driver contract — [`provider::MemoryProvider`] plus the -//! thirteen capability family traits and the value types they need. +//! capability family traits and the value types they need. //! - [`null`]: [`null::NullMemoryProvider`], the reference driver a //! compiled-out or unconfigured memory subsystem binds to. //! - [`health`]: [`health::MemoryHealth`], the liveness state a driver reports.