diff --git a/Cargo.lock b/Cargo.lock index 156fa1f..7c1587a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1903,15 +1903,25 @@ version = "0.1.1" dependencies = [ "anyhow", "async-trait", - "chrono", "log", "schemars", "serde", "serde_json", - "sha2 0.11.0", - "thiserror 2.0.20", + "tinymemory-bus", "tokio", "toml", +] + +[[package]] +name = "tinymemory-bus" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 65bd18d..2d36b67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = ["crates/*"] default-members = [ "crates/tinymemory", "crates/tinymemory-api", + "crates/tinymemory-bus", "crates/tinymemory-conformance", "crates/tinymemory-core", "crates/tinymemory-remote", diff --git a/README.md b/README.md index ad0473f..4458862 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,17 @@ crates/ │ │ binds as, and the fail-closed external-driver gate │ ├── tests/ integration tests against the public API only │ └── examples/ runnable, compiled-in-CI usage examples -├── tinymemory-api/ the contract. Dependency-light on purpose: depending on -│ it never drags in SQLite, git2, reqwest, or an async -│ runtime +├── tinymemory-api/ the driver contract: the traits an engine implements and +│ the host seam it binds through, plus every +│ `tinymemory-bus` type re-exported at its historical path. +│ Dependency-light on purpose: depending on it never drags +│ in SQLite, git2, reqwest, or an async runtime +├── tinymemory-bus/ the wire vocabulary: every type that crosses the module +│ boundary, plus the member names. Sits *below* the +│ contract — `tinymemory-api` depends on it and re-exports +│ it — so a host that only makes calls into +│ `tinymemory-module` links this alone and compiles no +│ traits, no null driver and no config surface ├── tinymemory-core/ the substance: ingestion, the summary tree, chunk │ storage, entities, the graph, the diff ledger, goals, │ tool-memory, and the Composio sync layer. The largest diff --git a/clippy.toml b/clippy.toml index e2d8ae6..8485eec 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,4 +2,15 @@ # a code item that forgot its backticks. These are product and technology names # written as prose on purpose; backticking them would imply they name a Rust # item. `..` keeps clippy's own default list rather than replacing it. -doc-valid-idents = ["..", "TinyMemory", "TinyCortex", "OpenHuman", "SQLite", "snake_case"] +doc-valid-idents = [ + "..", + "TinyMemory", + "TinyCortex", + "OpenHuman", + "TinyBus", + "SQLite", + "snake_case", + # Product names in `chunks::SourceKind`'s prose, not Rust items. + "WhatsApp", + "FastMail", +] diff --git a/crates/tinymemory-api/Cargo.toml b/crates/tinymemory-api/Cargo.toml index cf68b27..4ff3c99 100644 --- a/crates/tinymemory-api/Cargo.toml +++ b/crates/tinymemory-api/Cargo.toml @@ -11,28 +11,29 @@ license = "MIT" repository = "https://github.com/tinyhumansai/tinymemory" description = "Stable public contracts for the TinyMemory 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. +# Deliberately dependency-light: this crate is the driver contract an engine +# compiles 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: +# The set shrank when the payload vocabulary moved to `tinymemory-bus`: +# `chrono`, `sha2` and `uuid` went with the types that needed them +# (`chunks::Metadata`, `chunks::chunk_id`, `ToolMemoryRule::generate_id`), and +# `thiserror` went with `MemoryError`. What is left is what the *traits* and the +# host seam need: # -# - `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`. -# - `schemars` — the `host::` config sections are still fields of the host's -# root `Config`, which derives `JsonSchema` to generate the -# settings schema the UI renders. Dropping the derive on the way -# down here would silently shrink that schema. `schemars` is pure -# Rust (serde + serde_json + dyn-clone + ref-cast) and carries -# none of the forbidden dependencies below. +# - `async-trait` — every capability-family trait is `async fn` on an +# object-safe trait. +# - `anyhow` — `traits::Memory` and the mandatory composition are +# anyhow-typed. +# - `schemars` — the `host::` config sections are still fields of the host's +# root `Config`, which derives `JsonSchema` to generate the +# settings schema the UI renders. Dropping the derive on the +# way down here would silently shrink that schema. `schemars` +# is pure Rust (serde + serde_json + dyn-clone + ref-cast). +# - `log` — the `host::cloud_providers` legacy-field migration logs what +# it rewrote. The zero-dependency facade, not an +# implementation. # # Nothing here may pull in `rusqlite`, `git2`, `reqwest`, `regex`, or an async # runtime. Guard with the FORWARD form, which is scoped to this package: @@ -44,18 +45,17 @@ description = "Stable public contracts for the TinyMemory memory system" # scope and prints the whole-workspace inverse tree, so it exits 0 and looks # clean even when this crate is the one pulling the dependency in. [dependencies] +# The wire vocabulary. Every payload type this crate exposes is defined there +# and re-exported here, so a host that only makes calls into the loadable module +# can depend on that crate alone and compile none of the traits, the null +# driver, or the `host::` config surface. See `src/lib.rs`. +tinymemory-bus = { path = "../tinymemory-bus" } anyhow = "1" async-trait = "0.1" -chrono = { version = "0.4", features = ["serde"] } -# `log` is the zero-dependency logging facade, not an implementation. The -# `host::cloud_providers` legacy-field migration logs what it rewrote. log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.2" -sha2 = "0.11" -thiserror = "2" -uuid = { version = "1", features = ["v4"] } [dev-dependencies] # The moved `host::` config sections are parsed from TOML in their own tests, diff --git a/crates/tinymemory-api/src/host/mod.rs b/crates/tinymemory-api/src/host/mod.rs index d3b4352..e3b713f 100644 --- a/crates/tinymemory-api/src/host/mod.rs +++ b/crates/tinymemory-api/src/host/mod.rs @@ -52,7 +52,6 @@ mod embedding_host; mod embeddings; mod error_reporter; mod events; -mod evidence; mod nlp; mod routes; mod usage; @@ -72,7 +71,6 @@ pub use events::{ EmbeddingHealthReason, MemoryEvent, MemoryEventSink, NoopEventSink, SyncTrigger, LOCAL_MODEL_UNAVAILABLE_KIND, MEMORY_USER_ERROR_SOURCE, }; -pub use evidence::EvidenceRef; pub use local_ai::{LocalAiConfig, LocalAiUsage}; pub use nlp::{SpacyEntity, SpacyResponse}; pub use routes::EmbeddingRouteConfig; @@ -84,6 +82,7 @@ pub use storage_memory::{ pub use subsystems::{ MemoryDriverConfig, MemoryHooksConfig, MemorySubsystemConfig, SubsystemsConfig, }; +pub use tinymemory_bus::evidence::EvidenceRef; pub use usage::UsageInfo; /// Effective default global memory-sync cadence (seconds) used when diff --git a/crates/tinymemory-api/src/lib.rs b/crates/tinymemory-api/src/lib.rs index cb7d52c..a8eff52 100644 --- a/crates/tinymemory-api/src/lib.rs +++ b/crates/tinymemory-api/src/lib.rs @@ -1,15 +1,32 @@ //! Stable public contracts for the TinyMemory memory system. //! -//! This crate holds the value types, error enum, capability vocabulary, and -//! storage trait that memory engines and their embedding hosts compile -//! against. It is engine-neutral on purpose: `tinycortex` is the default -//! embedded engine, not the owner of the contract, and a second engine -//! (`supermemory`, `mem0`, a self-hosted HTTP backend) implements the same -//! traits without either engine learning about the other. -//! 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. +//! This crate holds the traits a memory engine implements, the host seam it is +//! bound through, and — re-exported from [`tinymemory_bus`] — the value types, +//! error enum and capability vocabulary they exchange. It is engine-neutral on +//! purpose: `tinycortex` is the default embedded engine, not the owner of the +//! contract, and a second engine (`supermemory`, `mem0`, a self-hosted HTTP +//! backend) implements the same traits without either engine learning about the +//! other. It is deliberately dependency-light (serde / serde_json / anyhow / +//! async-trait / schemars / log, plus `tinymemory-bus`) so depending on the +//! contract never drags in SQLite, git2, reqwest, regex, or an async runtime. +//! +//! ## The vocabulary lives one layer down +//! +//! Every payload type is defined in [`tinymemory_bus`] and re-exported here at +//! its historical path, so `tinymemory_api::types::MemoryEntry` is the *same +//! item* as `tinymemory_bus::types::MemoryEntry`, not a structural twin. +//! +//! The split follows what a consumer actually needs. A **driver author** +//! implements [`provider::MemoryProvider`] and wants this crate: traits, the +//! null driver, the mandatory composition, the [`host`] seam. A **host** loads +//! `tinymemory-module` over `TinyBus` and only makes calls — it names +//! `MemoryEntry` and `MemoryCategory` and implements nothing — so it depends on +//! `tinymemory-bus` alone and compiles none of this. +//! +//! Defining a second set of payload types for that host was the alternative, +//! and it is the failure the root manifest's `[patch]` table exists to prevent: +//! `MemoryCategory` from the module would not be `MemoryCategory` in the host, +//! with a conversion at every call site that nothing type-checks. //! //! ## Self-contained by design //! @@ -64,13 +81,26 @@ //! round-trips [`error::MemoryError`] through. Shared by both ends of every //! such transport, so the names cannot drift apart. -pub mod capabilities; -pub mod chunks; pub mod drivers; -pub mod error; -pub mod goals; -pub mod health; pub mod host; + +// The wire vocabulary, re-exported from `tinymemory-bus`. +// +// These modules used to be defined here. They moved down a layer because a +// *host* needs them and needs nothing else in this crate: it loads +// `tinymemory-module` and makes calls, so it names `MemoryEntry` and +// `MemoryCategory` but implements no trait, binds no driver and parses no +// config. Making it depend on the whole driver contract to spell a payload type +// was the wrong shape. +// +// Re-exported rather than merely available, so every historical path still +// resolves — `tinymemory_api::types::MemoryEntry` is the same item as +// `tinymemory_bus::types::MemoryEntry`, not a twin of it. That identity is the +// point: a second definition would need a conversion at the module seam that +// nothing type-checks. +pub use tinymemory_bus::{ + capabilities, chunks, error, goals, health, recall, tool_memory, tree, types, version, wire, +}; /// The mandatory-family composition: wrap any [`traits::Memory`] backend as a /// complete [`provider::MemoryProvider`]. /// @@ -84,12 +114,6 @@ pub mod host; pub mod mandatory; 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; -pub mod wire; -pub use version::{is_compatible, CONTRACT_VERSION}; +pub use tinymemory_bus::{is_compatible, CONTRACT_VERSION}; diff --git a/crates/tinymemory-api/src/provider/chunks.rs b/crates/tinymemory-api/src/provider/chunks.rs index 34c7635..1a8f96d 100644 --- a/crates/tinymemory-api/src/provider/chunks.rs +++ b/crates/tinymemory-api/src/provider/chunks.rs @@ -31,90 +31,16 @@ //! `docs/specs/2026-08-13-memory-module-port.md` §3. use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use crate::chunks::{Chunk, SourceKind}; +use crate::chunks::Chunk; use crate::error::MemoryError; use crate::provider::types::SourceScope; -/// Filters for [`MemoryChunks::list_chunks`]. -/// -/// Every field is optional and they compose with AND. The default matches -/// everything the scope allows, bounded by the driver's own safety cap. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ChunkQuery { - /// Restrict to one source kind. - #[serde(default)] - pub source_kind: Option, - /// Restrict to one logical source id. - #[serde(default)] - pub source_id: Option, - /// Restrict to one owner. - #[serde(default)] - pub owner: Option, - /// Inclusive lower bound on source time, epoch milliseconds. - #[serde(default)] - pub since_ms: Option, - /// Inclusive upper bound on source time, epoch milliseconds. - #[serde(default)] - pub until_ms: Option, - /// Maximum rows. The driver clamps this to its own cap — a caller cannot - /// raise the ceiling by asking for more. - #[serde(default)] - pub limit: Option, - /// Rows to skip, for pagination. - #[serde(default)] - pub offset: Option, - /// Drop chunks marked dropped by the lifecycle. - #[serde(default)] - pub exclude_dropped: bool, -} - -/// One chunk's stored embedding. -/// -/// Returned as a list rather than a map because the wire form of a map keyed by -/// chunk id is a JSON object, and an id is caller-supplied text; a list keeps -/// the encoding independent of what an id happens to contain. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ChunkEmbedding { - /// The chunk this vector belongs to. - pub chunk_id: String, - /// The vector, in the embedding space named by the requested signature. - pub vector: Vec, -} - -/// One chunk plus the per-chunk facts stored beside it. -/// -/// # Why a detail view rather than four accessors -/// -/// An inspection caller wants the row, its body, where the body lives, its -/// lifecycle state and whether it has been embedded. Exposing those as four -/// methods would read naturally in-process and cost **four bus round trips per -/// row** out of it — and this is used to render lists. One method, one trip. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ChunkDetail { - /// The chunk row. - pub chunk: Chunk, - /// The chunk's body as stored in the content vault, when it could be read. - /// - /// `None` means the vault read failed — distinct from an empty body, which - /// is a legitimately empty chunk. A caller rendering a preview should fall - /// back to [`Chunk::content`] rather than showing nothing. - #[serde(default)] - pub body: Option, - /// Path of the body in the content vault, when it has one. - #[serde(default)] - pub content_path: Option, - /// Lifecycle state (`active`, `dropped`, …); `None` when unrecorded. - #[serde(default)] - pub lifecycle_status: Option, - /// Whether an embedding vector exists for this chunk in **any** space. - /// - /// Not scoped to a signature on purpose: this answers "has this been - /// embedded at all", which is what an inspection view wants. Asking whether - /// a *particular* space has it is [`MemoryChunks::chunk_embeddings`]. - pub has_embedding: bool, -} +// The value types this family exchanges. They are defined in `tinymemory-bus` +// — they cross the module boundary, and a host that only makes calls must be +// able to name them without compiling this trait — and re-exported here so +// every historical path keeps resolving and the types stay the same types. +pub use tinymemory_bus::provider::chunks::{ChunkDetail, ChunkEmbedding, ChunkQuery}; /// Direct read access to the chunk tier. /// diff --git a/crates/tinymemory-api/src/provider/episodic.rs b/crates/tinymemory-api/src/provider/episodic.rs index 0828bda..aaaeaab 100644 --- a/crates/tinymemory-api/src/provider/episodic.rs +++ b/crates/tinymemory-api/src/provider/episodic.rs @@ -41,79 +41,14 @@ //! knows the id it just wrote; nothing else has to guess. use async_trait::async_trait; -use serde::{Deserialize, Serialize}; use crate::error::MemoryError; -/// One recorded turn. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct EpisodicTurn { - /// Row id, assigned by the driver on insert. - /// - /// `None` when the host is describing a turn to be written; always `Some` - /// on a turn read back. - #[serde(default)] - pub id: Option, - /// Session this turn belongs to. - pub session_id: String, - /// When it happened, epoch seconds with sub-second resolution. - /// - /// The archivist offsets an assistant turn by 1 ms from the user turn it - /// answers so the pair sorts in order within one exchange; that convention - /// is the host's and the driver must preserve the value it is given rather - /// than re-stamping it. - pub timestamp: f64, - /// `"user"` or `"assistant"`. Open vocabulary — a driver must not reject an - /// unfamiliar role. - pub role: String, - /// The turn's text. - pub content: String, - /// A short lesson extracted from tool failures, when there was one. - #[serde(default)] - pub lesson: Option, - /// Serialized tool-call summary, when the turn made any. - #[serde(default)] - pub tool_calls_json: Option, - /// Cost attributed to this turn, in microdollars. - #[serde(default)] - pub cost_microdollars: i64, -} - -/// A stretch of consecutive turns about one subject. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ConversationSegment { - /// Stable id, chosen by the host. - pub segment_id: String, - /// Session the segment belongs to. - pub session_id: String, - /// Owning namespace. - pub namespace: String, - /// Row id of the first turn in the segment. - pub start_episodic_id: i64, - /// Row id of the last turn, once one has been appended. - #[serde(default)] - pub end_episodic_id: Option, - /// Timestamp of the first turn. - pub start_timestamp: f64, - /// Timestamp of the last turn, once one has been appended. - #[serde(default)] - pub end_timestamp: Option, - /// How many turns the segment holds. - pub turn_count: i32, - /// Summary, once the segment has been closed and summarised. - #[serde(default)] - pub summary: Option, - /// The segment's running embedding centroid, when it has one. - /// - /// Carried on the read so the host can run boundary detection against it - /// without a second call: deciding whether the next turn still belongs to - /// this segment is host policy, but it needs the centroid the driver - /// holds. - #[serde(default)] - pub embedding: Option>, - /// Whether the segment is still open. - pub open: bool, -} +// The value types this family exchanges. They are defined in `tinymemory-bus` +// — they cross the module boundary, and a host that only makes calls must be +// able to name them without compiling this trait — and re-exported here so +// every historical path keeps resolving and the types stay the same types. +pub use tinymemory_bus::provider::episodic::{ConversationSegment, EpisodicTurn}; /// The turn-by-turn conversation record. /// diff --git a/crates/tinymemory-api/src/provider/mod.rs b/crates/tinymemory-api/src/provider/mod.rs index ea3235b..74e7bb7 100644 --- a/crates/tinymemory-api/src/provider/mod.rs +++ b/crates/tinymemory-api/src/provider/mod.rs @@ -68,7 +68,10 @@ pub mod people; pub mod profile; pub mod records; pub mod retrieval; -pub mod types; +// The value types every family exchanges, defined in `tinymemory-bus` and +// re-exported at their historical path. See this crate's `lib.rs` for why the +// vocabulary sits a layer below the traits. +pub use tinymemory_bus::provider::types; pub use audit::{audit_provider, CapabilityAudit}; pub use chunks::{ChunkDetail, ChunkEmbedding, ChunkQuery, MemoryChunks}; diff --git a/crates/tinymemory-api/src/provider/people.rs b/crates/tinymemory-api/src/provider/people.rs index a525d40..f2da240 100644 --- a/crates/tinymemory-api/src/provider/people.rs +++ b/crates/tinymemory-api/src/provider/people.rs @@ -32,133 +32,17 @@ //! not parse one out — it round-trips an id it was given and nothing more. use async_trait::async_trait; -use serde::{Deserialize, Serialize}; use crate::error::MemoryError; -/// Opaque identity of one person, as the driver issued it. -/// -/// Treat as a token: round-trip it, compare it for equality, never parse it. -pub type PersonRef = String; - -/// One way a person is addressed. -/// -/// The driver is responsible for canonicalising these before storing or -/// looking up — case folding an email, trimming a handle, collapsing whitespace -/// in a display name. Two handles that canonicalise alike must resolve to the -/// same person, which is why callers pass the raw form and never a -/// pre-normalised one: normalisation that differed between caller and driver -/// would silently mint duplicate people. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", content = "value", rename_all = "snake_case")] -pub enum PersonHandle { - /// An iMessage handle — a phone number or an Apple ID. - IMessage(String), - /// An email address. - Email(String), - /// A human-readable display name. - DisplayName(String), -} - -/// One person as the driver holds them. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct PersonRecord { - /// Driver-issued identity. - pub id: PersonRef, - /// Best-known display name, when one is known. - #[serde(default)] - pub display_name: Option, - /// Primary email, when one is known. - #[serde(default)] - pub primary_email: Option, - /// Primary phone number, when one is known. - #[serde(default)] - pub primary_phone: Option, - /// Every handle this person is known by, canonicalised. - #[serde(default)] - pub handles: Vec, - /// Creation time, RFC 3339. - pub created_at: String, - /// Last-update time, RFC 3339. - pub updated_at: String, -} - -/// Per-component breakdown of a closeness score, each in `[0, 1]`. -/// -/// Exposed rather than collapsed to one number so a caller can explain a -/// ranking. The components are **not** comparable across drivers: each engine -/// picks its own half-life and depth proxy, so compare within one driver's -/// results only. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct PersonScore { - /// How recently the person was interacted with. - pub recency: f32, - /// How often. - pub frequency: f32, - /// How two-sided the exchange is — one-sided contact scores zero. - pub reciprocity: f32, - /// How substantial each interaction is. - pub depth: f32, - /// The composite, clamped to `[0, 1]`. - pub score: f32, - /// How many interactions the score was computed from. - /// - /// Travels with the score rather than beside it, because a score cannot be - /// read honestly without it: 0.9 from three exchanges and 0.9 from three - /// hundred are the same number and very different facts. Every caller that - /// gets a score gets the sample size, and no caller has to remember to ask. - #[serde(default)] - pub interaction_count: usize, -} - -/// A person together with their score, as returned by a ranked list. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RankedPerson { - /// The person. - pub person: PersonRecord, - /// Their closeness score, including the interaction count it was computed - /// from. - pub score: PersonScore, -} - -/// The outcome of resolving a handle. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResolvedPerson { - /// Who the handle resolved to. - pub id: PersonRef, - /// Whether this call minted the person rather than finding them. - /// - /// Distinguished so a caller can tell "I now know who this is" from "I have - /// just invented someone", which read identically from the id alone. - pub created: bool, -} - -/// One observed interaction, as reported by the host. -/// -/// The host owns the channels, so it observes these; the driver only stores and -/// aggregates them. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct PersonInteraction { - /// Who the interaction was with. - pub person_id: PersonRef, - /// When it happened, RFC 3339. - pub at: String, - /// `true` when the user sent it. This is what drives reciprocity, so an - /// importer that cannot tell direction should not guess. - pub is_outbound: bool, - /// A proxy for substance — token or character count. Clamped during - /// scoring, so an outlier cannot dominate a ranking. - pub length: u32, -} - -/// What an address-book seed actually did. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct AddressBookSeedOutcome { - /// People created or updated from the address book. - pub seeded: usize, - /// Contacts skipped — no usable handle, or a write that failed. - pub skipped: usize, -} +// The value types this family exchanges. They are defined in `tinymemory-bus` +// — they cross the module boundary, and a host that only makes calls must be +// able to name them without compiling this trait — and re-exported here so +// every historical path keeps resolving and the types stay the same types. +pub use tinymemory_bus::provider::people::{ + AddressBookSeedOutcome, PersonHandle, PersonInteraction, PersonRecord, PersonRef, PersonScore, + RankedPerson, ResolvedPerson, +}; /// Contacts, handle resolution, and closeness scoring. /// diff --git a/crates/tinymemory-api/src/provider/profile.rs b/crates/tinymemory-api/src/provider/profile.rs index 51009f5..57fe43c 100644 --- a/crates/tinymemory-api/src/provider/profile.rs +++ b/crates/tinymemory-api/src/provider/profile.rs @@ -29,160 +29,14 @@ //! keep the thing the user asked to forget on disk indefinitely. use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use crate::error::MemoryError; -use crate::host::EvidenceRef; -/// What kind of claim a facet makes. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FacetType { - /// A stated or inferred preference. - Preference, - /// A way of working. Persisted as `skill` for historical reasons. - Workflow, - /// A role the user holds. - Role, - /// A personality trait. - Personality, - /// Ambient context about the user's situation. - Context, -} - -impl FacetType { - /// The identifier persisted in the facet table and published on the RPC - /// surface. - /// - /// **This is not the serde representation**, and the difference is - /// deliberate: [`Self::Workflow`] serialises as `workflow` but persists as - /// `skill`, a historical column value. Both forms are load-bearing — the - /// serde one crosses the bus, this one reaches storage and the published - /// JSON — so they are kept separate rather than reconciled. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Preference => "preference", - Self::Workflow => "skill", - Self::Role => "role", - Self::Personality => "personality", - Self::Context => "context", - } - } - - /// Parse a persisted identifier; unknown values fall back to - /// [`Self::Preference`], matching the engine's own lenient reader. - #[must_use] - pub fn parse_or_default(raw: &str) -> Self { - match raw { - "skill" => Self::Workflow, - "role" => Self::Role, - "personality" => Self::Personality, - "context" => Self::Context, - _ => Self::Preference, - } - } -} - -/// Where a facet sits in its lifecycle, as the host's stability detector last -/// left it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FacetState { - /// Cleared the promotion threshold; included in the ambient profile. - #[default] - Active, - /// Between the provisional and promotion thresholds; included at lower - /// weight. - Provisional, - /// Between eviction and provisional; held as a candidate. - Candidate, - /// Below the eviction threshold; removed on the next rebuild. - Dropped, -} - -impl FacetState { - /// Stable identifier, matching the serde representation. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Provisional => "provisional", - Self::Candidate => "candidate", - Self::Dropped => "dropped", - } - } -} - -/// The user's explicit override, which outranks [`FacetState`]. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum UserState { - /// No override — the host's detector manages the lifecycle. - #[default] - Auto, - /// Pinned by the user: stays active regardless of score. - Pinned, - /// Forgotten by the user: stays dropped, and new evidence must not - /// re-promote it. - Forgotten, -} - -impl UserState { - /// Stable identifier, matching the serde representation. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Auto => "auto", - Self::Pinned => "pinned", - Self::Forgotten => "forgotten", - } - } -} - -/// One learned claim about the user. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ProfileFacet { - /// Stable identity of this facet row. - pub facet_id: String, - /// What kind of claim it makes. - pub facet_type: FacetType, - /// The claim's key, e.g. `style/verbosity`. - pub key: String, - /// The claim's value. - pub value: String, - /// How confident the extraction was, in `[0, 1]`. - pub confidence: f64, - /// How many pieces of evidence support it. - pub evidence_count: i32, - /// Legacy segment-id references, when present. - #[serde(default)] - pub source_segment_ids: Option, - /// First observation, epoch seconds. - pub first_seen_at: f64, - /// Most recent observation, epoch seconds. - pub last_seen_at: f64, - /// Lifecycle state, assigned by the host. - #[serde(default)] - pub state: FacetState, - /// Stability score from the host's last rebuild. - #[serde(default)] - pub stability: f64, - /// The user's override. - #[serde(default)] - pub user_state: UserState, - /// Where the evidence came from. - #[serde(default)] - pub evidence_refs: Vec, - /// Facet class derived from the key prefix (`style`, `identity`, …). - /// `None` for rows whose key prefix matches no known class. - #[serde(default)] - pub class: Option, - /// Per-cue-family evidence counts, once the host has written a rebuild. - #[serde(default)] - pub cue_families: Option>, -} +// The value types this family exchanges. They are defined in `tinymemory-bus` +// — they cross the module boundary, and a host that only makes calls must be +// able to name them without compiling this trait — and re-exported here so +// every historical path keeps resolving and the types stay the same types. +pub use tinymemory_bus::provider::profile::{FacetState, FacetType, ProfileFacet, UserState}; /// Learned facets about the user. /// diff --git a/crates/tinymemory-api/src/provider/retrieval.rs b/crates/tinymemory-api/src/provider/retrieval.rs index 6ae78c9..666396d 100644 --- a/crates/tinymemory-api/src/provider/retrieval.rs +++ b/crates/tinymemory-api/src/provider/retrieval.rs @@ -35,145 +35,19 @@ //! look identical to a genuine empty result. use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use crate::chunks::SourceKind; use crate::error::MemoryError; use crate::provider::types::SourceScope; use crate::types::NamespaceMemoryHit; -/// Whether a hit is a raw leaf or a sealed summary. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RetrievalNodeKind { - /// A stored chunk, tree level 0. - Leaf, - /// A sealed summary node, tree level ≥ 1. - Summary, -} - -/// One ranked retrieval result. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RetrievalHit { - /// Chunk id for a leaf, summary-node id for a summary. Globally unique. - pub node_id: String, - /// Leaf or summary. - pub node_kind: RetrievalNodeKind, - /// Provenance tree id; empty for a bare leaf not yet sealed into a tree. - #[serde(default)] - pub tree_id: String, - /// Human-readable tree scope, e.g. `slack:#eng`; empty for a bare leaf. - #[serde(default)] - pub tree_scope: String, - /// Tree level: 0 for a leaf chunk, ≥ 1 for a summary. - pub level: u32, - /// Raw chunk text, or sealed summary text. - pub content: String, - /// Canonical entity ids referenced by this node; empty on leaves. - #[serde(default)] - pub entities: Vec, - /// Topic tags for this node. - #[serde(default)] - pub topics: Vec, - /// Inclusive start of the node's time coverage. - pub time_range_start: DateTime, - /// Inclusive end of the node's time coverage. - pub time_range_end: DateTime, - /// Relevance, higher is better. - /// - /// **Not comparable across primitives or across drivers.** A `fast_retrieve` - /// score and a `cover_window` score are produced by different rankers; - /// merging two result sets by score would be meaningless. - pub score: f32, - /// Ids one level down; empty on leaves. - #[serde(default)] - pub child_ids: Vec, - /// Chunk back-pointer, populated for leaves only. - #[serde(default)] - pub source_ref: Option, -} - -/// A page of ranked hits. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct RetrievalResponse { - /// The hits, already filtered, ranked and truncated to the caller's limit. - pub hits: Vec, - /// Total matches **before** truncation. - pub total: usize, - /// `true` when `total > hits.len()`, i.e. a higher limit would return more. - /// - /// Carried explicitly rather than left for the caller to derive: it is the - /// difference between "there is nothing else" and "there is more, ask - /// again", and a caller that computed it from a page alone could not tell. - pub truncated: bool, -} - -/// Options for [`MemoryRetrieval::fast_retrieve`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct FastRetrieveQuery { - /// Maximum hits to return. - pub limit: usize, - /// How many graph hops to expand from the seed entities. - pub max_hops: u32, - /// Restrict to the last N days of source time. - #[serde(default)] - pub time_window_days: Option, -} - -/// A time window to cover. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct CoverWindowQuery { - /// Inclusive lower bound, epoch milliseconds. - pub since_ms: i64, - /// Inclusive upper bound, epoch milliseconds. - pub until_ms: i64, - /// Restrict to one logical source. - #[serde(default)] - pub source_id: Option, - /// Restrict to one source kind. - #[serde(default)] - pub source_kind: Option, - /// Maximum nodes in the cover. - #[serde(default)] - pub limit: Option, -} - -/// Filters for [`MemoryRetrieval::retrieve_source`]. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct SourceRetrievalQuery { - /// Restrict to one logical source (the engine's "scope", e.g. `slack:#eng`). - #[serde(default)] - pub source_id: Option, - /// Restrict to one source kind. - #[serde(default)] - pub source_kind: Option, - /// Restrict to the last N days of source time. - #[serde(default)] - pub time_window_days: Option, - /// Free-text query to rank against. `None` returns the newest nodes rather - /// than ranking — the primitive is a browse as well as a search. - #[serde(default)] - pub query: Option, - /// Maximum hits. - pub limit: usize, -} - -/// One entity-index match. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct EntityMatch { - /// Canonical id, e.g. `email:alice@example.com` or `topic:phoenix`. - pub canonical_id: String, - /// Entity classification. An **open** snake_case vocabulary — see the - /// module docs for why this is not an enum. - pub kind: String, - /// An example surface form that matched, for display. - pub surface: String, - /// Rows grouped under this canonical id. - pub mention_count: u64, - /// Epoch milliseconds of the newest mention. - pub last_seen_ms: i64, -} +// The value types this family exchanges. They are defined in `tinymemory-bus` +// — they cross the module boundary, and a host that only makes calls must be +// able to name them without compiling this trait — and re-exported here so +// every historical path keeps resolving and the types stay the same types. +pub use tinymemory_bus::provider::retrieval::{ + CoverWindowQuery, EntityMatch, FastRetrieveQuery, RetrievalHit, RetrievalNodeKind, + RetrievalResponse, SourceRetrievalQuery, +}; /// The engine's deterministic retrieval primitives. /// diff --git a/crates/tinymemory-bus/Cargo.toml b/crates/tinymemory-bus/Cargo.toml new file mode 100644 index 0000000..881e888 --- /dev/null +++ b/crates/tinymemory-bus/Cargo.toml @@ -0,0 +1,71 @@ +[package] +name = "tinymemory-bus" +# Not published, for the same reason `tinymemory-api` is not: the graph below it +# reaches crates that are not on crates.io. A host takes this by git or by path. +publish = false +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +license = "MIT" +repository = "https://github.com/tinyhumansai/tinymemory" +description = "The TinyBus wire contract for the TinyMemory module: member names, payload types, and typed calls" + +# Deliberately dependency-light: this is the crate a host links to talk to the +# loadable module, so it must cost that host almost nothing. Nothing here may +# pull in `rusqlite`, `git2`, `reqwest`, `regex`, an async runtime, or +# `tinybus` — see `src/lib.rs` for why the transport in particular is absent. +# +# The set is the same one the payload types carried when they lived in +# `tinymemory-api`, minus everything only the traits and the host config needed: +# +# - `chrono` — timestamps on chunk, tree and retrieval 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). +# - `anyhow` — `error::MemoryError::Other`, which carries an opaque cause. +# - `thiserror`— the `MemoryError` and `CapabilityError` enums. +# +# Guard with the FORWARD form, which is scoped to this package — `cargo tree -i` +# discards the `-p` scope and exits clean even when this crate is the one +# pulling the dependency in: +# +# cargo tree -p tinymemory-bus -e normal,build --prefix none \ +# | grep -Ei 'rusqlite|libsqlite|git2|reqwest|regex|tokio|tinybus' # expect no match +[dependencies] +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +thiserror = "2" +uuid = { version = "1", features = ["v4"] } + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" +missing_debug_implementations = "warn" +unreachable_pub = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } + +[lints.clippy] +all = { level = "warn", priority = -1 } +# `pedantic` is deliberately not enabled, matching `tinymemory-tinycortex` and +# `tinymemory-remote`. These modules moved here verbatim from +# `tinymemory-api`, which carries no `[lints]` table at all; switching pedantic +# on over the move would bury a mechanical relocation under several hundred +# unrelated `#[must_use]` and backtick edits. Turning it on is worth doing — as +# its own commit, over `tinymemory-api` too, so the contract and the vocabulary +# stay lint-compatible. +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" +todo = "warn" +unimplemented = "warn" +missing_errors_doc = "warn" +missing_panics_doc = "warn" +doc_markdown = "warn" + +[lints.rustdoc] +broken_intra_doc_links = "warn" +private_intra_doc_links = "warn" diff --git a/crates/tinymemory-bus/README.md b/crates/tinymemory-bus/README.md new file mode 100644 index 0000000..5598697 --- /dev/null +++ b/crates/tinymemory-bus/README.md @@ -0,0 +1,115 @@ +# tinymemory-bus + +Every type that crosses the TinyMemory `TinyBus` boundary, and the names of the +members that carry them. + +TinyMemory ships as a loadable module so a host does not compile the engine: +`crates/tinymemory-module` exports one object with 89 members on it, built as a +`cdylib`. A host can load that binary but cannot `use` anything out of it, so +the payload vocabulary has to be published as an ordinary library. This is it. + +| module | what it holds | +| ---------------------------------------------------------------- | ---------------------------------------------- | +| `names` | bus name, object path, one constant per member | +| `types`, `chunks`, `recall`, `tree`, `goals`, `tool_memory`, `health`, `capabilities`, `evidence` | the value vocabulary | +| `provider` | the value types each capability family exchanges | +| `error`, `wire` | `MemoryError` and the name table it round-trips through | +| `version` | `CONTRACT_VERSION` and the bind rule | + +Seven dependencies, all pure Rust: `serde`, `serde_json`, `chrono`, `sha2`, +`uuid`, `anyhow`, `thiserror`. + +## This crate sits underneath `tinymemory-api` + +`tinymemory-api` **depends on this crate and re-exports all of it**. That +direction matters, and it is the opposite of the obvious one. + +The payload types used to live in `tinymemory-api`. They moved down because a +*host* needs them and needs nothing else in that crate: it loads the module and +makes calls, so it names `MemoryEntry` and `MemoryCategory` but implements no +trait, binds no driver and parses no config. Making it depend on the whole +driver contract to spell a payload type was the wrong shape. + +The alternative — a parallel set of payload types for hosts — is worse, and the +repository has already had the equivalent bug: when `tinymemory-api` resolved +twice, `MemoryCategory` from one copy was not the same type as `MemoryCategory` +from the other, and the mismatch only surfaced at the seam. The root +`Cargo.toml`'s `[patch]` table exists to stop that. One definition, here, at the +bottom. + +Because the re-export is by module rather than by item, every historical path +keeps resolving unchanged — `tinymemory_api::types::MemoryEntry`, +`tinymemory::MemoryCategory`, `tinycortex::memory::types::*` — and they are the +same items, not twins. + +So: a driver author depends on `tinymemory-api` and gets traits and vocabulary. +A host depends on `tinymemory-bus` and gets vocabulary alone. + +## What is deliberately absent + +**No traits.** `MemoryProvider` and the eighteen capability-family traits +describe what an engine must implement, not what a frame carries. They stay in +`tinymemory-api`. The split is readable off the path: a name here is data, a +name there is an obligation. + +**No transport.** This crate does not depend on `tinybus` and holds no +connection, client or codec. A host already owns its connection — its reconnect +policy, its timeouts, its tracing — and the useful part is the vocabulary. + +That is also structural, not just preference: `tinybus` is vendored as a +submodule whose manifest inherits fields from its own nested +`[workspace.package]`, so a member of this workspace that depends on it makes +cargo resolve that inheritance against the wrong root and fail. It is why +`crates/tinymemory-module` is its own workspace root — see the note on `exclude` +in the root `Cargo.toml`. A crate every workspace member depends on has to stay +transport-free. + +**No host configuration, no null driver, no composition helpers.** Those are +`tinymemory-api`'s, and none of them cross a frame. + +## Making a call + +Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes +them into a tuple — and the member name comes from `names`: + +```rust,ignore +use tinymemory_bus::names::{methods, BUS_NAME, OBJECT_PATH}; +use tinymemory_bus::types::MemoryEntry; +use tinymemory_bus::wire; + +let body = serde_json::json!([namespace, key]); +match connection.call(BUS_NAME, OBJECT_PATH, methods::GET, body).await { + Ok(reply) => Ok(serde_json::from_value::>(reply)?), + // The name is the contract, and `from_wire` is the same table the module + // mapped out through, so the variant survives the round trip. + Err(tinybus::Error::MethodFailed { name, message }) => { + Err(wire::from_wire(&name, &message)) + } + Err(other) => Err(other.into()), +} +``` + +`OpenStore` is the one member that returns an object *path* rather than a value: +a sibling store under the same workspace, exporting the identical interface. +Treat `OBJECT_PATH` as the root object, not the only one. + +## Staying in step with the module + +`names::METHODS` lists every member. `crates/tinymemory-module` asserts its +served members against that list, in order, in +`the_served_members_are_exactly_the_published_contract`. Nothing else links the +two — this crate lists members by hand, the module derives them from its +`#[tinybus::interface]` block — so that test is what turns a drift into a +`cargo test` failure instead of an `UnknownMethod` in a host at runtime. + +Adding a member is two edits here: a constant in `names::methods` and an entry +in `names::METHODS`. + +## Lints + +`clippy::pedantic` is deliberately off, matching `tinymemory-tinycortex` and +`tinymemory-remote`. These modules arrived verbatim from `tinymemory-api`, which +opts into no lints at all; switching pedantic on over the move would have buried +a mechanical relocation under several hundred unrelated `#[must_use]` and +backtick edits. Turning it on is worth doing as its own change, over +`tinymemory-api` too, so the contract and the vocabulary stay lint-compatible. diff --git a/crates/tinymemory-api/src/capabilities.rs b/crates/tinymemory-bus/src/capabilities.rs similarity index 99% rename from crates/tinymemory-api/src/capabilities.rs rename to crates/tinymemory-bus/src/capabilities.rs index 5e392c8..e7641ac 100644 --- a/crates/tinymemory-api/src/capabilities.rs +++ b/crates/tinymemory-bus/src/capabilities.rs @@ -253,7 +253,7 @@ pub struct Capabilities { impl Capabilities { /// The empty default capability set. The `null` driver advertises - /// [`Self::mandatory`] via its [`MemoryProvider::capabilities`](crate::provider::MemoryProvider::capabilities) + /// [`Self::mandatory`] via its `MemoryProvider::capabilities` /// implementation, not this. pub const fn empty() -> Self { Self { bits: 0 } diff --git a/crates/tinymemory-api/src/capabilities_tests.rs b/crates/tinymemory-bus/src/capabilities_tests.rs similarity index 96% rename from crates/tinymemory-api/src/capabilities_tests.rs rename to crates/tinymemory-bus/src/capabilities_tests.rs index 92e37c9..0254418 100644 --- a/crates/tinymemory-api/src/capabilities_tests.rs +++ b/crates/tinymemory-bus/src/capabilities_tests.rs @@ -9,6 +9,12 @@ //! 3. [`super::Capabilities::validate`] rejects a set missing **any** of the //! three mandatory families, checked one family at a time. +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; use serde_json::json; diff --git a/crates/tinymemory-api/src/chunks.rs b/crates/tinymemory-bus/src/chunks.rs similarity index 97% rename from crates/tinymemory-api/src/chunks.rs rename to crates/tinymemory-bus/src/chunks.rs index 789bc1b..2c141b7 100644 --- a/crates/tinymemory-api/src/chunks.rs +++ b/crates/tinymemory-bus/src/chunks.rs @@ -243,7 +243,8 @@ impl Metadata { /// 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). + /// Deterministic id derived from (`source_kind`, `source_id`, `seq_in_source`, + /// `content`). pub id: String, /// Canonical Markdown content. pub content: String, @@ -386,7 +387,9 @@ mod time_range_serde { } /// Serialize a `(start, end)` UTC timestamp pair as `{start_ms, end_ms}`. - pub fn serialize( + // `pub(crate)`, not `pub`: the enclosing module is private, so a bare `pub` + // is a surface nothing outside this crate can reach anyway. + pub(crate) fn serialize( value: &(DateTime, DateTime), serializer: S, ) -> Result { @@ -403,7 +406,7 @@ mod time_range_serde { /// 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>>( + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result<(DateTime, DateTime), D::Error> { let wire = Wire::deserialize(deserializer)?; diff --git a/crates/tinymemory-api/src/chunks_tests.rs b/crates/tinymemory-bus/src/chunks_tests.rs similarity index 95% rename from crates/tinymemory-api/src/chunks_tests.rs rename to crates/tinymemory-bus/src/chunks_tests.rs index 3d9c89f..49d8487 100644 --- a/crates/tinymemory-api/src/chunks_tests.rs +++ b/crates/tinymemory-bus/src/chunks_tests.rs @@ -1,5 +1,11 @@ //! Unit tests for the chunk model (`super`). +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; use chrono::TimeZone; diff --git a/crates/tinymemory-api/src/error.rs b/crates/tinymemory-bus/src/error.rs similarity index 100% rename from crates/tinymemory-api/src/error.rs rename to crates/tinymemory-bus/src/error.rs diff --git a/crates/tinymemory-api/src/error_tests.rs b/crates/tinymemory-bus/src/error_tests.rs similarity index 86% rename from crates/tinymemory-api/src/error_tests.rs rename to crates/tinymemory-bus/src/error_tests.rs index 75d72bc..c10b91a 100644 --- a/crates/tinymemory-api/src/error_tests.rs +++ b/crates/tinymemory-bus/src/error_tests.rs @@ -2,6 +2,12 @@ //! added for the driver contract. The older variants are exercised where they //! are constructed, in the engine crate. +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; use crate::capabilities::Capability; diff --git a/crates/tinymemory-api/src/host/evidence.rs b/crates/tinymemory-bus/src/evidence.rs similarity index 61% rename from crates/tinymemory-api/src/host/evidence.rs rename to crates/tinymemory-bus/src/evidence.rs index 7134b6d..53f1a78 100644 --- a/crates/tinymemory-api/src/host/evidence.rs +++ b/crates/tinymemory-bus/src/evidence.rs @@ -22,28 +22,60 @@ use serde::{Deserialize, Serialize}; #[serde(tag = "type", rename_all = "snake_case")] pub enum EvidenceRef { /// A single row in `episodic_log`. - Episodic { episodic_id: i64 }, + Episodic { + /// Row id in `episodic_log`. + episodic_id: i64, + }, /// A contiguous window of rows in `episodic_log`. - EpisodicWindow { from_id: i64, to_id: i64 }, + EpisodicWindow { + /// First row id in the window, inclusive. + from_id: i64, + /// Last row id in the window, inclusive. + to_id: i64, + }, /// A row in the tree-source summary table. - SourceSummary { summary_id: String }, + SourceSummary { + /// Row id in the tree-source summary table. + summary_id: String, + }, /// A node in `tree_topic`. - TreeTopic { topic_id: String }, + TreeTopic { + /// Node id in `tree_topic`. + topic_id: String, + }, /// A chunk in `vector_chunks` associated with a document source. - DocumentChunk { source_id: String, chunk_id: String }, + DocumentChunk { + /// The document source the chunk belongs to. + source_id: String, + /// Row id in `vector_chunks`. + chunk_id: String, + }, /// A specific message in an email source. EmailMessage { + /// The email source the message arrived in. source_id: String, + /// Provider-assigned message id. message_id: String, }, /// A field value from a connected provider (Composio toolkit). Provider { + /// Composio toolkit slug the value came from. toolkit: String, + /// The connection the value was read through. connection_id: String, + /// Field name within the provider's payload. field: String, }, /// A tool call record within an episodic entry. - ToolCall { tool_name: String, episodic_id: i64 }, + ToolCall { + /// The tool that was called. + tool_name: String, + /// The episodic row the call was recorded in. + episodic_id: i64, + }, /// A per-window weight from `tree_source`. - TreeSourceWeight { window_label: String }, + TreeSourceWeight { + /// The `tree_source` window the weight belongs to. + window_label: String, + }, } diff --git a/crates/tinymemory-api/src/goals.rs b/crates/tinymemory-bus/src/goals.rs similarity index 100% rename from crates/tinymemory-api/src/goals.rs rename to crates/tinymemory-bus/src/goals.rs diff --git a/crates/tinymemory-api/src/goals_tests.rs b/crates/tinymemory-bus/src/goals_tests.rs similarity index 100% rename from crates/tinymemory-api/src/goals_tests.rs rename to crates/tinymemory-bus/src/goals_tests.rs diff --git a/crates/tinymemory-api/src/health.rs b/crates/tinymemory-bus/src/health.rs similarity index 100% rename from crates/tinymemory-api/src/health.rs rename to crates/tinymemory-bus/src/health.rs diff --git a/crates/tinymemory-api/src/health_tests.rs b/crates/tinymemory-bus/src/health_tests.rs similarity index 89% rename from crates/tinymemory-api/src/health_tests.rs rename to crates/tinymemory-bus/src/health_tests.rs index a31c965..0ae3ee2 100644 --- a/crates/tinymemory-api/src/health_tests.rs +++ b/crates/tinymemory-bus/src/health_tests.rs @@ -5,6 +5,12 @@ //! kernel's generic `DriverHealth`, and the wire form carries a stable //! `status` discriminant plus a `reason`. +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; use serde_json::json; diff --git a/crates/tinymemory-bus/src/lib.rs b/crates/tinymemory-bus/src/lib.rs new file mode 100644 index 0000000..445f5e2 --- /dev/null +++ b/crates/tinymemory-bus/src/lib.rs @@ -0,0 +1,85 @@ +//! Every type that crosses the TinyMemory `TinyBus` boundary, and the names of +//! the members that carry them. +//! +//! TinyMemory ships as a loadable `TinyBus` module: `crates/tinymemory-module` +//! exports one object with 89 members on it, built as a `cdylib`. A host that +//! loads it — OpenHuman — can call into it but cannot `use` anything out of it, +//! so the payload vocabulary has to be published as an ordinary library. This +//! is that library. +//! +//! ## What is here +//! +//! - [`names`] — the bus name, the object path, and one constant per member. +//! - [`types`], [`chunks`], [`recall`], [`tree`], [`goals`], [`tool_memory`], +//! [`health`], [`capabilities`], [`evidence`] — the value vocabulary. +//! - [`provider`] — the value types the capability families exchange. +//! - [`error`] and [`wire`] — [`error::MemoryError`] and the name table it +//! round-trips through when a driver is reached over a wire. +//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. +//! +//! ## What is deliberately not here +//! +//! **No traits.** `MemoryProvider` and the eighteen capability-family traits +//! are driver obligations: they describe what an engine must implement, not +//! what a frame carries. They stay in `tinymemory-api`, which depends on this +//! crate. +//! +//! **No transport.** This crate does not depend on `tinybus` and holds no +//! connection, client, or codec. A host already owns its connection — its +//! reconnect policy, its timeouts, its tracing — and the useful part is the +//! vocabulary, not another wrapper around it. +//! +//! That is also a structural necessity, not only a preference: `tinybus` is +//! vendored as a submodule whose manifest inherits fields from its own nested +//! `[workspace.package]`, so a member of this workspace that depends on it +//! makes cargo resolve that inheritance against the wrong root and fail. It is +//! why `crates/tinymemory-module` is its own workspace root — see the note on +//! `exclude` in the root `Cargo.toml`. A crate every workspace member can +//! depend on has to stay transport-free. +//! +//! **No host configuration, no null driver, no composition helpers.** Those are +//! `tinymemory-api`'s, and none of them cross a frame. +//! +//! ## This crate is underneath the contract, not beside it +//! +//! `tinymemory-api` **depends on this crate and re-exports all of it**, so +//! every historical path — `tinymemory_api::types::MemoryEntry`, +//! `tinymemory::MemoryCategory`, `tinycortex::memory::types::*` — keeps +//! resolving unchanged, and the types are the *same types*, not structural +//! twins. +//! +//! That direction is the whole point. Defining a parallel set of payload types +//! for hosts would mean `MemoryCategory` from the module was not +//! `MemoryCategory` in the host, with a conversion at every call site that +//! nothing checks — the exact failure the root manifest's `[patch]` table +//! exists to prevent, reintroduced deliberately. One definition, here, at the +//! bottom. +//! +//! A host that only makes calls therefore depends on this crate alone and +//! compiles no traits, no engine seam and no config surface. A driver author +//! depends on `tinymemory-api` and gets both. +//! +//! ## Staying in step with the module +//! +//! [`names::METHODS`] lists every member. `crates/tinymemory-module` asserts +//! its served members against that list, in order, so a method added to the +//! interface without an entry here fails that crate's tests rather than +//! surfacing as an `UnknownMethod` in a host at runtime. + +pub mod capabilities; +pub mod chunks; +pub mod error; +pub mod evidence; +pub mod goals; +pub mod health; +pub mod names; +pub mod provider; +pub mod recall; +pub mod tool_memory; +pub mod tree; +pub mod types; +pub mod version; +pub mod wire; + +pub use names::{BUS_NAME, METHODS, OBJECT_PATH}; +pub use version::{is_compatible, CONTRACT_VERSION}; diff --git a/crates/tinymemory-bus/src/names.rs b/crates/tinymemory-bus/src/names.rs new file mode 100644 index 0000000..87a78da --- /dev/null +++ b/crates/tinymemory-bus/src/names.rs @@ -0,0 +1,336 @@ +//! The object this contract addresses, and every member name on it. +//! +//! A member name is what actually travels in a frame, so it is the part of +//! the contract a typo breaks at runtime rather than at compile time. The +//! constants here exist so neither end spells one by hand. +//! +//! The names are the `PascalCase` of the module's method identifiers, which is +//! what `#[tinybus::interface]` derives them from. [`METHODS`] lists all of +//! them; the module asserts its served members against it, so a method added +//! there without a constant here fails that crate's tests. + +/// Well-known bus name exported by the `TinyMemory` module. +pub const BUS_NAME: &str = "ai.tinyhumans.tinymemory.Memory"; + +/// Object path the interface is served at. +/// +/// `OpenStore` returns a *different* path — a sibling store under the same +/// workspace, exporting this identical interface. Treat this constant as the +/// root object, not as the only one. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinymemory/Memory"; + +/// One constant per member name on [`BUS_NAME`]. +pub mod methods { + // Driver identity, capability negotiation, health and store opening. + /// `DriverId` — driver id. + pub const DRIVER_ID: &str = "DriverId"; + /// `Capabilities` — capabilities. + pub const CAPABILITIES: &str = "Capabilities"; + /// `Health` — health. + pub const HEALTH: &str = "Health"; + /// `Shutdown` — shutdown. + pub const SHUTDOWN: &str = "Shutdown"; + /// `OpenStore` — open store. + pub const OPEN_STORE: &str = "OpenStore"; + + // The mandatory key/value surface every driver implements. + /// `Store` — store. + pub const STORE: &str = "Store"; + /// `Get` — get. + pub const GET: &str = "Get"; + /// `Forget` — forget. + pub const FORGET: &str = "Forget"; + /// `List` — list. + pub const LIST: &str = "List"; + /// `Namespaces` — namespaces. + pub const NAMESPACES: &str = "Namespaces"; + + // Semantic recall over stored entries. + /// `Recall` — recall. + pub const RECALL: &str = "Recall"; + /// `RecallNamespaceScored` — recall namespace scored. + pub const RECALL_NAMESPACE_SCORED: &str = "RecallNamespaceScored"; + + // Paged export and bulk import of raw records. + /// `ExportPage` — export page. + pub const EXPORT_PAGE: &str = "ExportPage"; + /// `ImportRecords` — import records. + pub const IMPORT_RECORDS: &str = "ImportRecords"; + + // Document and chat ingestion through the summary pipeline. + /// `IngestDocument` — ingest document. + pub const INGEST_DOCUMENT: &str = "IngestDocument"; + /// `IngestChat` — ingest chat. + pub const INGEST_CHAT: &str = "IngestChat"; + + // Namespace-scoped document storage and retrieval. + /// `PutDocument` — put document. + pub const PUT_DOCUMENT: &str = "PutDocument"; + /// `GetDocument` — get document. + pub const GET_DOCUMENT: &str = "GetDocument"; + /// `ListDocuments` — list documents. + pub const LIST_DOCUMENTS: &str = "ListDocuments"; + /// `ListNamespaces` — list namespaces. + pub const LIST_NAMESPACES: &str = "ListNamespaces"; + /// `DeleteDocument` — delete document. + pub const DELETE_DOCUMENT: &str = "DeleteDocument"; + /// `ClearNamespace` — clear namespace. + pub const CLEAR_NAMESPACE: &str = "ClearNamespace"; + /// `QueryDocuments` — query documents. + pub const QUERY_DOCUMENTS: &str = "QueryDocuments"; + /// `RecallDocuments` — recall documents. + pub const RECALL_DOCUMENTS: &str = "RecallDocuments"; + + // The markdown summary tree: append, query, drill down, seal, cascade. + /// `Append` — append. + pub const APPEND: &str = "Append"; + /// `QuerySource` — query source. + pub const QUERY_SOURCE: &str = "QuerySource"; + /// `DrillDown` — drill down. + pub const DRILL_DOWN: &str = "DrillDown"; + /// `Seal` — seal. + pub const SEAL: &str = "Seal"; + /// `Cascade` — cascade. + pub const CASCADE: &str = "Cascade"; + + // Entities, relations and the namespaced key/value store. + /// `Entities` — entities. + pub const ENTITIES: &str = "Entities"; + /// `EntityEdges` — entity edges. + pub const ENTITY_EDGES: &str = "EntityEdges"; + /// `TouchEntities` — touch entities. + pub const TOUCH_ENTITIES: &str = "TouchEntities"; + /// `SearchEntities` — search entities. + pub const SEARCH_ENTITIES: &str = "SearchEntities"; + /// `Relations` — relations. + pub const RELATIONS: &str = "Relations"; + /// `PutRelation` — put relation. + pub const PUT_RELATION: &str = "PutRelation"; + /// `KvGet` — kv get. + pub const KV_GET: &str = "KvGet"; + /// `KvPut` — kv put. + pub const KV_PUT: &str = "KvPut"; + /// `KvDelete` — kv delete. + pub const KV_DELETE: &str = "KvDelete"; + /// `KvList` — kv list. + pub const KV_LIST: &str = "KvList"; + + // Source snapshots, diffs, item acceptance and forgetting. + /// `CaptureSnapshot` — capture snapshot. + pub const CAPTURE_SNAPSHOT: &str = "CaptureSnapshot"; + /// `Snapshots` — snapshots. + pub const SNAPSHOTS: &str = "Snapshots"; + /// `Diff` — diff. + pub const DIFF: &str = "Diff"; + /// `AcceptSourceItems` — accept source items. + pub const ACCEPT_SOURCE_ITEMS: &str = "AcceptSourceItems"; + /// `ForgetSource` — forget source. + pub const FORGET_SOURCE: &str = "ForgetSource"; + + // The long-term goals document. + /// `Goals` — goals. + pub const GOALS: &str = "Goals"; + /// `SetGoals` — set goals. + pub const SET_GOALS: &str = "SetGoals"; + + // Tool-scoped memory rules. + /// `ToolRules` — tool rules. + pub const TOOL_RULES: &str = "ToolRules"; + /// `PutToolRule` — put tool rule. + pub const PUT_TOOL_RULE: &str = "PutToolRule"; + /// `DeleteToolRule` — delete tool rule. + pub const DELETE_TOOL_RULE: &str = "DeleteToolRule"; + + // Re-embedding, compaction, consolidation and diagnosis. + /// `Reembed` — reembed. + pub const REEMBED: &str = "Reembed"; + /// `Compact` — compact. + pub const COMPACT: &str = "Compact"; + /// `Consolidate` — consolidate. + pub const CONSOLIDATE: &str = "Consolidate"; + /// `Doctor` — doctor. + pub const DOCTOR: &str = "Doctor"; + + // The people store: ranking, handles, scores and interactions. + /// `ListPeople` — list people. + pub const LIST_PEOPLE: &str = "ListPeople"; + /// `GetPerson` — get person. + pub const GET_PERSON: &str = "GetPerson"; + /// `ResolveHandle` — resolve handle. + pub const RESOLVE_HANDLE: &str = "ResolveHandle"; + /// `AddHandleAlias` — add handle alias. + pub const ADD_HANDLE_ALIAS: &str = "AddHandleAlias"; + /// `ScorePerson` — score person. + pub const SCORE_PERSON: &str = "ScorePerson"; + /// `RecordInteraction` — record interaction. + pub const RECORD_INTERACTION: &str = "RecordInteraction"; + /// `SeedFromAddressBook` — seed from address book. + pub const SEED_FROM_ADDRESS_BOOK: &str = "SeedFromAddressBook"; + + // The persisted chunk model and its embeddings. + /// `ListChunks` — list chunks. + pub const LIST_CHUNKS: &str = "ListChunks"; + /// `GetChunk` — get chunk. + pub const GET_CHUNK: &str = "GetChunk"; + /// `ChunkDetail` — chunk detail. + pub const CHUNK_DETAIL: &str = "ChunkDetail"; + /// `StorageKinds` — storage kinds. + pub const STORAGE_KINDS: &str = "StorageKinds"; + /// `ChunkEmbeddings` — chunk embeddings. + pub const CHUNK_EMBEDDINGS: &str = "ChunkEmbeddings"; + + // The scored retrieval surface. + /// `FastRetrieve` — fast retrieve. + pub const FAST_RETRIEVE: &str = "FastRetrieve"; + /// `CoverWindow` — cover window. + pub const COVER_WINDOW: &str = "CoverWindow"; + /// `RetrieveSource` — retrieve source. + pub const RETRIEVE_SOURCE: &str = "RetrieveSource"; + /// `RetrieveChildren` — retrieve children. + pub const RETRIEVE_CHILDREN: &str = "RetrieveChildren"; + /// `RetrieveLeaves` — retrieve leaves. + pub const RETRIEVE_LEAVES: &str = "RetrieveLeaves"; + + // Profile facets and their provenance. + /// `ListActiveFacets` — list active facets. + pub const LIST_ACTIVE_FACETS: &str = "ListActiveFacets"; + /// `ListAllFacets` — list all facets. + pub const LIST_ALL_FACETS: &str = "ListAllFacets"; + /// `GetFacet` — get facet. + pub const GET_FACET: &str = "GetFacet"; + /// `FacetsByType` — facets by type. + pub const FACETS_BY_TYPE: &str = "FacetsByType"; + /// `UpsertFacet` — upsert facet. + pub const UPSERT_FACET: &str = "UpsertFacet"; + /// `UpsertProviderFacet` — upsert provider facet. + pub const UPSERT_PROVIDER_FACET: &str = "UpsertProviderFacet"; + /// `SetFacetUserState` — set facet user state. + pub const SET_FACET_USER_STATE: &str = "SetFacetUserState"; + /// `DeleteFacet` — delete facet. + pub const DELETE_FACET: &str = "DeleteFacet"; + /// `DeleteFacetById` — delete facet by id. + pub const DELETE_FACET_BY_ID: &str = "DeleteFacetById"; + /// `DropFacetsBelow` — drop facets below. + pub const DROP_FACETS_BELOW: &str = "DropFacetsBelow"; + /// `WorkflowIdentityMatches` — workflow identity matches. + pub const WORKFLOW_IDENTITY_MATCHES: &str = "WorkflowIdentityMatches"; + + // Episodic turns and conversation segments. + /// `InsertTurn` — insert turn. + pub const INSERT_TURN: &str = "InsertTurn"; + /// `SessionTurns` — session turns. + pub const SESSION_TURNS: &str = "SessionTurns"; + /// `OpenSegment` — open segment. + pub const OPEN_SEGMENT: &str = "OpenSegment"; + /// `CreateSegment` — create segment. + pub const CREATE_SEGMENT: &str = "CreateSegment"; + /// `AppendTurn` — append turn. + pub const APPEND_TURN: &str = "AppendTurn"; + /// `CloseSegment` — close segment. + pub const CLOSE_SEGMENT: &str = "CloseSegment"; + /// `SetSegmentSummary` — set segment summary. + pub const SET_SEGMENT_SUMMARY: &str = "SetSegmentSummary"; + /// `UpsertSegmentEmbedding` — upsert segment embedding. + pub const UPSERT_SEGMENT_EMBEDDING: &str = "UpsertSegmentEmbedding"; +} + +/// Every member name, in the order the module declares them. +/// +/// The order matters: `tinybus`'s `Interface::members()` returns declaration +/// order, and the module compares the two sequences directly rather than as +/// sets, so a reordering is caught alongside an addition or a removal. +pub const METHODS: [&str; 89] = [ + methods::DRIVER_ID, + methods::CAPABILITIES, + methods::HEALTH, + methods::SHUTDOWN, + methods::OPEN_STORE, + methods::STORE, + methods::GET, + methods::FORGET, + methods::LIST, + methods::NAMESPACES, + methods::RECALL, + methods::EXPORT_PAGE, + methods::IMPORT_RECORDS, + methods::INGEST_DOCUMENT, + methods::INGEST_CHAT, + methods::PUT_DOCUMENT, + methods::GET_DOCUMENT, + methods::LIST_DOCUMENTS, + methods::LIST_NAMESPACES, + methods::DELETE_DOCUMENT, + methods::CLEAR_NAMESPACE, + methods::QUERY_DOCUMENTS, + methods::RECALL_DOCUMENTS, + methods::APPEND, + methods::QUERY_SOURCE, + methods::DRILL_DOWN, + methods::SEAL, + methods::CASCADE, + methods::ENTITIES, + methods::ENTITY_EDGES, + methods::TOUCH_ENTITIES, + methods::KV_GET, + methods::KV_PUT, + methods::KV_DELETE, + methods::KV_LIST, + methods::RELATIONS, + methods::PUT_RELATION, + methods::CAPTURE_SNAPSHOT, + methods::SNAPSHOTS, + methods::DIFF, + methods::GOALS, + methods::SET_GOALS, + methods::TOOL_RULES, + methods::PUT_TOOL_RULE, + methods::DELETE_TOOL_RULE, + methods::ACCEPT_SOURCE_ITEMS, + methods::FORGET_SOURCE, + methods::REEMBED, + methods::COMPACT, + methods::CONSOLIDATE, + methods::DOCTOR, + methods::LIST_PEOPLE, + methods::GET_PERSON, + methods::RESOLVE_HANDLE, + methods::ADD_HANDLE_ALIAS, + methods::SCORE_PERSON, + methods::RECORD_INTERACTION, + methods::SEED_FROM_ADDRESS_BOOK, + methods::LIST_CHUNKS, + methods::GET_CHUNK, + methods::CHUNK_DETAIL, + methods::STORAGE_KINDS, + methods::CHUNK_EMBEDDINGS, + methods::FAST_RETRIEVE, + methods::COVER_WINDOW, + methods::LIST_ACTIVE_FACETS, + methods::LIST_ALL_FACETS, + methods::GET_FACET, + methods::FACETS_BY_TYPE, + methods::INSERT_TURN, + methods::SESSION_TURNS, + methods::OPEN_SEGMENT, + methods::CREATE_SEGMENT, + methods::APPEND_TURN, + methods::CLOSE_SEGMENT, + methods::SET_SEGMENT_SUMMARY, + methods::UPSERT_SEGMENT_EMBEDDING, + methods::UPSERT_FACET, + methods::UPSERT_PROVIDER_FACET, + methods::SET_FACET_USER_STATE, + methods::DELETE_FACET, + methods::DELETE_FACET_BY_ID, + methods::DROP_FACETS_BELOW, + methods::WORKFLOW_IDENTITY_MATCHES, + methods::RETRIEVE_SOURCE, + methods::RETRIEVE_CHILDREN, + methods::RETRIEVE_LEAVES, + methods::RECALL_NAMESPACE_SCORED, + methods::SEARCH_ENTITIES, +]; + +#[cfg(test)] +#[path = "names_tests.rs"] +mod tests; diff --git a/crates/tinymemory-bus/src/names_tests.rs b/crates/tinymemory-bus/src/names_tests.rs new file mode 100644 index 0000000..14656e6 --- /dev/null +++ b/crates/tinymemory-bus/src/names_tests.rs @@ -0,0 +1,67 @@ +//! Tests for the member-name table. +//! +//! These are pinning tests, not behavioural ones. A member name is a string +//! that only fails at runtime, in a host, as an `UnknownMethod` — so the value +//! here is in catching a typo or a duplicate at `cargo test` time in this +//! crate, before the module or a host ever sees it. +// A failed assertion in a test is a panic either way; `expect` here says what +// the invariant was. Same allowance the crate's other test modules take. +#![allow(clippy::expect_used, clippy::panic)] + +use super::{methods, BUS_NAME, METHODS, OBJECT_PATH}; + +#[test] +fn the_object_identity_is_pinned() { + // Changing either of these breaks every deployed host at once, so they are + // spelled out here rather than derived from anything. + assert_eq!(BUS_NAME, "ai.tinyhumans.tinymemory.Memory"); + assert_eq!(OBJECT_PATH, "/ai/tinyhumans/tinymemory/Memory"); +} + +#[test] +fn no_member_name_appears_twice() { + let mut sorted = METHODS; + sorted.sort_unstable(); + let mut unique = sorted.to_vec(); + unique.dedup(); + assert_eq!( + unique.len(), + METHODS.len(), + "a member name is listed more than once" + ); +} + +#[test] +fn every_member_name_is_pascal_case() { + // `#[tinybus::interface]` derives a member from its method identifier with + // `pascal_case`, so anything else in this table is a hand-written name that + // will not match what the module actually serves. + for member in METHODS { + let mut chars = member.chars(); + let first = chars.next().unwrap_or('_'); + assert!( + first.is_ascii_uppercase(), + "{member} does not start with an uppercase letter" + ); + assert!( + member.chars().all(|c| c.is_ascii_alphanumeric()), + "{member} is not alphanumeric" + ); + } +} + +#[test] +fn the_constants_and_the_table_are_the_same_set() { + // A spot check in both directions: a constant that is not in the table + // would be invisible to the module's drift assertion, and a table entry + // with no constant is a name a caller has to spell by hand. + assert!(METHODS.contains(&methods::STORE)); + assert!(METHODS.contains(&methods::OPEN_STORE)); + assert!(METHODS.contains(&methods::WORKFLOW_IDENTITY_MATCHES)); + assert_eq!(methods::STORE, "Store"); + assert_eq!(methods::OPEN_STORE, "OpenStore"); + assert_eq!( + methods::WORKFLOW_IDENTITY_MATCHES, + "WorkflowIdentityMatches" + ); +} diff --git a/crates/tinymemory-bus/src/provider/chunks.rs b/crates/tinymemory-bus/src/provider/chunks.rs new file mode 100644 index 0000000..0be7aef --- /dev/null +++ b/crates/tinymemory-bus/src/provider/chunks.rs @@ -0,0 +1,114 @@ +//! The chunks family: direct read access to the stored chunk tier. +//! +//! A driver advertising [`Capability::Chunks`](crate::capabilities::Capability::Chunks) +//! can list and fetch individual chunks, and hand back the embedding vectors it +//! holds for them. +//! +//! # Why a caller would want this rather than recall +//! +//! `MemoryRecall` answers "what is relevant to this +//! query" and owns its own ranking. This family answers "give me the rows +//! matching these filters", which is what a host-side search tool needs when it +//! is doing the ranking itself — cosine similarity with its own MMR +//! diversification, say, or a hybrid keyword/vector blend the engine does not +//! implement. +//! +//! That makes it a deliberately lower-level surface than the rest of the +//! contract, and the honest framing is that it leaks a little of the engine's +//! storage model: chunks, source kinds, embedding signatures. The alternative +//! was worse. Without it a host either reaches around the driver into the +//! engine's own tables — which is exactly the split-brain this contract exists +//! to end — or every ranking strategy has to be pushed into the engine and +//! versioned there. +//! +//! # Embeddings are keyed by signature, and the signature must match exactly +//! +//! `MemoryChunks::chunk_embeddings` takes a `model_signature` and returns +//! only vectors stored under it. A caller that computes that string differently +//! from the driver gets an empty result rather than an error — the vectors are +//! there, just filed under a name the caller did not ask for. That is a real +//! failure mode with a real precedent, and it is silent; see +//! `docs/specs/2026-08-13-memory-module-port.md` §3. + +use serde::{Deserialize, Serialize}; + +use crate::chunks::{Chunk, SourceKind}; + +/// Filters for `MemoryChunks::list_chunks`. +/// +/// Every field is optional and they compose with AND. The default matches +/// everything the scope allows, bounded by the driver's own safety cap. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChunkQuery { + /// Restrict to one source kind. + #[serde(default)] + pub source_kind: Option, + /// Restrict to one logical source id. + #[serde(default)] + pub source_id: Option, + /// Restrict to one owner. + #[serde(default)] + pub owner: Option, + /// Inclusive lower bound on source time, epoch milliseconds. + #[serde(default)] + pub since_ms: Option, + /// Inclusive upper bound on source time, epoch milliseconds. + #[serde(default)] + pub until_ms: Option, + /// Maximum rows. The driver clamps this to its own cap — a caller cannot + /// raise the ceiling by asking for more. + #[serde(default)] + pub limit: Option, + /// Rows to skip, for pagination. + #[serde(default)] + pub offset: Option, + /// Drop chunks marked dropped by the lifecycle. + #[serde(default)] + pub exclude_dropped: bool, +} + +/// One chunk's stored embedding. +/// +/// Returned as a list rather than a map because the wire form of a map keyed by +/// chunk id is a JSON object, and an id is caller-supplied text; a list keeps +/// the encoding independent of what an id happens to contain. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChunkEmbedding { + /// The chunk this vector belongs to. + pub chunk_id: String, + /// The vector, in the embedding space named by the requested signature. + pub vector: Vec, +} + +/// One chunk plus the per-chunk facts stored beside it. +/// +/// # Why a detail view rather than four accessors +/// +/// An inspection caller wants the row, its body, where the body lives, its +/// lifecycle state and whether it has been embedded. Exposing those as four +/// methods would read naturally in-process and cost **four bus round trips per +/// row** out of it — and this is used to render lists. One method, one trip. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChunkDetail { + /// The chunk row. + pub chunk: Chunk, + /// The chunk's body as stored in the content vault, when it could be read. + /// + /// `None` means the vault read failed — distinct from an empty body, which + /// is a legitimately empty chunk. A caller rendering a preview should fall + /// back to [`Chunk::content`] rather than showing nothing. + #[serde(default)] + pub body: Option, + /// Path of the body in the content vault, when it has one. + #[serde(default)] + pub content_path: Option, + /// Lifecycle state (`active`, `dropped`, …); `None` when unrecorded. + #[serde(default)] + pub lifecycle_status: Option, + /// Whether an embedding vector exists for this chunk in **any** space. + /// + /// Not scoped to a signature on purpose: this answers "has this been + /// embedded at all", which is what an inspection view wants. Asking whether + /// a *particular* space has it is `MemoryChunks::chunk_embeddings`. + pub has_embedding: bool, +} diff --git a/crates/tinymemory-bus/src/provider/episodic.rs b/crates/tinymemory-bus/src/provider/episodic.rs new file mode 100644 index 0000000..5a02312 --- /dev/null +++ b/crates/tinymemory-bus/src/provider/episodic.rs @@ -0,0 +1,113 @@ +//! The episodic family: the turn-by-turn record of conversations. +//! +//! A driver advertising [`Capability::Episodic`](crate::capabilities::Capability::Episodic) +//! stores every chat turn in a full-text index and groups consecutive turns +//! into *conversation segments* — a segment being a stretch of turns about one +//! thing, closed when the subject changes and then summarised and embedded. +//! +//! # Why this is a family rather than a raw connection +//! +//! It is the last thing in the host that held a live `rusqlite::Connection`. +//! The archivist hook was handed one straight out of the session factory and +//! called free functions on it, which worked only because the engine was +//! compiled into this process. A connection cannot cross a bus, so either the +//! archivist's operations become a contract family or episodic capture stays +//! behind and the engine can never leave. +//! +//! What crosses is small and already typed: insert a turn, read a session's +//! turns back, and six segment-lifecycle operations. That was the whole surface +//! the raw connection was used for — no ad-hoc SQL, no schema knowledge. +//! +//! # The host keeps the policy, and it is not a small share +//! +//! Two of the archivist's eight engine calls took no connection at all — +//! deciding *whether* a new turn starts a new segment, and composing a summary +//! when no model is available. Neither touches storage, so both stay host-side +//! in `agent::harness::archivist`, next to the recap logic and the boundary +//! thresholds they read. This family persists what the host decided; it does +//! not decide. +//! +//! # `insert_turn` returns the id, and that is load-bearing +//! +//! The old code inserted a row and then issued `SELECT last_insert_rowid()` on +//! the same connection to learn its id. That is two operations relying on a +//! *connection-local* side effect, and it is wrong the moment anything else +//! shares the connection or the two hops cross a bus — `last_insert_rowid` is +//! per-connection state, so an interleaved insert from another task yields the +//! wrong id and the turn is filed under the wrong segment. +//! +//! Returning the id from the insert removes both problems at once: one round +//! trip instead of two, and no reliance on connection-local state. The engine +//! knows the id it just wrote; nothing else has to guess. + +use serde::{Deserialize, Serialize}; + +/// One recorded turn. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EpisodicTurn { + /// Row id, assigned by the driver on insert. + /// + /// `None` when the host is describing a turn to be written; always `Some` + /// on a turn read back. + #[serde(default)] + pub id: Option, + /// Session this turn belongs to. + pub session_id: String, + /// When it happened, epoch seconds with sub-second resolution. + /// + /// The archivist offsets an assistant turn by 1 ms from the user turn it + /// answers so the pair sorts in order within one exchange; that convention + /// is the host's and the driver must preserve the value it is given rather + /// than re-stamping it. + pub timestamp: f64, + /// `"user"` or `"assistant"`. Open vocabulary — a driver must not reject an + /// unfamiliar role. + pub role: String, + /// The turn's text. + pub content: String, + /// A short lesson extracted from tool failures, when there was one. + #[serde(default)] + pub lesson: Option, + /// Serialized tool-call summary, when the turn made any. + #[serde(default)] + pub tool_calls_json: Option, + /// Cost attributed to this turn, in microdollars. + #[serde(default)] + pub cost_microdollars: i64, +} + +/// A stretch of consecutive turns about one subject. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConversationSegment { + /// Stable id, chosen by the host. + pub segment_id: String, + /// Session the segment belongs to. + pub session_id: String, + /// Owning namespace. + pub namespace: String, + /// Row id of the first turn in the segment. + pub start_episodic_id: i64, + /// Row id of the last turn, once one has been appended. + #[serde(default)] + pub end_episodic_id: Option, + /// Timestamp of the first turn. + pub start_timestamp: f64, + /// Timestamp of the last turn, once one has been appended. + #[serde(default)] + pub end_timestamp: Option, + /// How many turns the segment holds. + pub turn_count: i32, + /// Summary, once the segment has been closed and summarised. + #[serde(default)] + pub summary: Option, + /// The segment's running embedding centroid, when it has one. + /// + /// Carried on the read so the host can run boundary detection against it + /// without a second call: deciding whether the next turn still belongs to + /// this segment is host policy, but it needs the centroid the driver + /// holds. + #[serde(default)] + pub embedding: Option>, + /// Whether the segment is still open. + pub open: bool, +} diff --git a/crates/tinymemory-bus/src/provider/mod.rs b/crates/tinymemory-bus/src/provider/mod.rs new file mode 100644 index 0000000..aa1250b --- /dev/null +++ b/crates/tinymemory-bus/src/provider/mod.rs @@ -0,0 +1,18 @@ +//! The value types the capability families exchange. +//! +//! These sit under `provider` because that is where they live in +//! `tinymemory-api`, which re-exports every one of them at its historical path. +//! Keeping the two trees the same shape is what makes the split auditable: a +//! type is either here, as data, or there, as a trait — and which one it is can +//! be read off the path. +//! +//! The traits themselves are **not** here and will not be. A trait is a driver +//! obligation; this crate describes a frame. See [`crate`] for the rest of that +//! argument. + +pub mod chunks; +pub mod episodic; +pub mod people; +pub mod profile; +pub mod retrieval; +pub mod types; diff --git a/crates/tinymemory-bus/src/provider/people.rs b/crates/tinymemory-bus/src/provider/people.rs new file mode 100644 index 0000000..e76d11c --- /dev/null +++ b/crates/tinymemory-bus/src/provider/people.rs @@ -0,0 +1,157 @@ +//! The people family: contacts, handle resolution, and closeness scoring. +//! +//! A driver advertising [`Capability::People`](crate::capabilities::Capability::People) +//! owns a store of people, the aliases each is known by, and the interactions +//! observed with them — and can rank them by how close the user is to each. +//! +//! # Why this is a family and not a widening of an existing one +//! +//! People is storage the engine owns, and it does not fit any family already +//! defined: a person is not a memory entry, not a document, and not a graph +//! entity. Adding these methods to, say, `MemoryEntities` would also have +//! been a **major** contract bump — the version rule treats a new method on a +//! family a driver may already advertise as breaking, because negotiation +//! cannot save a caller from a method an older driver does not implement. A new +//! family is a minor bump instead, and an older driver simply does not +//! advertise it. +//! +//! +//! # The types here are the contract's own +//! +//! None of these name an engine type. TinyCortex has its own `Person`, +//! `Handle` and `Interaction`; a second engine will have others. The adapter at +//! each engine's edge converts, which is what keeps this contract +//! engine-neutral — see the module rules in +//! [`super`]. +//! +//! # Identity crosses as a string +//! +//! [`PersonRef`] is an opaque string rather than a `Uuid`. The contract does +//! not promise that every engine identifies people by UUID, and a caller must +//! not parse one out — it round-trips an id it was given and nothing more. + +use serde::{Deserialize, Serialize}; + +/// Opaque identity of one person, as the driver issued it. +/// +/// Treat as a token: round-trip it, compare it for equality, never parse it. +pub type PersonRef = String; + +/// One way a person is addressed. +/// +/// The driver is responsible for canonicalising these before storing or +/// looking up — case folding an email, trimming a handle, collapsing whitespace +/// in a display name. Two handles that canonicalise alike must resolve to the +/// same person, which is why callers pass the raw form and never a +/// pre-normalised one: normalisation that differed between caller and driver +/// would silently mint duplicate people. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum PersonHandle { + /// An iMessage handle — a phone number or an Apple ID. + IMessage(String), + /// An email address. + Email(String), + /// A human-readable display name. + DisplayName(String), +} + +/// One person as the driver holds them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonRecord { + /// Driver-issued identity. + pub id: PersonRef, + /// Best-known display name, when one is known. + #[serde(default)] + pub display_name: Option, + /// Primary email, when one is known. + #[serde(default)] + pub primary_email: Option, + /// Primary phone number, when one is known. + #[serde(default)] + pub primary_phone: Option, + /// Every handle this person is known by, canonicalised. + #[serde(default)] + pub handles: Vec, + /// Creation time, RFC 3339. + pub created_at: String, + /// Last-update time, RFC 3339. + pub updated_at: String, +} + +/// Per-component breakdown of a closeness score, each in `[0, 1]`. +/// +/// Exposed rather than collapsed to one number so a caller can explain a +/// ranking. The components are **not** comparable across drivers: each engine +/// picks its own half-life and depth proxy, so compare within one driver's +/// results only. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PersonScore { + /// How recently the person was interacted with. + pub recency: f32, + /// How often. + pub frequency: f32, + /// How two-sided the exchange is — one-sided contact scores zero. + pub reciprocity: f32, + /// How substantial each interaction is. + pub depth: f32, + /// The composite, clamped to `[0, 1]`. + pub score: f32, + /// How many interactions the score was computed from. + /// + /// Travels with the score rather than beside it, because a score cannot be + /// read honestly without it: 0.9 from three exchanges and 0.9 from three + /// hundred are the same number and very different facts. Every caller that + /// gets a score gets the sample size, and no caller has to remember to ask. + #[serde(default)] + pub interaction_count: usize, +} + +/// A person together with their score, as returned by a ranked list. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RankedPerson { + /// The person. + pub person: PersonRecord, + /// Their closeness score, including the interaction count it was computed + /// from. + pub score: PersonScore, +} + +/// The outcome of resolving a handle. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedPerson { + /// Who the handle resolved to. + pub id: PersonRef, + /// Whether this call minted the person rather than finding them. + /// + /// Distinguished so a caller can tell "I now know who this is" from "I have + /// just invented someone", which read identically from the id alone. + pub created: bool, +} + +/// One observed interaction, as reported by the host. +/// +/// The host owns the channels, so it observes these; the driver only stores and +/// aggregates them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonInteraction { + /// Who the interaction was with. + pub person_id: PersonRef, + /// When it happened, RFC 3339. + pub at: String, + /// `true` when the user sent it. This is what drives reciprocity, so an + /// importer that cannot tell direction should not guess. + pub is_outbound: bool, + /// A proxy for substance — token or character count. Clamped during + /// scoring, so an outlier cannot dominate a ranking. + pub length: u32, +} + +/// What an address-book seed actually did. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AddressBookSeedOutcome { + /// People created or updated from the address book. + pub seeded: usize, + /// Contacts skipped — no usable handle, or a write that failed. + pub skipped: usize, +} diff --git a/crates/tinymemory-bus/src/provider/profile.rs b/crates/tinymemory-bus/src/provider/profile.rs new file mode 100644 index 0000000..1042d8b --- /dev/null +++ b/crates/tinymemory-bus/src/provider/profile.rs @@ -0,0 +1,183 @@ +//! The profile family: learned facets about the user. +//! +//! A driver advertising [`Capability::Profile`](crate::capabilities::Capability::Profile) +//! stores *facets* — small learned claims like a preferred verbosity, a role, +//! a tool the user reaches for — each carrying the evidence behind it, a +//! stability score, and a lifecycle state. +//! +//! # The host owns the learning; the driver owns the rows +//! +//! Which facets to extract, how to score stability, when to promote or evict — +//! all of that is host policy and stays there. This family is the persistence +//! seam beneath it: read facets, write facets, set the user's override, drop +//! what fell below a threshold. +//! +//! That split is why [`ProfileFacet`] carries a `stability` and a `state` the +//! driver never computes. It records what the host decided; it does not decide. +//! +//! # `user_state` is the user's, and outranks the score +//! +//! [`UserState::Pinned`] and [`UserState::Forgotten`] are explicit user +//! decisions. A pinned facet stays active however low its stability falls, and +//! a forgotten one stays dropped however much new evidence arrives — a user who +//! says "forget that" must not have it re-learned. +//! +//! The two are **not** symmetric under +//! `MemoryProfile::drop_facets_below`, and the asymmetry is deliberate: only +//! `Pinned` is protected from the sweep. A `Forgotten` facet is already in +//! [`FacetState::Dropped`] and is *meant* to be collected — protecting it would +//! keep the thing the user asked to forget on disk indefinitely. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::evidence::EvidenceRef; + +/// What kind of claim a facet makes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FacetType { + /// A stated or inferred preference. + Preference, + /// A way of working. Persisted as `skill` for historical reasons. + Workflow, + /// A role the user holds. + Role, + /// A personality trait. + Personality, + /// Ambient context about the user's situation. + Context, +} + +impl FacetType { + /// The identifier persisted in the facet table and published on the RPC + /// surface. + /// + /// **This is not the serde representation**, and the difference is + /// deliberate: [`Self::Workflow`] serialises as `workflow` but persists as + /// `skill`, a historical column value. Both forms are load-bearing — the + /// serde one crosses the bus, this one reaches storage and the published + /// JSON — so they are kept separate rather than reconciled. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Preference => "preference", + Self::Workflow => "skill", + Self::Role => "role", + Self::Personality => "personality", + Self::Context => "context", + } + } + + /// Parse a persisted identifier; unknown values fall back to + /// [`Self::Preference`], matching the engine's own lenient reader. + #[must_use] + pub fn parse_or_default(raw: &str) -> Self { + match raw { + "skill" => Self::Workflow, + "role" => Self::Role, + "personality" => Self::Personality, + "context" => Self::Context, + _ => Self::Preference, + } + } +} + +/// Where a facet sits in its lifecycle, as the host's stability detector last +/// left it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FacetState { + /// Cleared the promotion threshold; included in the ambient profile. + #[default] + Active, + /// Between the provisional and promotion thresholds; included at lower + /// weight. + Provisional, + /// Between eviction and provisional; held as a candidate. + Candidate, + /// Below the eviction threshold; removed on the next rebuild. + Dropped, +} + +impl FacetState { + /// Stable identifier, matching the serde representation. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Provisional => "provisional", + Self::Candidate => "candidate", + Self::Dropped => "dropped", + } + } +} + +/// The user's explicit override, which outranks [`FacetState`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UserState { + /// No override — the host's detector manages the lifecycle. + #[default] + Auto, + /// Pinned by the user: stays active regardless of score. + Pinned, + /// Forgotten by the user: stays dropped, and new evidence must not + /// re-promote it. + Forgotten, +} + +impl UserState { + /// Stable identifier, matching the serde representation. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Pinned => "pinned", + Self::Forgotten => "forgotten", + } + } +} + +/// One learned claim about the user. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProfileFacet { + /// Stable identity of this facet row. + pub facet_id: String, + /// What kind of claim it makes. + pub facet_type: FacetType, + /// The claim's key, e.g. `style/verbosity`. + pub key: String, + /// The claim's value. + pub value: String, + /// How confident the extraction was, in `[0, 1]`. + pub confidence: f64, + /// How many pieces of evidence support it. + pub evidence_count: i32, + /// Legacy segment-id references, when present. + #[serde(default)] + pub source_segment_ids: Option, + /// First observation, epoch seconds. + pub first_seen_at: f64, + /// Most recent observation, epoch seconds. + pub last_seen_at: f64, + /// Lifecycle state, assigned by the host. + #[serde(default)] + pub state: FacetState, + /// Stability score from the host's last rebuild. + #[serde(default)] + pub stability: f64, + /// The user's override. + #[serde(default)] + pub user_state: UserState, + /// Where the evidence came from. + #[serde(default)] + pub evidence_refs: Vec, + /// Facet class derived from the key prefix (`style`, `identity`, …). + /// `None` for rows whose key prefix matches no known class. + #[serde(default)] + pub class: Option, + /// Per-cue-family evidence counts, once the host has written a rebuild. + #[serde(default)] + pub cue_families: Option>, +} diff --git a/crates/tinymemory-bus/src/provider/retrieval.rs b/crates/tinymemory-bus/src/provider/retrieval.rs new file mode 100644 index 0000000..e3f4a23 --- /dev/null +++ b/crates/tinymemory-bus/src/provider/retrieval.rs @@ -0,0 +1,172 @@ +//! The retrieval family: the engine's deterministic retrieval primitives. +//! +//! A driver advertising [`Capability::Retrieval`](crate::capabilities::Capability::Retrieval) +//! exposes graph-walk retrieval, time-window coverage, and entity-index search +//! — the LLM-free primitives a host composes an answer from. +//! +//! # Separate from `MemoryTree`, on purpose +//! +//! The tree family navigates a known node: query one source, drill into +//! children, seal, cascade. These three answer questions about the store as a +//! whole, and they return a different shape — ranked hits with scores and a +//! truncation flag, not a node and its children. +//! +//! They are also, mechanically, why this is a new family rather than three more +//! `MemoryTree` methods: adding a method to a family a driver may already +//! advertise is a **major** contract bump, because negotiation cannot protect a +//! caller from a method an older driver never implemented. +//! +//! # Entity kinds travel as strings, not as an enum +//! +//! The engine's own `EntityKind` is `#[non_exhaustive]` and has grown twice. +//! A closed enum here would mean that the first time an engine emits a kind +//! this build has not heard of, the **response fails to deserialize** — a new +//! entity category would break retrieval outright rather than showing up as an +//! unfamiliar label. +//! +//! So [`EntityMatch::kind`] is an open vocabulary: a snake_case string the +//! caller passes through. Known values today are `email`, `url`, `handle`, +//! `hashtag`, `person`, `organization`, `location`, `event`, `product`, +//! `datetime`, `technology`, `artifact`, `quantity`, `misc`, `topic`. +//! +//! Requests are the opposite case and are validated: an unknown kind in +//! `MemoryRetrieval::search_entities`'s filter is a caller mistake the driver +//! reports as [`MemoryError::Invalid`](crate::error::MemoryError::Invalid), because silently matching nothing would +//! look identical to a genuine empty result. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::chunks::SourceKind; + +/// Whether a hit is a raw leaf or a sealed summary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetrievalNodeKind { + /// A stored chunk, tree level 0. + Leaf, + /// A sealed summary node, tree level ≥ 1. + Summary, +} + +/// One ranked retrieval result. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RetrievalHit { + /// Chunk id for a leaf, summary-node id for a summary. Globally unique. + pub node_id: String, + /// Leaf or summary. + pub node_kind: RetrievalNodeKind, + /// Provenance tree id; empty for a bare leaf not yet sealed into a tree. + #[serde(default)] + pub tree_id: String, + /// Human-readable tree scope, e.g. `slack:#eng`; empty for a bare leaf. + #[serde(default)] + pub tree_scope: String, + /// Tree level: 0 for a leaf chunk, ≥ 1 for a summary. + pub level: u32, + /// Raw chunk text, or sealed summary text. + pub content: String, + /// Canonical entity ids referenced by this node; empty on leaves. + #[serde(default)] + pub entities: Vec, + /// Topic tags for this node. + #[serde(default)] + pub topics: Vec, + /// Inclusive start of the node's time coverage. + pub time_range_start: DateTime, + /// Inclusive end of the node's time coverage. + pub time_range_end: DateTime, + /// Relevance, higher is better. + /// + /// **Not comparable across primitives or across drivers.** A `fast_retrieve` + /// score and a `cover_window` score are produced by different rankers; + /// merging two result sets by score would be meaningless. + pub score: f32, + /// Ids one level down; empty on leaves. + #[serde(default)] + pub child_ids: Vec, + /// Chunk back-pointer, populated for leaves only. + #[serde(default)] + pub source_ref: Option, +} + +/// A page of ranked hits. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct RetrievalResponse { + /// The hits, already filtered, ranked and truncated to the caller's limit. + pub hits: Vec, + /// Total matches **before** truncation. + pub total: usize, + /// `true` when `total > hits.len()`, i.e. a higher limit would return more. + /// + /// Carried explicitly rather than left for the caller to derive: it is the + /// difference between "there is nothing else" and "there is more, ask + /// again", and a caller that computed it from a page alone could not tell. + pub truncated: bool, +} + +/// Options for `MemoryRetrieval::fast_retrieve`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FastRetrieveQuery { + /// Maximum hits to return. + pub limit: usize, + /// How many graph hops to expand from the seed entities. + pub max_hops: u32, + /// Restrict to the last N days of source time. + #[serde(default)] + pub time_window_days: Option, +} + +/// A time window to cover. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoverWindowQuery { + /// Inclusive lower bound, epoch milliseconds. + pub since_ms: i64, + /// Inclusive upper bound, epoch milliseconds. + pub until_ms: i64, + /// Restrict to one logical source. + #[serde(default)] + pub source_id: Option, + /// Restrict to one source kind. + #[serde(default)] + pub source_kind: Option, + /// Maximum nodes in the cover. + #[serde(default)] + pub limit: Option, +} + +/// Filters for `MemoryRetrieval::retrieve_source`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceRetrievalQuery { + /// Restrict to one logical source (the engine's "scope", e.g. `slack:#eng`). + #[serde(default)] + pub source_id: Option, + /// Restrict to one source kind. + #[serde(default)] + pub source_kind: Option, + /// Restrict to the last N days of source time. + #[serde(default)] + pub time_window_days: Option, + /// Free-text query to rank against. `None` returns the newest nodes rather + /// than ranking — the primitive is a browse as well as a search. + #[serde(default)] + pub query: Option, + /// Maximum hits. + pub limit: usize, +} + +/// One entity-index match. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntityMatch { + /// Canonical id, e.g. `email:alice@example.com` or `topic:phoenix`. + pub canonical_id: String, + /// Entity classification. An **open** snake_case vocabulary — see the + /// module docs for why this is not an enum. + pub kind: String, + /// An example surface form that matched, for display. + pub surface: String, + /// Rows grouped under this canonical id. + pub mention_count: u64, + /// Epoch milliseconds of the newest mention. + pub last_seen_ms: i64, +} diff --git a/crates/tinymemory-api/src/provider/types.rs b/crates/tinymemory-bus/src/provider/types.rs similarity index 98% rename from crates/tinymemory-api/src/provider/types.rs rename to crates/tinymemory-bus/src/provider/types.rs index feb420a..29ed4de 100644 --- a/crates/tinymemory-api/src/provider/types.rs +++ b/crates/tinymemory-bus/src/provider/types.rs @@ -85,7 +85,7 @@ impl SourceScope { /// rule. /// /// ``` - /// use tinymemory_api::provider::types::SourceScope; + /// use tinymemory_bus::provider::types::SourceScope; /// /// let scope = SourceScope::new(["src-abc"]); /// assert!(scope.allows_source_id("src-abc")); @@ -102,7 +102,7 @@ impl SourceScope { } } -/// One unit of content handed to [`crate::provider::MemoryIngest`]. +/// One unit of content handed to `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 @@ -194,7 +194,7 @@ pub struct ExportRecord { /// One page of an export, plus the cursor that continues it. /// -/// Paging (rather than a stream) keeps [`crate::provider::MemoryPortability`] +/// Paging (rather than a stream) keeps `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)] @@ -340,7 +340,7 @@ pub struct DiffReport { pub changes: Vec, } -/// One item handed to [`crate::provider::MemorySourceSink`] by the host's sync +/// One item handed to `MemorySourceSink` by the host's sync /// machinery. /// /// The host owns credentials, scheduling, and fetching; the driver owns storage diff --git a/crates/tinymemory-api/src/provider/types_tests.rs b/crates/tinymemory-bus/src/provider/types_tests.rs similarity index 93% rename from crates/tinymemory-api/src/provider/types_tests.rs rename to crates/tinymemory-bus/src/provider/types_tests.rs index 390e59a..a2653eb 100644 --- a/crates/tinymemory-api/src/provider/types_tests.rs +++ b/crates/tinymemory-bus/src/provider/types_tests.rs @@ -4,6 +4,12 @@ //! fail-closed reading of an empty [`SourceScope`], and the wire strings / //! serde defaults that an out-of-process driver depends on. +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; #[test] diff --git a/crates/tinymemory-api/src/recall.rs b/crates/tinymemory-bus/src/recall.rs similarity index 100% rename from crates/tinymemory-api/src/recall.rs rename to crates/tinymemory-bus/src/recall.rs diff --git a/crates/tinymemory-api/src/recall_tests.rs b/crates/tinymemory-bus/src/recall_tests.rs similarity index 94% rename from crates/tinymemory-api/src/recall_tests.rs rename to crates/tinymemory-bus/src/recall_tests.rs index 5adf317..71a4af4 100644 --- a/crates/tinymemory-api/src/recall_tests.rs +++ b/crates/tinymemory-bus/src/recall_tests.rs @@ -5,6 +5,12 @@ //! half of the field-parity defence described in the module docs (the compile //! half being the exhaustive destructuring inside both `From` impls). +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; use serde_json::json; diff --git a/crates/tinymemory-api/src/tool_memory.rs b/crates/tinymemory-bus/src/tool_memory.rs similarity index 100% rename from crates/tinymemory-api/src/tool_memory.rs rename to crates/tinymemory-bus/src/tool_memory.rs diff --git a/crates/tinymemory-api/src/tool_memory_tests.rs b/crates/tinymemory-bus/src/tool_memory_tests.rs similarity index 92% rename from crates/tinymemory-api/src/tool_memory_tests.rs rename to crates/tinymemory-bus/src/tool_memory_tests.rs index 821369e..821382a 100644 --- a/crates/tinymemory-api/src/tool_memory_tests.rs +++ b/crates/tinymemory-bus/src/tool_memory_tests.rs @@ -1,5 +1,11 @@ //! Tests for the tool-scoped memory domain types. +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; #[test] diff --git a/crates/tinymemory-api/src/tree.rs b/crates/tinymemory-bus/src/tree.rs similarity index 100% rename from crates/tinymemory-api/src/tree.rs rename to crates/tinymemory-bus/src/tree.rs diff --git a/crates/tinymemory-api/src/tree_tests.rs b/crates/tinymemory-bus/src/tree_tests.rs similarity index 100% rename from crates/tinymemory-api/src/tree_tests.rs rename to crates/tinymemory-bus/src/tree_tests.rs diff --git a/crates/tinymemory-api/src/types.rs b/crates/tinymemory-bus/src/types.rs similarity index 99% rename from crates/tinymemory-api/src/types.rs rename to crates/tinymemory-bus/src/types.rs index d6c066a..68e8b04 100644 --- a/crates/tinymemory-api/src/types.rs +++ b/crates/tinymemory-bus/src/types.rs @@ -79,7 +79,7 @@ impl MemoryTaint { /// # Examples /// /// ``` - /// use tinymemory_api::types::MemoryTaint; + /// use tinymemory_bus::types::MemoryTaint; /// /// assert_eq!(MemoryTaint::Internal.as_db_str(), "internal"); /// assert_eq!(MemoryTaint::ExternalSync.as_db_str(), "external_sync"); @@ -103,7 +103,7 @@ impl MemoryTaint { /// # Examples /// /// ``` - /// use tinymemory_api::types::MemoryTaint; + /// use tinymemory_bus::types::MemoryTaint; /// /// assert_eq!(MemoryTaint::from_db_str("internal"), MemoryTaint::Internal); /// assert_eq!(MemoryTaint::from_db_str("external_sync"), MemoryTaint::ExternalSync); diff --git a/crates/tinymemory-api/src/types_tests.rs b/crates/tinymemory-bus/src/types_tests.rs similarity index 95% rename from crates/tinymemory-api/src/types_tests.rs rename to crates/tinymemory-bus/src/types_tests.rs index 5ee61b5..402e978 100644 --- a/crates/tinymemory-api/src/types_tests.rs +++ b/crates/tinymemory-bus/src/types_tests.rs @@ -1,5 +1,11 @@ //! Unit tests for the core memory data contracts in [`super`]. +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::*; use serde_json::json; diff --git a/crates/tinymemory-api/src/version.rs b/crates/tinymemory-bus/src/version.rs similarity index 98% rename from crates/tinymemory-api/src/version.rs rename to crates/tinymemory-bus/src/version.rs index 6123c36..798f721 100644 --- a/crates/tinymemory-api/src/version.rs +++ b/crates/tinymemory-bus/src/version.rs @@ -70,7 +70,7 @@ pub const CONTRACT_VERSION: (u16, u16) = (2, 2); /// # Examples /// /// ``` -/// use tinymemory_api::{is_compatible, CONTRACT_VERSION}; +/// use tinymemory_bus::{is_compatible, CONTRACT_VERSION}; /// /// // The version this build speaks is always compatible with itself. /// assert!(is_compatible(CONTRACT_VERSION)); diff --git a/crates/tinymemory-api/src/version_tests.rs b/crates/tinymemory-bus/src/version_tests.rs similarity index 100% rename from crates/tinymemory-api/src/version_tests.rs rename to crates/tinymemory-bus/src/version_tests.rs diff --git a/crates/tinymemory-api/src/wire.rs b/crates/tinymemory-bus/src/wire.rs similarity index 98% rename from crates/tinymemory-api/src/wire.rs rename to crates/tinymemory-bus/src/wire.rs index 02a6e61..e99bdc4 100644 --- a/crates/tinymemory-api/src/wire.rs +++ b/crates/tinymemory-bus/src/wire.rs @@ -22,7 +22,7 @@ //! only those three responses. That is wrong for two reasons. //! //! A host does not merely *react* to a driver error; it **is** a -//! [`MemoryProvider`](crate::provider::MemoryProvider) to everything above it, +//! `MemoryProvider` to everything above it, //! so it has to hand its own callers a `MemoryError`. Collapsing on the way out //! and guessing on the way back in would turn a `NotFound` into an `Invalid`, //! and `get`'s contract says a missing entry is `Ok(None)` while an `Invalid` is diff --git a/crates/tinymemory-api/src/wire_tests.rs b/crates/tinymemory-bus/src/wire_tests.rs similarity index 93% rename from crates/tinymemory-api/src/wire_tests.rs rename to crates/tinymemory-bus/src/wire_tests.rs index 22f0085..a6db5db 100644 --- a/crates/tinymemory-api/src/wire_tests.rs +++ b/crates/tinymemory-bus/src/wire_tests.rs @@ -1,5 +1,11 @@ //! The name table is a contract, so these tests pin it rather than exercise it. +// A failed assertion in a test is a panic either way; `unwrap`/`expect` here say +// what the invariant was. Same allowance the repository's other test modules +// take, and the reason these files carried none before is that +// `tinymemory-api` opts into no lints at all. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + use super::{from_wire, wire_message, wire_name}; use crate::capabilities::Capability; use crate::error::MemoryError; diff --git a/crates/tinymemory-module/Cargo.lock b/crates/tinymemory-module/Cargo.lock index d703571..aa131ca 100644 --- a/crates/tinymemory-module/Cargo.lock +++ b/crates/tinymemory-module/Cargo.lock @@ -1793,6 +1793,20 @@ dependencies = [ "serde_json", "sha2 0.11.0", "thiserror 2.0.20", + "tinymemory-bus", + "uuid", +] + +[[package]] +name = "tinymemory-bus" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", "uuid", ] @@ -1845,6 +1859,7 @@ dependencies = [ "tinycortex", "tinymemory", "tinymemory-api", + "tinymemory-bus", "tinymemory-core", "tinymemory-tinycortex", "tokio", diff --git a/crates/tinymemory-module/Cargo.toml b/crates/tinymemory-module/Cargo.toml index 45845ba..5c93e21 100644 --- a/crates/tinymemory-module/Cargo.toml +++ b/crates/tinymemory-module/Cargo.toml @@ -66,6 +66,11 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +# The host-side contract. A dev-dependency, not a normal one: the module serves +# `tinymemory-api` types directly and needs nothing from this crate to run. What +# it needs is the assertion — that the members it serves are exactly the ones +# `tinymemory-bus` tells a host to expect — and that belongs in tests. +tinymemory-bus = { path = "../tinymemory-bus" } # The loader E2E and the store tests need a throwaway workspace directory. tempfile = "3" diff --git a/crates/tinymemory-module/src/service/test.rs b/crates/tinymemory-module/src/service/test.rs index 7cd7b3f..e1761ca 100644 --- a/crates/tinymemory-module/src/service/test.rs +++ b/crates/tinymemory-module/src/service/test.rs @@ -288,3 +288,50 @@ fn every_served_method_is_declared_in_the_manifest() { "these methods are declared in the manifest but not served: {unserved:?}" ); } + +/// The members served here are exactly the ones `tinymemory-bus` publishes, in +/// the same order. +/// +/// `tinymemory-bus` is what a host compiles against: it carries one constant +/// and one typed call struct per member. Nothing links the two — this crate +/// derives its members from the `#[tinybus::interface]` block, that one lists +/// them by hand — so a method added here without a matching entry there is a +/// capability no host can reach, and an entry there with no method here is a +/// call that fails at runtime with `UnknownMethod`. +/// +/// Neither failure has a compile error anywhere, which is why it is asserted. +/// The comparison is on sequences rather than sets on purpose: `members()` +/// returns declaration order, `METHODS` is written in declaration order, and +/// pinning the order too means the two lists stay readable side by side. +#[test] +fn the_served_members_are_exactly_the_published_contract() { + let service = super::MemoryService::new(std::sync::Arc::new( + tinymemory_api::null::NullMemoryProvider, + )); + let served: Vec = tinybus::service::Interface::members(&service) + .iter() + .map(|member| member.as_str().to_string()) + .collect(); + let published: Vec = tinymemory_bus::METHODS + .iter() + .map(|member| (*member).to_string()) + .collect(); + + // Reported as differences rather than as a 89-element inequality, so the + // failure names the method that moved instead of printing both lists. + let missing: Vec<&String> = served.iter().filter(|m| !published.contains(m)).collect(); + assert!( + missing.is_empty(), + "served here but absent from tinymemory-bus, so no host can call them: {missing:?}" + ); + let extra: Vec<&String> = published.iter().filter(|m| !served.contains(m)).collect(); + assert!( + extra.is_empty(), + "published by tinymemory-bus but not served here, so a host calling them gets \ + UnknownMethod: {extra:?}" + ); + assert_eq!( + served, published, + "the two lists hold the same members in different orders" + ); +}