diff --git a/Cargo.lock b/Cargo.lock index e2740cd..f916047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1752,13 +1752,23 @@ dependencies = [ [[package]] name = "tinycortex-api" version = "0.1.1" +dependencies = [ + "tinymemory-api", +] + +[[package]] +name = "tinymemory-api" +version = "0.1.1" +source = "git+https://github.com/tinyhumansai/tinymemory?rev=4549cda222de3891b95e2fa58e2565bb2c194328#4549cda222de3891b95e2fa58e2565bb2c194328" dependencies = [ "anyhow", "async-trait", "chrono", + "log", + "schemars", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.19", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 3a737ce..9d5ad0e 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 cf4a1e8..f18fab6 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,30 +1,28 @@ [package] name = "tinycortex-api" +# Not published, for the same reason as the engine above. +publish = false version = "0.1.1" edition = "2021" 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 @@ -33,11 +31,23 @@ 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] -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"] } +# 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, 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/capabilities.rs b/api/src/capabilities.rs deleted file mode 100644 index 7c1e7d3..0000000 --- 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 1e5e550..0000000 --- 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 7770adb..0000000 --- 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 e7db31f..0000000 --- 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 c9e6405..0000000 --- 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 75d72bc..0000000 --- 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 697a867..0000000 --- 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 e67410c..0000000 --- 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 5a68444..0000000 --- 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 a31c965..0000000 --- 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 a0c3734..2115a2b 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,11 +1,19 @@ //! Stable public contracts for the TinyCortex memory system. //! -//! This crate holds the value types, error enum, capability vocabulary, and +//! # 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 *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 //! @@ -35,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. @@ -52,18 +61,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 482bc5c..0000000 --- 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 573ee90..0000000 --- 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 b215c4e..0000000 --- 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 07ec80e..0000000 --- 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 9c16e86..0000000 --- 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 842ce38..0000000 --- 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 45140e5..0000000 --- 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 4827848..0000000 --- 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 5fe65c9..0000000 --- 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 064697f..0000000 --- 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 1c9c3d2..0000000 --- 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 390e59a..0000000 --- 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 4d82619..0000000 --- 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 5adf317..0000000 --- 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 8699d6d..0000000 --- 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 821369e..0000000 --- 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 d1b788c..0000000 --- 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 068dc00..0000000 --- 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 bb9965f..0000000 --- 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 b683b2f..0000000 --- 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 5ee61b5..0000000 --- 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 2ccf329..0000000 --- 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 1b3a4c0..0000000 --- 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" - ); - } -}