diff --git a/Cargo.lock b/Cargo.lock index 0ba37a9..d401f71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1755,12 +1755,16 @@ version = "0.1.1" dependencies = [ "anyhow", "async-trait", + "block2", "chrono", "dirs", "futures", "git2", "hex", "log", + "objc2", + "objc2-contacts", + "objc2-foundation", "parking_lot", "rand 0.10.2", "regex", @@ -1831,14 +1835,10 @@ dependencies = [ "anyhow", "async-trait", "axum", - "block2", "chrono", "dirs", "futures", "log", - "objc2", - "objc2-contacts", - "objc2-foundation", "parking_lot", "rand 0.8.7", "regex", diff --git a/api/src/capabilities.rs b/api/src/capabilities.rs index 7c1e7d3..5e392c8 100644 --- a/api/src/capabilities.rs +++ b/api/src/capabilities.rs @@ -51,7 +51,7 @@ use crate::error::MemoryError; /// One capability family a memory driver may advertise. /// -/// The variants are exactly the thirteen families of the memory contract. Each +/// The variants are exactly the sixteen families of the memory contract. Each /// maps to a trait family in the contract, a group of RPC methods, and a group /// of agent tools; a driver that does not advertise a family simply has that /// surface absent. @@ -85,6 +85,17 @@ pub enum Capability { Maintenance, /// Export and import of the whole store as a stream. **Mandatory.** Portability, + /// Contacts, handle resolution, and closeness scoring. + People, + /// Direct read access to the stored chunk tier. + Chunks, + /// Deterministic retrieval primitives: graph walk, time-window cover, + /// entity-index search. + Retrieval, + /// Learned facets about the user. + Profile, + /// The turn-by-turn conversation record and its segment lifecycle. + Episodic, } impl Capability { @@ -93,7 +104,7 @@ impl Capability { /// Declaration order is also bit order in [`Capabilities`] and iteration /// order in its serialized form, so this slice is the single ordering /// authority for the whole module. - pub const ALL: [Capability; 13] = [ + pub const ALL: [Capability; 18] = [ Capability::Core, Capability::Recall, Capability::Ingest, @@ -107,6 +118,14 @@ impl Capability { Capability::Sources, Capability::Maintenance, Capability::Portability, + // Appended, never inserted: declaration order is bit order in + // `Capabilities`, so moving an existing variant would silently change + // what an already-persisted or already-transmitted bitset means. + Capability::People, + Capability::Chunks, + Capability::Retrieval, + Capability::Profile, + Capability::Episodic, ]; /// The families a driver must advertise to be bindable at all. @@ -145,6 +164,11 @@ impl Capability { Self::Sources => "sources", Self::Maintenance => "maintenance", Self::Portability => "portability", + Self::People => "people", + Self::Chunks => "chunks", + Self::Retrieval => "retrieval", + Self::Profile => "profile", + Self::Episodic => "episodic", } } @@ -187,6 +211,11 @@ impl Capability { Self::Sources => 10, Self::Maintenance => 11, Self::Portability => 12, + Self::People => 13, + Self::Chunks => 14, + Self::Retrieval => 15, + Self::Profile => 16, + Self::Episodic => 17, } } diff --git a/api/src/capabilities_tests.rs b/api/src/capabilities_tests.rs index 1e5e550..92e37c9 100644 --- a/api/src/capabilities_tests.rs +++ b/api/src/capabilities_tests.rs @@ -2,7 +2,7 @@ //! //! Three properties are load-bearing and each has its own test: //! -//! 1. the enum has exactly the thirteen contract families and no more; +//! 1. the enum has exactly the sixteen contract families and no more; //! 2. the serialized form is stable snake_case **strings**, never discriminant //! integers — a driver deployed against an older build must keep advertising //! the same set after a variant is inserted mid-enum; @@ -13,9 +13,9 @@ use super::*; use serde_json::json; #[test] -fn capability_has_exactly_the_thirteen_contract_families() { - assert_eq!(Capability::ALL.len(), 13); - assert_eq!(Capability::all().len(), 13); +fn capability_has_exactly_the_eighteen_contract_families() { + assert_eq!(Capability::ALL.len(), 18); + assert_eq!(Capability::all().len(), 18); let names: Vec<&str> = Capability::ALL.iter().map(|c| c.as_str()).collect(); assert_eq!( @@ -34,6 +34,11 @@ fn capability_has_exactly_the_thirteen_contract_families() { "sources", "maintenance", "portability", + "people", + "chunks", + "retrieval", + "profile", + "episodic", ] ); } @@ -141,7 +146,7 @@ fn capabilities_empty_contains_nothing() { } #[test] -fn capabilities_bit_width_has_room_well_beyond_the_current_thirteen_families() { +fn capabilities_bit_width_has_room_well_beyond_the_current_sixteen_families() { // A `u16` bitset (the original representation) has exactly 16 bit // positions, leaving room for only 3 more families before a family's // `1 << index` bit-shift overflows. Pin the wider `u64` representation so diff --git a/api/src/host/embeddings.rs b/api/src/host/embeddings.rs index 76ba792..4f14b37 100644 --- a/api/src/host/embeddings.rs +++ b/api/src/host/embeddings.rs @@ -20,11 +20,114 @@ use async_trait::async_trait; /// provider. Drift between the two silently splits one embedding space into /// two, and every vector written on the wrong side of the split becomes /// unsearchable without a re-embed. +/// # Delimiters in a component +/// +/// A component containing `;`, `=` or `%` is percent-encoded, because without +/// that the format is ambiguous: `("a;model=b", "c")` and `("a", "b;model=c")` +/// are different embedding spaces that would otherwise produce one identical +/// key, and vectors from both would then be compared as though they came from +/// the same model. +/// +/// Encoding only those three characters is what keeps this from being a +/// migration. Every provider and model identifier actually in use is +/// alphanumeric plus `-`, `_`, `.`, `/` or `:`, and each of those passes +/// through untouched — so every signature already on disk still formats to the +/// same bytes. Only a name that could have collided changes, and such a name +/// has never been written. #[must_use] pub fn format_embedding_signature(name: &str, model_id: &str, dims: usize) -> String { + let name = escape_component(name); + let model_id = escape_component(model_id); format!("provider={name};model={model_id};dims={dims}") } +/// Percent-encode the three characters that carry structure in a signature. +/// +/// `%` goes first and must: encoding it afterwards would re-encode the `%` this +/// function just introduced, and `a;b` would arrive as `a%3Bb` from one path +/// and `a%253Bb` from another. +fn escape_component(value: &str) -> String { + if !value.contains(['%', ';', '=']) { + // The overwhelmingly common path, and the one that guarantees existing + // keys are untouched: no allocation beyond the copy, no rewriting. + return value.to_string(); + } + value + .replace('%', "%25") + .replace(';', "%3B") + .replace('=', "%3D") +} + +#[cfg(test)] +mod embedding_signature_tests { + use super::format_embedding_signature; + + /// The signature format is a **persisted key**, pinned to literal values. + /// + /// Written against golden strings rather than against another copy of the + /// function on purpose: the host used to hold a byte-identical duplicate of + /// this file and the two silently diverged once already. A guard that + /// compares two implementations stops protecting anything the moment one of + /// them goes away — which is exactly what happened when the duplicate was + /// removed. Literals outlive that. + /// + /// Every vector on disk is keyed by one of these strings, so a change here + /// is a migration, never an edit. + #[test] + fn signature_format_is_pinned_to_its_persisted_form() { + assert_eq!( + format_embedding_signature("ollama", "nomic-embed-text", 768), + "provider=ollama;model=nomic-embed-text;dims=768" + ); + assert_eq!( + format_embedding_signature("none", "none", 0), + "provider=none;model=none;dims=0" + ); + } + + /// Two distinct embedding spaces must never share one signature. + /// + /// Without escaping these two collide exactly: both format to + /// `provider=a;model=b;model=c;dims=3`. A collision here is not a cosmetic + /// problem — the signature is what decides which vectors are comparable, so + /// two models' vectors would be scored against each other as though they + /// came from one space. + #[test] + fn delimiter_characters_cannot_make_distinct_spaces_collide() { + let first = format_embedding_signature("a;model=b", "c", 3); + let second = format_embedding_signature("a", "b;model=c", 3); + assert_ne!(first, second); + } + + /// Escaping `%` last would make the encoding itself ambiguous. + #[test] + fn an_already_percent_encoded_name_does_not_collide_with_a_literal_one() { + assert_ne!( + format_embedding_signature("a%3Bb", "m", 3), + format_embedding_signature("a;b", "m", 3) + ); + } + + /// The escaping is not a migration: every identifier shaped like the ones + /// actually in use formats to the same bytes it always did. + #[test] + fn identifiers_in_real_use_are_untouched_by_the_escaping() { + for (provider, model) in [ + ("ollama", "nomic-embed-text"), + ("openai", "text-embedding-3-small"), + ("huggingface", "sentence-transformers/all-MiniLM-L6-v2"), + ("local", "bge_base.en-v1.5"), + ("backend", "tinyhumans:default"), + ] { + assert_eq!( + format_embedding_signature(provider, model, 768), + format!("provider={provider};model={model};dims=768"), + "{provider}/{model} must not be rewritten — it is a persisted key" + ); + } + } +} + /// Converts text into numerical vectors. #[async_trait] pub trait EmbeddingProvider: Send + Sync { diff --git a/api/src/lib.rs b/api/src/lib.rs index 7e27ff6..f34647a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -41,10 +41,10 @@ //! - [`recall`]: the borrowed [`recall::RecallOpts`] and owned, serde-derived //! [`recall::OwnedRecallOpts`] recall filters (both re-exported from //! [`types`]). -//! - [`capabilities`]: the thirteen [`capabilities::Capability`] families and +//! - [`capabilities`]: the sixteen [`capabilities::Capability`] families and //! the [`capabilities::Capabilities`] set negotiated at bind time. //! - [`provider`]: the driver contract — [`provider::MemoryProvider`] plus the -//! thirteen capability family traits and the value types they need. +//! sixteen capability family traits and the value types they need. //! - [`null`]: [`null::NullMemoryProvider`], the reference driver a //! compiled-out or unconfigured memory subsystem binds to. //! - [`health`]: [`health::MemoryHealth`], the liveness state a driver reports. diff --git a/api/src/null.rs b/api/src/null.rs index 1956100..e94a11b 100644 --- a/api/src/null.rs +++ b/api/src/null.rs @@ -10,7 +10,7 @@ //! `stub.rs` files with one generic answer. //! //! It is also the fixture the capability-degradation tests bind: with it in the -//! slot, the ten optional families are unadvertised, so their RPC methods are +//! slot, the fifteen optional families are unadvertised, so their RPC methods are //! unregistered and their agent tools are absent — and the core still boots. //! //! And it is the existence proof for the mandatory set: if @@ -32,9 +32,9 @@ //! driver that failed to bind — **that** case falls back to the embedded //! default, never to this. Do not wire it as a general-purpose failure mode. //! -//! ## Why it implements all thirteen families but advertises three +//! ## Why it implements all eighteen families but advertises three //! -//! The ten optional families are implemented and every method returns +//! The fifteen optional families are implemented and every method returns //! [`crate::error::MemoryError::Unsupported`] naming its family, but the //! `as_*` accessors return `None` and //! [`crate::provider::MemoryProvider::capabilities`] lists only the mandatory @@ -60,16 +60,21 @@ use crate::provider::types::{ MaintenanceReport, SnapshotRef, SourceItem, SourceScope, }; use crate::provider::{ - MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryGoals, MemoryGraph, - MemoryIngest, MemoryMaintenance, MemoryPortability, MemoryProvider, MemoryRecall, - MemorySourceSink, MemoryToolMemory, MemoryTree, + AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, CoverWindowQuery, EntityMatch, + FacetType, FastRetrieveQuery, MemoryChunks, MemoryCore, MemoryDiff, MemoryDocuments, + MemoryEntities, MemoryGoals, MemoryGraph, MemoryIngest, MemoryMaintenance, MemoryPeople, + MemoryPortability, MemoryProfile, MemoryProvider, MemoryRecall, MemoryRetrieval, + MemorySourceSink, MemoryToolMemory, MemoryTree, PersonHandle, PersonInteraction, PersonRecord, + PersonScore, ProfileFacet, RankedPerson, ResolvedPerson, RetrievalHit, RetrievalResponse, + SourceRetrievalQuery, UserState, }; use crate::recall::OwnedRecallOpts; use crate::tool_memory::ToolMemoryRule; use crate::tree::{IngestRequest, QueryResult, TreeStatus}; use crate::types::{ GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, - NamespaceDocumentInput, NamespaceRetrievalContext, NamespaceSummary, StoredMemoryDocument, + NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, NamespaceSummary, + StoredMemoryDocument, }; /// The [`driver_id`](MemoryProvider::driver_id) this driver reports. @@ -101,7 +106,7 @@ impl MemoryProvider for NullMemoryProvider { NULL_DRIVER_ID } - /// Exactly the mandatory three. The ten optional families are implemented + /// Exactly the mandatory three. The fifteen optional families are implemented /// below but deliberately not advertised, so they stay unreachable through /// the trait object. fn capabilities(&self) -> Capabilities { @@ -475,6 +480,202 @@ impl MemoryMaintenance for NullMemoryProvider { } } +#[async_trait] +impl MemoryPeople for NullMemoryProvider { + async fn list_people(&self, _limit: Option) -> Result, MemoryError> { + unsupported(Capability::People) + } + + async fn get_person(&self, _person_id: &str) -> Result, MemoryError> { + unsupported(Capability::People) + } + + async fn resolve_handle( + &self, + _handle: &PersonHandle, + _create_if_missing: bool, + ) -> Result, MemoryError> { + unsupported(Capability::People) + } + + async fn add_handle_alias( + &self, + _person_id: &str, + _handle: &PersonHandle, + ) -> Result<(), MemoryError> { + unsupported(Capability::People) + } + + async fn score_person(&self, _person_id: &str) -> Result, MemoryError> { + unsupported(Capability::People) + } + + async fn record_interaction( + &self, + _interaction: &PersonInteraction, + ) -> Result<(), MemoryError> { + unsupported(Capability::People) + } + + async fn seed_from_address_book(&self) -> Result { + unsupported(Capability::People) + } +} + +#[async_trait] +impl MemoryChunks for NullMemoryProvider { + async fn list_chunks( + &self, + _query: &ChunkQuery, + _scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + unsupported(Capability::Chunks) + } + + async fn get_chunk( + &self, + _chunk_id: &str, + ) -> Result, MemoryError> { + unsupported(Capability::Chunks) + } + + async fn chunk_detail(&self, _chunk_id: &str) -> Result, MemoryError> { + unsupported(Capability::Chunks) + } + + async fn storage_kinds(&self) -> Result, MemoryError> { + unsupported(Capability::Chunks) + } + + async fn chunk_embeddings( + &self, + _chunk_ids: &[String], + _model_signature: &str, + ) -> Result, MemoryError> { + unsupported(Capability::Chunks) + } +} + +#[async_trait] +impl MemoryRetrieval for NullMemoryProvider { + async fn fast_retrieve( + &self, + _query: &str, + _options: FastRetrieveQuery, + _scope: Option<&SourceScope>, + ) -> Result { + unsupported(Capability::Retrieval) + } + + async fn cover_window( + &self, + _window: &CoverWindowQuery, + _scope: Option<&SourceScope>, + ) -> Result { + unsupported(Capability::Retrieval) + } + + async fn retrieve_source( + &self, + _query: &SourceRetrievalQuery, + _scope: Option<&SourceScope>, + ) -> Result { + unsupported(Capability::Retrieval) + } + + async fn retrieve_children( + &self, + _node_id: &str, + _max_depth: u32, + _query: Option<&str>, + _limit: Option, + _scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + unsupported(Capability::Retrieval) + } + + async fn retrieve_leaves( + &self, + _chunk_ids: &[String], + _scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + unsupported(Capability::Retrieval) + } + + async fn recall_namespace_scored( + &self, + _namespace: &str, + _query: &str, + _limit: usize, + _exclude_session_id: Option<&str>, + ) -> Result, MemoryError> { + unsupported(Capability::Retrieval) + } + + async fn search_entities( + &self, + _query: &str, + _kinds: Option<&[String]>, + _limit: usize, + ) -> Result, MemoryError> { + unsupported(Capability::Retrieval) + } +} + +#[async_trait] +impl MemoryProfile for NullMemoryProvider { + async fn list_active_facets(&self) -> Result, MemoryError> { + unsupported(Capability::Profile) + } + async fn list_all_facets(&self) -> Result, MemoryError> { + unsupported(Capability::Profile) + } + async fn get_facet(&self, _key: &str) -> Result, MemoryError> { + unsupported(Capability::Profile) + } + async fn facets_by_type( + &self, + _facet_type: FacetType, + ) -> Result, MemoryError> { + unsupported(Capability::Profile) + } + async fn upsert_facet(&self, _facet: &ProfileFacet) -> Result<(), MemoryError> { + unsupported(Capability::Profile) + } + async fn upsert_provider_facet( + &self, + _facet_id: &str, + _facet_type: FacetType, + _key: &str, + _value: &str, + _confidence: f64, + _segment_id: Option<&str>, + _observed_at: f64, + ) -> Result<(), MemoryError> { + unsupported(Capability::Profile) + } + async fn set_facet_user_state( + &self, + _key: &str, + _user_state: UserState, + ) -> Result { + unsupported(Capability::Profile) + } + async fn delete_facet(&self, _key: &str) -> Result { + unsupported(Capability::Profile) + } + async fn delete_facet_by_id(&self, _facet_id: &str) -> Result { + unsupported(Capability::Profile) + } + async fn drop_facets_below(&self, _threshold: f64) -> Result { + unsupported(Capability::Profile) + } + /// `false`, matching the trait's documented "an error reads as no". + async fn workflow_identity_matches(&self, _pattern: &str, _value: &str) -> bool { + false + } +} + #[cfg(test)] #[path = "null_tests.rs"] mod tests; diff --git a/api/src/provider/audit_tests.rs b/api/src/provider/audit_tests.rs index 07ec80e..2d7b12e 100644 --- a/api/src/provider/audit_tests.rs +++ b/api/src/provider/audit_tests.rs @@ -134,14 +134,14 @@ fn honest_driver_passes_the_audit() { #[test] fn over_claiming_driver_is_reported_as_advertised_but_absent() { - // Advertises everything, exposes no optional accessor. Every one of the ten - // optional families would fail on first call — the exact + // Advertises everything, exposes no optional accessor. Every one of the + // thirteen optional families would fail on first call — the exact // registered-but-failing outcome the capability filter exists to prevent. let liar = Fixture::new(Capabilities::all(), false); let audit = audit_provider(&liar).expect_err("over-claiming driver must fail the audit"); assert_eq!(audit.present_but_unadvertised, Vec::new()); - assert_eq!(audit.advertised_but_absent.len(), 10); + assert_eq!(audit.advertised_but_absent.len(), 15); assert!(audit.advertised_but_absent.contains(&Capability::Tree)); // The mandatory three are supertraits, so they can never be missing. assert!(!audit.advertised_but_absent.contains(&Capability::Core)); diff --git a/api/src/provider/chunks.rs b/api/src/provider/chunks.rs new file mode 100644 index 0000000..34c7635 --- /dev/null +++ b/api/src/provider/chunks.rs @@ -0,0 +1,200 @@ +//! 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`](super::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 async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::chunks::{Chunk, SourceKind}; +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, +} + +/// Direct read access to the chunk tier. +/// +/// Reached through [`MemoryProvider::as_chunks`](super::MemoryProvider::as_chunks). +#[async_trait] +pub trait MemoryChunks: Send + Sync { + /// Chunks matching `query`, newest first. + /// + /// `scope` is applied **before** the row limit, so a disallowed source + /// cannot starve permitted ones out of the result — filtering after the + /// limit would let a noisy forbidden source silently empty the page. + /// + /// Passing `None` for `scope` means unrestricted, which is only correct for + /// a caller that has already decided no source gate applies. It is a + /// separate argument rather than a field of [`ChunkQuery`] to keep that + /// decision explicit at every call site. + /// + /// # Errors + /// + /// Backend failures only; no match yields an empty vector. + async fn list_chunks( + &self, + query: &ChunkQuery, + scope: Option<&SourceScope>, + ) -> Result, MemoryError>; + + /// One chunk by id. + /// + /// # Errors + /// + /// Backend failures only; an unknown id yields `Ok(None)`. + async fn get_chunk(&self, chunk_id: &str) -> Result, MemoryError>; + + /// One chunk with its stored detail, in a single call. + /// + /// # Errors + /// + /// Backend failures only; an unknown id yields `Ok(None)`. + async fn chunk_detail(&self, chunk_id: &str) -> Result, MemoryError>; + + /// The storage-shape catalog this driver persists. + /// + /// Stable snake_case identifiers naming the *shapes* the engine stores + /// (`chunk`, `vector`, `tree`, …), for a caller planning a multi-kind + /// retrieval fan-out. + /// + /// # Why this is asked rather than compiled in + /// + /// It is the engine's own vocabulary — a second engine stores different + /// shapes — so a host-side copy would drift the moment the engine changed + /// and could never be right for a driver the host was not built against. + /// It was a host-side copy, and it had already drifted: the tool's + /// description advertised `content`, `document` and `graph`, none of which + /// the engine has, and omitted `raw` and `entity`, which it does. + /// + /// Open vocabulary, for the same reason [`EntityMatch::kind`] is — a driver + /// that grows a shape must not break a caller that has not heard of it. + /// + /// [`EntityMatch::kind`]: super::retrieval::EntityMatch::kind + /// + /// # Errors + /// + /// Backend failures only. A driver with a fixed catalog cannot fail here + /// and should return it unconditionally. + async fn storage_kinds(&self) -> Result, MemoryError>; + + /// Stored embeddings for `chunk_ids`, in the space named by + /// `model_signature`. + /// + /// Chunks with no vector under that signature are **omitted**, so the + /// result may be shorter than the input and callers must not index by + /// position. See the module docs for why a signature mismatch looks like an + /// empty result rather than an error. + /// + /// # Errors + /// + /// Backend failures only. + async fn chunk_embeddings( + &self, + chunk_ids: &[String], + model_signature: &str, + ) -> Result, MemoryError>; +} diff --git a/api/src/provider/driver.rs b/api/src/provider/driver.rs index 29475a3..e23b82c 100644 --- a/api/src/provider/driver.rs +++ b/api/src/provider/driver.rs @@ -55,12 +55,17 @@ use async_trait::async_trait; use crate::capabilities::{Capabilities, Capability}; use crate::error::MemoryError; use crate::health::MemoryHealth; +use crate::provider::chunks::MemoryChunks; use crate::provider::content::{MemoryDocuments, MemoryIngest, MemoryTree}; +use crate::provider::episodic::MemoryEpisodic; use crate::provider::knowledge::{MemoryDiff, MemoryEntities, MemoryGraph}; use crate::provider::mandatory::{MemoryCore, MemoryPortability, MemoryRecall}; +use crate::provider::people::MemoryPeople; +use crate::provider::profile::MemoryProfile; use crate::provider::records::{ MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory, }; +use crate::provider::retrieval::MemoryRetrieval; /// A bound memory driver. /// @@ -69,7 +74,7 @@ use crate::provider::records::{ /// supertraits, so a driver missing any of them cannot be constructed as a /// provider at all. /// -/// The ten optional families are reached through the `as_*` accessors below. +/// The thirteen optional families are reached through the `as_*` accessors below. /// Each defaults to `None`, so a minimal driver implements only what it /// supports and inherits correct absence for everything else. #[async_trait] @@ -167,6 +172,31 @@ pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'stati None } + /// Contacts, handle resolution and closeness scoring, when advertised. + fn as_people(&self) -> Option<&dyn MemoryPeople> { + None + } + + /// Direct chunk-tier reads, when advertised. + fn as_chunks(&self) -> Option<&dyn MemoryChunks> { + None + } + + /// Deterministic retrieval primitives, when advertised. + fn as_retrieval(&self) -> Option<&dyn MemoryRetrieval> { + None + } + + /// Learned user facets, when advertised. + fn as_profile(&self) -> Option<&dyn MemoryProfile> { + None + } + + /// The turn-by-turn conversation record, when advertised. + fn as_episodic(&self) -> Option<&dyn MemoryEpisodic> { + None + } + /// Whether `capability` is actually **reachable** on this driver. /// /// This is the implementation-side truth, as opposed to @@ -192,6 +222,11 @@ pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'stati Capability::ToolMemory => self.as_tool_memory().is_some(), Capability::Sources => self.as_sources().is_some(), Capability::Maintenance => self.as_maintenance().is_some(), + Capability::People => self.as_people().is_some(), + Capability::Chunks => self.as_chunks().is_some(), + Capability::Retrieval => self.as_retrieval().is_some(), + Capability::Profile => self.as_profile().is_some(), + Capability::Episodic => self.as_episodic().is_some(), } } } diff --git a/api/src/provider/episodic.rs b/api/src/provider/episodic.rs new file mode 100644 index 0000000..0828bda --- /dev/null +++ b/api/src/provider/episodic.rs @@ -0,0 +1,223 @@ +//! 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 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 turn-by-turn conversation record. +/// +/// Reached through [`MemoryProvider::as_episodic`](super::MemoryProvider::as_episodic). +#[async_trait] +pub trait MemoryEpisodic: Send + Sync { + /// Record one turn, returning the id the driver assigned it. + /// + /// See the module docs for why the id comes back from the insert rather + /// than from a follow-up `last_insert_rowid` call. + /// + /// # Errors + /// + /// Backend failures. A driver that refuses a turn on safety grounds (a + /// secret-shaped session id, say) reports [`MemoryError::Invalid`] rather + /// than silently dropping it — the host cannot notice a missing turn. + async fn insert_turn(&self, turn: &EpisodicTurn) -> Result; + + /// Every recorded turn for one session, oldest first. + /// + /// # Errors + /// + /// Backend failures; an unknown session yields an empty vector. + async fn session_turns(&self, session_id: &str) -> Result, MemoryError>; + + /// The open segment for a session, when there is one. + /// + /// # Errors + /// + /// Backend failures only; no open segment yields `Ok(None)`. + async fn open_segment( + &self, + session_id: &str, + ) -> Result, MemoryError>; + + /// Start a new segment at `start_episodic_id`. + /// + /// # Errors + /// + /// Backend failures only. + async fn create_segment( + &self, + segment_id: &str, + session_id: &str, + namespace: &str, + start_episodic_id: i64, + start_timestamp: f64, + now: f64, + ) -> Result<(), MemoryError>; + + /// Extend a segment to include one more turn. + /// + /// # Errors + /// + /// Backend failures only. + async fn append_turn( + &self, + segment_id: &str, + episodic_id: i64, + timestamp: f64, + now: f64, + ) -> Result<(), MemoryError>; + + /// Mark a segment closed. Idempotent. + /// + /// # Errors + /// + /// Backend failures only. + async fn close_segment(&self, segment_id: &str, now: f64) -> Result<(), MemoryError>; + + /// Attach a summary to a segment. + /// + /// Separate from [`Self::close_segment`] because the two happen at + /// different times: a segment closes the moment the subject changes, and is + /// summarised afterwards by a model call that may be slow, may fail, or may + /// fall back to a composed summary. Folding them together would mean either + /// holding the segment open across an inference call or losing the summary + /// when one fails. + /// + /// # Errors + /// + /// Backend failures only. + async fn set_segment_summary( + &self, + segment_id: &str, + summary: &str, + now: f64, + ) -> Result<(), MemoryError>; + + /// Store a segment's embedding under `model_signature`, replacing any + /// vector already held for that signature. + /// + /// The signature must be produced the same way the rest of the store + /// produces it — see `docs/specs/2026-08-13-memory-module-port.md` §3 for + /// why a mismatch here is silent. + /// + /// # Errors + /// + /// Backend failures only. + async fn upsert_segment_embedding( + &self, + segment_id: &str, + model_signature: &str, + embedding: &[f32], + created_at: f64, + ) -> Result<(), MemoryError>; +} diff --git a/api/src/provider/mod.rs b/api/src/provider/mod.rs index 5fe65c9..ea3235b 100644 --- a/api/src/provider/mod.rs +++ b/api/src/provider/mod.rs @@ -1,4 +1,4 @@ -//! The memory driver contract: [`MemoryProvider`] plus the thirteen capability +//! The memory driver contract: [`MemoryProvider`] plus the eighteen capability //! family traits a driver may implement. //! //! ## Shape @@ -17,11 +17,16 @@ //! ├─ as_goals() -> Option<&dyn MemoryGoals> //! ├─ as_tool_memory() -> Option<&dyn MemoryToolMemory> //! ├─ as_sources() -> Option<&dyn MemorySourceSink> -//! └─ as_maintenance() -> Option<&dyn MemoryMaintenance> +//! ├─ as_maintenance() -> Option<&dyn MemoryMaintenance> +//! ├─ as_people() -> Option<&dyn MemoryPeople> +//! ├─ as_chunks() -> Option<&dyn MemoryChunks> +//! ├─ as_retrieval() -> Option<&dyn MemoryRetrieval> +//! ├─ as_profile() -> Option<&dyn MemoryProfile> +//! └─ as_episodic() -> Option<&dyn MemoryEpisodic> //! ``` //! //! The mandatory three are supertraits, so "mandatory" is enforced by the type -//! system rather than by a runtime check. The optional ten are accessors that +//! system rather than by a runtime check. The optional fifteen are accessors that //! default to `None`, so absence is the default and presence is opt-in. //! //! ## Rules that bind every family @@ -45,27 +50,43 @@ //! //! ## Reference implementation //! -//! [`crate::null::NullMemoryProvider`] implements all thirteen families: +//! [`crate::null::NullMemoryProvider`] implements all eighteen families: //! `/dev/null` semantics for the mandatory three, and -//! [`crate::error::MemoryError::Unsupported`] for the other ten, which it does +//! [`crate::error::MemoryError::Unsupported`] for the other fifteen, which it does //! not advertise. It is what a compiled-out or unconfigured memory subsystem //! binds to, and it doubles as the proof that the mandatory set is //! implementable without a storage engine. pub mod audit; +pub mod chunks; pub mod content; pub mod driver; +pub mod episodic; pub mod knowledge; pub mod mandatory; +pub mod people; +pub mod profile; pub mod records; +pub mod retrieval; pub mod types; pub use audit::{audit_provider, CapabilityAudit}; +pub use chunks::{ChunkDetail, ChunkEmbedding, ChunkQuery, MemoryChunks}; pub use content::{MemoryDocuments, MemoryIngest, MemoryTree}; pub use driver::MemoryProvider; +pub use episodic::{ConversationSegment, EpisodicTurn, MemoryEpisodic}; pub use knowledge::{MemoryDiff, MemoryEntities, MemoryGraph}; pub use mandatory::{MemoryCore, MemoryPortability, MemoryRecall}; +pub use people::{ + AddressBookSeedOutcome, MemoryPeople, PersonHandle, PersonInteraction, PersonRecord, PersonRef, + PersonScore, RankedPerson, ResolvedPerson, +}; +pub use profile::{FacetState, FacetType, MemoryProfile, ProfileFacet, UserState}; pub use records::{MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory}; +pub use retrieval::{ + CoverWindowQuery, EntityMatch, FastRetrieveQuery, MemoryRetrieval, RetrievalHit, + RetrievalNodeKind, RetrievalResponse, SourceRetrievalQuery, +}; pub use types::{ ChangeKind, DiffReport, EntityHit, EntityRef, ExportPage, ExportRecord, ImportOutcome, IngestItem, IngestOutcome, MaintenanceReport, SnapshotRef, SourceChange, SourceItem, diff --git a/api/src/provider/people.rs b/api/src/provider/people.rs new file mode 100644 index 0000000..a525d40 --- /dev/null +++ b/api/src/provider/people.rs @@ -0,0 +1,249 @@ +//! 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. +//! +//! [`MemoryEntities`]: crate::provider::MemoryEntities +//! +//! # 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 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, +} + +/// Contacts, handle resolution, and closeness scoring. +/// +/// Reached through +/// [`MemoryProvider::as_people`](super::MemoryProvider::as_people); a driver +/// that does not advertise [`Capability::People`](crate::capabilities::Capability::People) +/// returns `None` there and none of this is callable. +#[async_trait] +pub trait MemoryPeople: Send + Sync { + /// Known people, ranked by closeness, highest first. + /// + /// `limit` caps the result; `None` means the driver's own default. A driver + /// must bound this even when asked for everything — an unbounded people + /// list crosses the same 16 MiB frame as everything else. + /// + /// # Errors + /// + /// Backend failures only. An empty store yields an empty vector. + async fn list_people(&self, limit: Option) -> Result, MemoryError>; + + /// One person by id. + /// + /// # Errors + /// + /// Backend failures only. An unknown id yields `Ok(None)` rather than + /// [`MemoryError::NotFound`] — asking about someone who is not in the store + /// is a normal question with a negative answer, not a failure. + async fn get_person(&self, person_id: &str) -> Result, MemoryError>; + + /// Resolve a handle to a person, optionally minting one. + /// + /// With `create_if_missing` false an unknown handle yields `Ok(None)`. With + /// it true the driver mints a person and reports + /// [`ResolvedPerson::created`]. + /// + /// # Errors + /// + /// Backend failures only. + async fn resolve_handle( + &self, + handle: &PersonHandle, + create_if_missing: bool, + ) -> Result, MemoryError>; + + /// Record that a person is also known by `handle`. + /// + /// Idempotent: adding an alias a person already has is a no-op, not an + /// error, because an importer replaying the same source must converge. + /// + /// # Errors + /// + /// [`MemoryError::NotFound`] when `person_id` is unknown — unlike a lookup, + /// this is a write against an identity the caller claimed exists. Backend + /// failures otherwise. + async fn add_handle_alias( + &self, + person_id: &str, + handle: &PersonHandle, + ) -> Result<(), MemoryError>; + + /// The closeness score for one person. + /// + /// # Errors + /// + /// Backend failures only. An unknown id yields `Ok(None)`. + async fn score_person(&self, person_id: &str) -> Result, MemoryError>; + + /// Record one observed interaction. + /// + /// # Errors + /// + /// [`MemoryError::NotFound`] when the person is unknown; backend failures + /// otherwise. + async fn record_interaction(&self, interaction: &PersonInteraction) -> Result<(), MemoryError>; + + /// Seed people from the host platform's address book, when it has one. + /// + /// A host with no address book — or without the permission to read it — + /// reports `seeded: 0` rather than failing, so a caller cannot distinguish + /// "nothing to import" from "not available here". That is deliberate: both + /// mean the same thing to the caller, and the alternative leaks a platform + /// detail into the contract. + /// + /// # Errors + /// + /// Backend failures only. + async fn seed_from_address_book(&self) -> Result; +} diff --git a/api/src/provider/profile.rs b/api/src/provider/profile.rs new file mode 100644 index 0000000..51009f5 --- /dev/null +++ b/api/src/provider/profile.rs @@ -0,0 +1,301 @@ +//! 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 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>, +} + +/// Learned facets about the user. +/// +/// Reached through [`MemoryProvider::as_profile`](super::MemoryProvider::as_profile). +#[async_trait] +pub trait MemoryProfile: Send + Sync { + /// Facets in [`FacetState::Active`], most stable first. + /// + /// # Errors + /// + /// Backend failures only. + async fn list_active_facets(&self) -> Result, MemoryError>; + + /// Every facet regardless of state, most stable first. + /// + /// # Errors + /// + /// Backend failures only. + async fn list_all_facets(&self) -> Result, MemoryError>; + + /// One facet by key. + /// + /// # Errors + /// + /// Backend failures only; an unknown key yields `Ok(None)`. + async fn get_facet(&self, key: &str) -> Result, MemoryError>; + + /// Facets of one type, most evidence first. + /// + /// # Errors + /// + /// Backend failures only. + async fn facets_by_type(&self, facet_type: FacetType) + -> Result, MemoryError>; + + /// Insert or replace a facet wholesale, including host-computed fields. + /// + /// # Errors + /// + /// Backend failures only. + async fn upsert_facet(&self, facet: &ProfileFacet) -> Result<(), MemoryError>; + + /// Confidence-aware upsert of a provider-sourced facet. + /// + /// Distinct from [`Self::upsert_facet`] because a provider supplies a claim + /// and its confidence but none of the lifecycle fields; merging is the + /// driver's, so a lower-confidence re-observation cannot overwrite a + /// stronger one. + /// + /// # Errors + /// + /// Backend failures only. + #[allow( + clippy::too_many_arguments, + reason = "each argument is a distinct column of the facet row a provider \ + supplies; grouping them into a struct would move the same seven \ + fields one level out without reducing what the caller must know" + )] + async fn upsert_provider_facet( + &self, + facet_id: &str, + facet_type: FacetType, + key: &str, + value: &str, + confidence: f64, + segment_id: Option<&str>, + observed_at: f64, + ) -> Result<(), MemoryError>; + + /// Set the user's override on one facet. `false` when the key is unknown. + /// + /// # Errors + /// + /// Backend failures only. + async fn set_facet_user_state( + &self, + key: &str, + user_state: UserState, + ) -> Result; + + /// Delete a facet by key. `false` when the key is unknown. + /// + /// # Errors + /// + /// Backend failures only. + async fn delete_facet(&self, key: &str) -> Result; + + /// Delete a facet by its `facet_id`. `false` when unknown. + /// + /// # Errors + /// + /// Backend failures only. + async fn delete_facet_by_id(&self, facet_id: &str) -> Result; + + /// Drop facets whose stability is below `threshold`, returning the count. + /// + /// Sweeps only facets already in [`FacetState::Dropped`]: an `Active` facet + /// below the threshold stays, because promotion and eviction are the host's + /// decision and this call only collects what the host already evicted. + /// [`UserState::Pinned`] is exempt; [`UserState::Forgotten`] is not — see + /// the module docs for why those differ. + /// + /// # Errors + /// + /// Backend failures only. + async fn drop_facets_below(&self, threshold: f64) -> Result; + + /// Whether any [`FacetType::Workflow`] facet's key matches `key_pattern` + /// (a SQL `LIKE` pattern) with exactly `canonical_value`. + /// + /// Answers "is this row the user?". Deliberately returns `bool` rather than + /// `Result`: every caller is a predicate whose only sane reading of a + /// backend error is "no", and threading a `Result` through them would + /// invite an `unwrap_or(true)` somewhere. + async fn workflow_identity_matches(&self, key_pattern: &str, canonical_value: &str) -> bool; +} diff --git a/api/src/provider/retrieval.rs b/api/src/provider/retrieval.rs new file mode 100644 index 0000000..6ae78c9 --- /dev/null +++ b/api/src/provider/retrieval.rs @@ -0,0 +1,324 @@ +//! 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`](super::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`], because silently matching nothing would +//! 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 engine's deterministic retrieval primitives. +/// +/// Reached through [`MemoryProvider::as_retrieval`](super::MemoryProvider::as_retrieval). +#[async_trait] +pub trait MemoryRetrieval: Send + Sync { + /// Graph-walk retrieval: seed from the query's entities, expand, rank. + /// + /// Deterministic and LLM-free — the driver embeds the query and walks, but + /// it does not synthesise prose. Composing an answer is the host's job. + /// + /// # Errors + /// + /// Backend and embedding failures. An empty query is + /// [`MemoryError::Invalid`], not an empty result: retrieval with nothing to + /// retrieve on is a caller mistake. + async fn fast_retrieve( + &self, + query: &str, + options: FastRetrieveQuery, + scope: Option<&SourceScope>, + ) -> Result; + + /// The minimum set of nodes covering a time window. + /// + /// # Errors + /// + /// Backend failures only. A window matching nothing yields an empty + /// response. + async fn cover_window( + &self, + window: &CoverWindowQuery, + scope: Option<&SourceScope>, + ) -> Result; + + /// Ranked retrieval over one source's summary tree. + /// + /// # Not to be confused with [`MemoryTree::query_source`](super::MemoryTree::query_source) + /// + /// They answer different questions and return different shapes. The tree + /// family's returns the raw [`Chunk`](crate::chunks::Chunk)s + /// filed under a source id, for a caller that wants the content. This one + /// returns ranked [`RetrievalHit`]s across the source's *summary* tree — + /// leaves and sealed summaries together, scored. The name differs precisely + /// so a caller cannot reach for one meaning and get the other. + /// + /// # Errors + /// + /// Backend failures only; no match yields an empty response. + async fn retrieve_source( + &self, + query: &SourceRetrievalQuery, + scope: Option<&SourceScope>, + ) -> Result; + + /// Walk one summary node's children, ranked. + /// + /// Named `retrieve_children` rather than `drill_down` because + /// [`MemoryTree::drill_down`](super::MemoryTree::drill_down) already exists + /// with different semantics — it returns a node and its direct children, + /// where this returns ranked hits several levels deep. They are also two + /// methods on one bus object, so the names could not collide even if the + /// ambiguity were acceptable. + /// + /// `max_depth` bounds how far down the walk goes; `query` ranks the result + /// when supplied and orders by the tree's own order when not. + /// + /// # Errors + /// + /// Backend failures only; an unknown `node_id` yields an empty vector + /// rather than [`MemoryError::NotFound`] — "no children" and "no such node" + /// are the same answer to this question. + /// `scope` restricts which sources may answer, and is explicit for the + /// reason given on [`Self::fast_retrieve`]: the walk filters by scope, and + /// a driver reached over a transport has no ambient scope to read. + async fn retrieve_children( + &self, + node_id: &str, + max_depth: u32, + query: Option<&str>, + limit: Option, + scope: Option<&SourceScope>, + ) -> Result, MemoryError>; + + /// Hydrate specific leaf chunks into ranked-hit form, by chunk id. + /// + /// Ids that do not resolve are **omitted**, so the result may be shorter + /// than the input and callers must not index by position. + /// + /// A chunk whose source falls outside `scope` is omitted the same way, so + /// naming a chunk id directly cannot read around a source restriction. + /// + /// # Errors + /// + /// Backend failures only. + async fn retrieve_leaves( + &self, + chunk_ids: &[String], + scope: Option<&SourceScope>, + ) -> Result, MemoryError>; + + /// Namespace recall returning **scored** hits with their signal breakdown. + /// + /// # Why this exists next to [`MemoryRecall::recall`](super::MemoryRecall::recall) + /// + /// [`MemoryRecall`](super::MemoryRecall) returns ranked entries and keeps + /// its scoring private. A host that wants to re-rank — a weight profile + /// trading graph proximity against vector similarity, say — needs the + /// *components*, not the verdict. This returns + /// [`NamespaceMemoryHit`], + /// whose `score_breakdown` carries them, so re-ranking is host policy over + /// engine signals rather than a second retrieval implementation. + /// + /// `exclude_session_id` drops documents auto-saved for that session. It + /// exists so a search issued mid-turn cannot retrieve the very request that + /// triggered it — a self-echo the caller cannot filter afterwards, because + /// by then the hit has already displaced a real result under the limit. + /// + /// # Errors + /// + /// Backend and embedding failures; an unknown namespace yields an empty + /// vector. + async fn recall_namespace_scored( + &self, + namespace: &str, + query: &str, + limit: usize, + exclude_session_id: Option<&str>, + ) -> Result, MemoryError>; + + /// Free-text search over the entity index. + /// + /// `kinds` filters by classification; `None` matches every kind. This is + /// how a caller resolves a name to a canonical id before a retrieval keyed + /// on that id. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for an unrecognised kind in `kinds` — see the + /// module docs. Backend failures otherwise; no match yields an empty + /// vector. + async fn search_entities( + &self, + query: &str, + kinds: Option<&[String]>, + limit: usize, + ) -> Result, MemoryError>; +} diff --git a/api/src/version.rs b/api/src/version.rs index 7d68fd0..6123c36 100644 --- a/api/src/version.rs +++ b/api/src/version.rs @@ -60,7 +60,7 @@ /// added to a family a driver may already advertise** (negotiation is /// family-granular, not method-granular, so that case cannot be made minor-safe /// by negotiation alone). -pub const CONTRACT_VERSION: (u16, u16) = (2, 0); +pub const CONTRACT_VERSION: (u16, u16) = (2, 2); /// Whether a driver speaking `remote` can be bound against this build. /// diff --git a/api/src/version_tests.rs b/api/src/version_tests.rs index b6baf39..19ce415 100644 --- a/api/src/version_tests.rs +++ b/api/src/version_tests.rs @@ -7,8 +7,10 @@ use super::*; #[test] -fn contract_version_starts_at_one_zero() { - assert_eq!(CONTRACT_VERSION, (2, 0)); +fn contract_version_is_two_two() { + // (2, 2): the `episodic` family was added, which the version rule makes a + // minor bump — capability negotiation is what keeps an older driver safe. + assert_eq!(CONTRACT_VERSION, (2, 2)); } #[test] diff --git a/core/Cargo.toml b/core/Cargo.toml index 9ca2254..18fd2f7 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -21,7 +21,7 @@ tinymemory = { path = ".." } # The default embedded engine. `store/`, `tree/` and `sync/` drive it directly; # `tinycortex-api` is a direct dependency because `tinycortex::memory` aliases # back only `{error, traits, types}`. -tinycortex = { version = "0.1", features = ["obsidian", "persona", "sync"] } +tinycortex = { version = "0.1", features = ["obsidian", "persona", "people", "sync"] } tinycortex-api = { version = "0.1" } # Chat-model and embedding primitives used by the tree summarizer and the @@ -61,14 +61,6 @@ url = "2" uuid = { version = "1", features = ["v4"] } walkdir = "2" -# macOS address-book reader behind `people/address_book.rs`. Gated by the -# host's `contacts` feature, forwarded here. -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = { version = "0.6", optional = true } -objc2-foundation = { version = "0.3", features = ["NSArray", "NSError", "NSObject", "NSString", "NSPredicate"], optional = true } -objc2-contacts = { version = "0.3.2", features = ["CNContact", "CNContactFetchRequest", "CNContactStore", "CNLabeledValue", "CNPhoneNumber"], optional = true } -block2 = { version = "0.6", optional = true } - [dev-dependencies] # `TestHostConfig` — the concrete `MemoryHostConfig` the extracted test suites # build, since `Config` is a trait object and cannot be `Default`ed. @@ -99,4 +91,8 @@ memory-git = ["tinycortex/git-diff", "tinycortex/wiki-git"] test-support = ["tinymemory-api/test-support"] # The macOS CNContactStore address-book seeding path. No-op off macOS. -contacts = ["dep:objc2", "dep:objc2-foundation", "dep:objc2-contacts", "dep:block2"] +# +# Forwarded rather than declared: `people` moved down into the engine, so the +# objc2 cohort is declared there and this crate no longer names those four +# crates at all. `tinycortex/contacts` implies `tinycortex/people`. +contacts = ["tinycortex/contacts"] diff --git a/core/src/people/address_book.rs b/core/src/people/address_book.rs deleted file mode 100644 index d32973e..0000000 --- a/core/src/people/address_book.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! macOS Address Book read via `CNContactStore`. -//! -//! Uses the documented Contacts framework API (`CNContactStore`) which: -//! - Triggers the TCC Contacts permission prompt (sandboxed builds work correctly). -//! - Returns a structured error for "permission denied" so callers can distinguish -//! that case from "no contacts". -//! -//! A trait (`ContactsSource`) provides a mockable seam so unit tests can inject a -//! canned list or a permission-denied error without any FFI calls. -//! -//! On non-mac platforms `read()` returns an empty vec (stub path). - -use crate::people::types::AddressBookContact; - -/// Result type distinguishing permission errors from other failures. -#[derive(Debug, PartialEq)] -pub enum AddressBookError { - /// The user denied or restricted Contacts access. - PermissionDenied, - /// Any other error (typically returned as a descriptive string). - Other(String), -} - -impl std::fmt::Display for AddressBookError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AddressBookError::PermissionDenied => { - write!( - f, - "contacts access denied — grant access in System Settings > Privacy > Contacts" - ) - } - AddressBookError::Other(s) => write!(f, "{s}"), - } - } -} - -/// Mockable seam for contact fetching. The real impl calls CNContactStore; -/// tests inject a `MockContactsSource`. -pub trait ContactsSource: Send + Sync { - fn fetch_contacts(&self) -> Result, AddressBookError>; -} - -/// Real implementation backed by CNContactStore (macOS only). -/// On non-mac this is an empty struct whose `fetch_contacts` always returns `Ok(vec![])`. -pub struct SystemContactsSource; - -impl ContactsSource for SystemContactsSource { - fn fetch_contacts(&self) -> Result, AddressBookError> { - imp::fetch_via_cn_contact_store() - } -} - -/// Fetch all contacts using the provided `ContactsSource`. -/// -/// Errors are logged at `warn` level and surfaced to the caller so RPC -/// handlers can distinguish "permission denied" from "no contacts found". -pub fn read_with(source: &dyn ContactsSource) -> Result, AddressBookError> { - match source.fetch_contacts() { - Ok(v) => { - tracing::debug!("[people::address_book] fetched {} contacts", v.len()); - Ok(v) - } - Err(AddressBookError::PermissionDenied) => { - tracing::warn!( - "[people::address_book] contacts access denied — \ - grant access in System Settings > Privacy > Contacts" - ); - Err(AddressBookError::PermissionDenied) - } - Err(AddressBookError::Other(ref e)) => { - tracing::warn!("[people::address_book] fetch error: {e}"); - Err(AddressBookError::Other(e.clone())) - } - } -} - -/// Convenience wrapper using the real `SystemContactsSource`. -pub fn read() -> Result, AddressBookError> { - read_with(&SystemContactsSource) -} - -// ── macOS implementation ────────────────────────────────────────────────────── -// -// Gated on `contacts` as well as the target: the four objc2 crates this needs -// are exclusive to this module, so a slim macOS build sheds the whole cohort. - -#[cfg(all(target_os = "macos", feature = "contacts"))] -mod imp { - use super::{AddressBookContact, AddressBookError}; - - use block2::RcBlock; - use core::ptr::NonNull; - use objc2::runtime::Bool; - use objc2::runtime::ProtocolObject; - use objc2::AnyThread as _; - use objc2_contacts::{ - CNAuthorizationStatus, CNContact, CNContactFetchRequest, CNContactStore, CNEntityType, - }; - use objc2_foundation::{NSArray, NSError, NSString}; - use std::sync::{Arc, Mutex}; - - // CNKeyDescriptor is a protocol; NSString conforms to it. - // We build the keys array as NSArray>. - use objc2_contacts::CNKeyDescriptor; - - /// Build the keys array used for CNContactFetchRequest. - /// - /// # Safety - /// NSString::from_str is safe; casting to ProtocolObject is safe because - /// `NSString: CNKeyDescriptor` (confirmed by the objc2-contacts bindings). - unsafe fn make_keys_array() -> objc2::rc::Retained>> - { - let given = NSString::from_str("givenName"); - let family = NSString::from_str("familyName"); - let emails = NSString::from_str("emailAddresses"); - let phones = NSString::from_str("phoneNumbers"); - - // NSString conforms to CNKeyDescriptor, so we can cast the refs. - let refs: &[&ProtocolObject] = &[ - ProtocolObject::from_ref(&*given), - ProtocolObject::from_ref(&*family), - ProtocolObject::from_ref(&*emails), - ProtocolObject::from_ref(&*phones), - ]; - NSArray::from_slice(refs) - } - - /// Request contacts access from TCC. Blocks on the calling thread until - /// the completion handler fires. Must not be called from the main thread - /// on macOS (CNContactStore will deadlock). - fn request_access(store: &CNContactStore) -> Result<(), AddressBookError> { - unsafe { - let status = CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts); - match status { - CNAuthorizationStatus::Authorized | CNAuthorizationStatus::Limited => { - tracing::debug!("[people::address_book] contacts access already authorized"); - return Ok(()); - } - CNAuthorizationStatus::Denied | CNAuthorizationStatus::Restricted => { - return Err(AddressBookError::PermissionDenied); - } - _ => { - tracing::debug!( - "[people::address_book] requesting contacts access (status={status:?})" - ); - } - } - - let (tx, rx) = std::sync::mpsc::channel::>(); - let tx = Arc::new(Mutex::new(Some(tx))); - let tx_clone = Arc::clone(&tx); - - let block = RcBlock::new(move |granted: Bool, _error: *mut NSError| { - let mut slot = tx_clone.lock().unwrap(); - if let Some(sender) = slot.take() { - let result = if granted.as_bool() { - Ok(()) - } else { - Err(AddressBookError::PermissionDenied) - }; - let _ = sender.send(result); - } - }); - - store.requestAccessForEntityType_completionHandler(CNEntityType::Contacts, &block); - - rx.recv().map_err(|_| { - AddressBookError::Other("contacts permission callback never fired".into()) - })? - } - } - - pub fn fetch_via_cn_contact_store() -> Result, AddressBookError> { - tracing::debug!("[people::address_book] fetch_via_cn_contact_store entry"); - unsafe { - let store = CNContactStore::new(); - request_access(&store)?; - - let keys_array = make_keys_array(); - let request = CNContactFetchRequest::initWithKeysToFetch( - CNContactFetchRequest::alloc(), - &keys_array, - ); - - let mut contacts: Vec = Vec::new(); - - // We use a raw pointer to the vec inside the block so that we can - // push from within the block. The block runs synchronously within - // enumerateContactsWithFetchRequest (it blocks until done), so the - // pointer is valid throughout. - let contacts_ptr: *mut Vec = &mut contacts; - - let block = RcBlock::new( - move |contact_nn: NonNull, _stop: NonNull| { - let contact: &CNContact = contact_nn.as_ref(); - - let given = contact.givenName().to_string(); - let family = contact.familyName().to_string(); - let full = { - let g = given.trim(); - let f = family.trim(); - match (g.is_empty(), f.is_empty()) { - (true, true) => None, - (false, true) => Some(g.to_string()), - (true, false) => Some(f.to_string()), - (false, false) => Some(format!("{g} {f}")), - } - }; - - let emails: Vec = { - let arr = contact.emailAddresses(); - let mut v = Vec::new(); - for i in 0..arr.len() { - let lv = arr.objectAtIndex(i); - // CNLabeledValue.value() → Retained - let email = lv.value().to_string(); - let trimmed = email.trim().to_string(); - if !trimmed.is_empty() { - v.push(trimmed); - } - } - v - }; - - let phones: Vec = { - let arr = contact.phoneNumbers(); - let mut v = Vec::new(); - for i in 0..arr.len() { - let lv = arr.objectAtIndex(i); - // CNLabeledValue.value() → Retained - let num = lv.value().stringValue().to_string(); - let trimmed = num.trim().to_string(); - if !trimmed.is_empty() { - v.push(trimmed); - } - } - v - }; - - if full.is_none() && emails.is_empty() && phones.is_empty() { - return; - } - - (*contacts_ptr).push(AddressBookContact { - display_name: full, - emails, - phones, - }); - }, - ); - - let mut error: Option> = None; - let ok = store.enumerateContactsWithFetchRequest_error_usingBlock( - &request, - Some(&mut error), - &block, - ); - if !ok { - let msg = error - .map(|e| e.localizedDescription().to_string()) - .unwrap_or_else(|| "unknown error from CNContactStore".into()); - return Err(AddressBookError::Other(msg)); - } - - tracing::debug!( - "[people::address_book] enumerated {} contacts", - contacts.len() - ); - Ok(contacts) - } - } -} - -// ── stub: non-macOS, or macOS with `contacts` compiled out ─────────────────── -// -// Pre-dates the gate — it already existed for Linux/Windows. Widening its cfg -// is the whole off-state: `read()`, `read_with()`, `AddressBookError` and -// `SystemContactsSource` stay compiled everywhere, so the `people` RPC surface -// is identical and an address-book refresh seeds nothing rather than failing. - -#[cfg(not(all(target_os = "macos", feature = "contacts")))] -mod imp { - use super::{AddressBookContact, AddressBookError}; - - pub fn fetch_via_cn_contact_store() -> Result, AddressBookError> { - Ok(vec![]) - } -} - -// ── tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -pub mod tests { - use super::*; - - /// Test double that returns a canned list without any FFI calls. - pub struct MockContactsSource { - pub result: Result, AddressBookError>, - } - - impl MockContactsSource { - pub fn ok(contacts: Vec) -> Self { - Self { - result: Ok(contacts), - } - } - - pub fn permission_denied() -> Self { - Self { - result: Err(AddressBookError::PermissionDenied), - } - } - } - - impl ContactsSource for MockContactsSource { - fn fetch_contacts(&self) -> Result, AddressBookError> { - match &self.result { - Ok(v) => Ok(v.clone()), - Err(AddressBookError::PermissionDenied) => Err(AddressBookError::PermissionDenied), - Err(AddressBookError::Other(s)) => Err(AddressBookError::Other(s.clone())), - } - } - } - - fn mk_contact(name: &str, email: &str) -> AddressBookContact { - AddressBookContact { - display_name: Some(name.into()), - emails: vec![email.into()], - phones: vec![], - } - } - - #[test] - fn mock_source_returns_canned_contacts() { - let source = MockContactsSource::ok(vec![ - mk_contact("Alice", "alice@example.com"), - mk_contact("Bob", "bob@example.com"), - ]); - let result = read_with(&source).unwrap(); - assert_eq!(result.len(), 2); - assert_eq!(result[0].display_name.as_deref(), Some("Alice")); - assert_eq!(result[1].emails[0], "bob@example.com"); - } - - #[test] - fn mock_source_permission_denied_is_distinguished() { - let source = MockContactsSource::permission_denied(); - let err = read_with(&source).unwrap_err(); - assert_eq!(err, AddressBookError::PermissionDenied); - } - - #[test] - fn system_source_non_mac_returns_empty() { - // Mirrors the `imp` cfgs above: the stub is what compiles whenever the - // real CNContactStore path is absent, whether by target or by gate. - #[cfg(not(all(target_os = "macos", feature = "contacts")))] - { - let source = SystemContactsSource; - let result = read_with(&source).unwrap(); - assert!(result.is_empty()); - } - #[cfg(all(target_os = "macos", feature = "contacts"))] - { - // TCC state is environment-dependent; just verify no panic. - let source = SystemContactsSource; - let _ = read_with(&source); - } - } - - #[test] - fn contact_with_no_fields_is_excluded_by_mock() { - let source = MockContactsSource::ok(vec![AddressBookContact { - display_name: Some("Sarah Lee".into()), - emails: vec![], - phones: vec!["+1 555 000 0001".into()], - }]); - let result = read_with(&source).unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].phones[0], "+1 555 000 0001"); - } -} diff --git a/core/src/people/migrations.rs b/core/src/people/migrations.rs deleted file mode 100644 index 57d153e..0000000 --- a/core/src/people/migrations.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! SQLite migrations for the people module. Mirrors the life_capture -//! migration style: idempotent, per-migration transaction, recorded in a -//! dedicated bookkeeping table. - -use rusqlite::{Connection, Result}; - -const MIGRATIONS: &[(&str, &str)] = &[("0001_init", include_str!("migrations/0001_init.sql"))]; - -pub fn run(conn: &Connection) -> Result<()> { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS _people_migrations ( - name TEXT PRIMARY KEY, - applied_at INTEGER NOT NULL - )", - )?; - - for (name, sql) in MIGRATIONS { - let already: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM _people_migrations WHERE name = ?1)", - rusqlite::params![name], - |row| row.get(0), - )?; - if already { - continue; - } - - conn.execute_batch("BEGIN")?; - let result = (|| -> Result<()> { - conn.execute_batch(sql)?; - conn.execute( - "INSERT INTO _people_migrations(name, applied_at) \ - VALUES (?1, CAST(strftime('%s','now') AS INTEGER))", - rusqlite::params![name], - )?; - Ok(()) - })(); - match result { - Ok(()) => conn.execute_batch("COMMIT")?, - Err(e) => { - let _ = conn.execute_batch("ROLLBACK"); - return Err(e); - } - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fresh() -> Connection { - Connection::open_in_memory().unwrap() - } - - #[test] - fn migrations_create_expected_tables() { - let conn = fresh(); - run(&conn).unwrap(); - let mut stmt = conn - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .unwrap(); - let names: Vec = stmt - .query_map([], |row| row.get(0)) - .unwrap() - .map(|r| r.unwrap()) - .collect(); - for expected in [ - "people", - "handle_aliases", - "interactions", - "_people_migrations", - ] { - assert!( - names.iter().any(|n| n == expected), - "missing {expected}: {names:?}" - ); - } - } - - #[test] - fn migrations_are_idempotent() { - let conn = fresh(); - run(&conn).unwrap(); - run(&conn).unwrap(); - let count: i64 = conn - .query_row("SELECT count(*) FROM _people_migrations", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(count, MIGRATIONS.len() as i64); - } -} diff --git a/core/src/people/migrations/0001_init.sql b/core/src/people/migrations/0001_init.sql deleted file mode 100644 index ee692b9..0000000 --- a/core/src/people/migrations/0001_init.sql +++ /dev/null @@ -1,37 +0,0 @@ --- People module schema. --- --- `people` holds one row per resolved person. `handle_aliases` holds all --- known (kind, canonical_value) handles that map to that person; the --- resolver is a lookup on `(kind, value)` → `person_id`. --- --- `interactions` records observed exchanges for scoring. Single-user v1; --- each row is attributed to (local-user, person_id). - -CREATE TABLE IF NOT EXISTS people ( - id TEXT PRIMARY KEY, -- uuid - display_name TEXT, - primary_email TEXT, - primary_phone TEXT, - created_at INTEGER NOT NULL, -- unix seconds - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS handle_aliases ( - kind TEXT NOT NULL, -- 'imessage' | 'email' | 'display_name' - value TEXT NOT NULL, -- canonicalized (lowercase / trimmed) - person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE, - created_at INTEGER NOT NULL, - PRIMARY KEY (kind, value) -); - -CREATE INDEX IF NOT EXISTS handle_aliases_person_idx ON handle_aliases(person_id); - -CREATE TABLE IF NOT EXISTS interactions ( - person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE, - ts INTEGER NOT NULL, -- unix seconds - is_outbound INTEGER NOT NULL, -- 1 = user sent, 0 = received - length INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX IF NOT EXISTS interactions_person_idx ON interactions(person_id, ts DESC); -CREATE INDEX IF NOT EXISTS interactions_ts_idx ON interactions(ts DESC); diff --git a/core/src/people/mod.rs b/core/src/people/mod.rs index a0ee412..feccd58 100644 --- a/core/src/people/mod.rs +++ b/core/src/people/mod.rs @@ -1,18 +1,27 @@ -//! People: contact resolution + scoring. +//! People: contact resolution + scoring — re-exported from the engine. //! -//! A5 module. Deterministic resolver maps (imessage handle | email | display -//! name) to a stable `PersonId`. Scoring blends recency × frequency × -//! reciprocity × depth from interaction rows into a ranked `people.list`. +//! # Why this is a shim //! -//! Intentionally self-contained: no dependency on `life_capture`, -//! `chronicle`, `nudges`, or UI. Integration happens in later slices. - -pub mod address_book; -pub mod migrations; -pub mod resolver; -pub mod scorer; -pub mod store; -pub mod types; +//! The implementation moved down into [`tinycortex::memory::people`]. People is +//! *storage*: a SQLite database of people, handle aliases and interactions, +//! with its own migrations and its own workspace-keyed connection. Storage +//! belongs to the engine, which is what lets the memory contract stay +//! engine-neutral — an engine bound in TinyCortex's place brings its own people +//! store rather than inheriting this one. +//! +//! What is left here is the historical path. `crate::people::{store, types, …}` +//! keeps resolving so the module's own call sites, and the six `store/` +//! references to `people::types`, did not all have to move in the same change. +//! +//! This mirrors [`crate::store::chunks`], which has related the same way to +//! `tinycortex::memory::chunks` since the engine seam was drawn. +//! +//! # The address book rides two gates +//! +//! `address_book`'s macOS reader is gated on `contacts` *and* on the target, in +//! the engine exactly as it was here. This crate's `contacts` feature now +//! forwards to `tinycortex/contacts`; with it off — or anywhere but macOS — the +//! stub returns an empty contact list, so a refresh seeds nothing rather than +//! failing. -#[cfg(test)] -mod tests; +pub use tinycortex::memory::people::{address_book, migrations, resolver, scorer, store, types}; diff --git a/core/src/people/resolver.rs b/core/src/people/resolver.rs deleted file mode 100644 index bb53512..0000000 --- a/core/src/people/resolver.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! HandleResolver — deterministic mapping (Handle) → PersonId. -//! -//! Given the same store contents, resolving the same handle twice returns -//! the same `PersonId`. If the handle is unknown and `create_if_missing` -//! is set, the resolver mints a new `PersonId`, inserts a `Person` skeleton -//! with the handle attached, and returns the new id. -//! -//! `seed_from_address_book` wires the `address_book` read path into the -//! resolver so that contacts from the system address book are pre-populated -//! as `Person` rows (and their handles are registered for future resolution). - -use chrono::Utc; - -use crate::people::address_book::{self, AddressBookError, ContactsSource}; -use crate::people::store::PeopleStore; -use crate::people::types::{Handle, Person, PersonId}; - -pub struct HandleResolver<'a> { - store: &'a PeopleStore, -} - -impl<'a> HandleResolver<'a> { - pub fn new(store: &'a PeopleStore) -> Self { - Self { store } - } - - /// Look up the person for a handle. Returns `None` if unknown. - pub async fn resolve(&self, handle: &Handle) -> Result, String> { - let canonical = handle.canonicalize(); - self.store - .lookup(&canonical) - .await - .map_err(|e| format!("lookup: {e}")) - } - - /// Look up or mint. Display-name / email fields on the newly-minted - /// `Person` are populated from the handle itself so the UI has - /// something to render before any enrichment runs. - pub async fn resolve_or_create(&self, handle: &Handle) -> Result { - self.resolve_or_create_with_status(handle) - .await - .map(|(id, _created)| id) - } - - pub async fn resolve_or_create_with_status( - &self, - handle: &Handle, - ) -> Result<(PersonId, bool), String> { - let canonical = handle.canonicalize(); - let id = PersonId::new(); - let (display_name, primary_email, primary_phone) = match &canonical { - Handle::DisplayName(s) => (Some(s.clone()), None, None), - Handle::Email(s) => (None, Some(s.clone()), None), - Handle::IMessage(s) => { - if s.contains('@') { - (None, Some(s.clone()), None) - } else { - (None, None, Some(s.clone())) - } - } - }; - let now = Utc::now(); - let person = Person { - id, - display_name, - primary_email, - primary_phone, - handles: vec![canonical.clone()], - created_at: now, - updated_at: now, - }; - self.store - .resolve_or_insert_person(&person, &canonical) - .await - .map_err(|e| format!("resolve_or_insert_person: {e}")) - } - - /// Merge: attach `other` as an alias on the person `primary` resolves to. - /// Useful for the sync path that learns "this email and this phone - /// belong to the same contact". - pub async fn link(&self, primary: &Handle, other: Handle) -> Result { - let pid = self.resolve_or_create(primary).await?; - let other = other.canonicalize(); - self.store - .add_alias(pid, other) - .await - .map_err(|e| format!("add_alias: {e}"))?; - Ok(pid) - } - - /// Seed the people store from the system address book. - /// - /// For each contact returned by `source`: - /// - Pick the first email or phone as the "primary" handle and look it - /// up or mint a `PersonId`. - /// - Link any additional emails / phones as aliases on the same person. - /// - If only a display name is present, mint via display name. - /// - /// Contacts that produce no handles at all are skipped. This is - /// idempotent: re-running on the same contact list is a no-op because - ///`lookup` finds existing handle rows. - /// - /// Returns `(seeded, skipped)` counts, and propagates `AddressBookError` - /// to let callers distinguish permission-denied from other failures. - pub async fn seed_from_address_book( - &self, - source: &dyn ContactsSource, - ) -> Result<(usize, usize), AddressBookError> { - let contacts = address_book::read_with(source)?; - let mut seeded = 0usize; - let mut skipped = 0usize; - - for c in contacts { - // Build a flat list of all handles for this contact. - let mut handles: Vec = Vec::new(); - for email in &c.emails { - let trimmed = email.trim(); - if !trimmed.is_empty() { - handles.push(Handle::Email(trimmed.to_string())); - } - } - for phone in &c.phones { - let trimmed = phone.trim(); - if !trimmed.is_empty() { - handles.push(Handle::IMessage(trimmed.to_string())); - } - } - if let Some(ref name) = c.display_name { - let trimmed = name.trim(); - if !trimmed.is_empty() { - handles.push(Handle::DisplayName(trimmed.to_string())); - } - } - - if handles.is_empty() { - skipped += 1; - continue; - } - - // The "primary" handle is the first email if present, otherwise - // the first phone, otherwise the display name. This gives the - // most stable link target for future interactions. - let primary = handles[0].clone(); - - // mint or look up the primary handle - match self.resolve_or_create(&primary).await { - Err(e) => { - tracing::warn!( - "[people::resolver] seed_from_address_book: failed to upsert primary handle {:?}: {e}", - primary.as_key() - ); - skipped += 1; - continue; - } - Ok(pid) => { - // link all additional handles as aliases - for alias in handles.into_iter().skip(1) { - if let Err(e) = self.store.add_alias(pid, alias.canonicalize()).await { - tracing::warn!( - "[people::resolver] seed_from_address_book: add_alias failed: {e}" - ); - } - } - seeded += 1; - } - } - } - - tracing::debug!( - "[people::resolver] seed_from_address_book done: seeded={seeded} skipped={skipped}" - ); - Ok((seeded, skipped)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::people::address_book::tests::MockContactsSource; - use crate::people::types::AddressBookContact; - - #[tokio::test] - async fn resolve_returns_none_for_unknown_handle() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - let got = r.resolve(&Handle::Email("x@y.z".into())).await.unwrap(); - assert!(got.is_none()); - } - - #[tokio::test] - async fn resolve_or_create_is_deterministic_across_case_and_whitespace() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - let a = r - .resolve_or_create(&Handle::Email("Sarah@Example.COM".into())) - .await - .unwrap(); - let b = r - .resolve_or_create(&Handle::Email(" sarah@example.com ".into())) - .await - .unwrap(); - assert_eq!(a, b, "canonicalization must collapse case+whitespace"); - } - - #[tokio::test] - async fn concurrent_resolve_or_create_returns_one_database_id() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - let handles: Vec<_> = (0..16) - .map(|_| Handle::Email("Race@Example.COM".into())) - .collect(); - - let ids = futures::future::join_all(handles.iter().map(|h| r.resolve_or_create(h))).await; - let first = ids[0].as_ref().unwrap(); - for id in &ids { - assert_eq!(id.as_ref().unwrap(), first); - } - - let people = s.list().await.unwrap(); - assert_eq!(people.len(), 1); - assert_eq!(people[0].id, *first); - } - - #[tokio::test] - async fn same_email_different_display_name_resolve_same_id() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - let via_email = r - .resolve_or_create(&Handle::Email("a@b.c".into())) - .await - .unwrap(); - // Linking a display name to the same email must not mint a second id. - let via_linked = r - .link( - &Handle::Email("a@b.c".into()), - Handle::DisplayName("Alice".into()), - ) - .await - .unwrap(); - assert_eq!(via_email, via_linked); - // And now resolving the display name returns the same id. - let via_name = r - .resolve(&Handle::DisplayName("Alice".into())) - .await - .unwrap(); - assert_eq!(via_name, Some(via_email)); - } - - #[tokio::test] - async fn distinct_handles_without_linking_produce_distinct_ids() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - let a = r - .resolve_or_create(&Handle::Email("a@b.c".into())) - .await - .unwrap(); - let b = r - .resolve_or_create(&Handle::Email("x@y.z".into())) - .await - .unwrap(); - assert_ne!(a, b); - } - - #[tokio::test] - async fn seed_from_address_book_populates_store() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - let source = MockContactsSource::ok(vec![ - AddressBookContact { - display_name: Some("Alice Smith".into()), - emails: vec!["alice@example.com".into()], - phones: vec!["+1 555 000 0001".into()], - }, - AddressBookContact { - display_name: Some("Bob Jones".into()), - emails: vec!["bob@example.com".into()], - phones: vec![], - }, - ]); - - let (seeded, skipped) = r.seed_from_address_book(&source).await.unwrap(); - assert_eq!(seeded, 2, "both contacts should be seeded"); - assert_eq!(skipped, 0); - - // Alice is resolvable by email - let alice_id = r - .resolve(&Handle::Email("alice@example.com".into())) - .await - .unwrap(); - assert!(alice_id.is_some(), "alice must be resolvable after seed"); - - // Alice is also resolvable by phone (linked as alias) - let alice_via_phone = r - .resolve(&Handle::IMessage("+1 555 000 0001".into())) - .await - .unwrap(); - assert_eq!( - alice_id, alice_via_phone, - "email and phone must resolve to same person" - ); - - // Bob is resolvable - let bob_id = r - .resolve(&Handle::Email("bob@example.com".into())) - .await - .unwrap(); - assert!(bob_id.is_some()); - assert_ne!(alice_id, bob_id, "distinct contacts must have distinct ids"); - } - - #[tokio::test] - async fn seed_from_address_book_permission_denied_is_propagated() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - let source = MockContactsSource::permission_denied(); - let err = r.seed_from_address_book(&source).await.unwrap_err(); - assert_eq!(err, AddressBookError::PermissionDenied); - - // Store must still be empty — no partial writes. - let people = s.list().await.unwrap(); - assert!( - people.is_empty(), - "no people should be inserted on permission denied" - ); - } - - #[tokio::test] - async fn seed_is_idempotent() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - let source = MockContactsSource::ok(vec![AddressBookContact { - display_name: Some("Carol".into()), - emails: vec!["carol@example.com".into()], - phones: vec![], - }]); - - let (s1, _) = r.seed_from_address_book(&source).await.unwrap(); - let (s2, _) = r.seed_from_address_book(&source).await.unwrap(); - assert_eq!(s1, 1); - assert_eq!(s2, 1, "second seed call should still report 1 (upsert)"); - - // Only one person in store. - let people = s.list().await.unwrap(); - assert_eq!(people.len(), 1, "idempotent — must not duplicate"); - } - - #[tokio::test] - async fn contact_with_only_display_name_is_seeded() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - let source = MockContactsSource::ok(vec![AddressBookContact { - display_name: Some("No Email Person".into()), - emails: vec![], - phones: vec![], - }]); - let (seeded, skipped) = r.seed_from_address_book(&source).await.unwrap(); - assert_eq!(seeded, 1); - assert_eq!(skipped, 0); - } - - #[tokio::test] - async fn contact_with_no_fields_is_skipped() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - let source = MockContactsSource::ok(vec![AddressBookContact { - display_name: None, - emails: vec![], - phones: vec![], - }]); - let (seeded, skipped) = r.seed_from_address_book(&source).await.unwrap(); - assert_eq!(seeded, 0); - assert_eq!(skipped, 1); - } - - // ── Cross-source merge safety tests (issue#1538) ────────────────────────── - // - // The people resolver must NOT silently merge two distinct identities that - // happen to share only a display name or only an unverified handle from - // different sources. These tests lock in the "ambiguous cross-source" - // contract: two handles from unrelated sources remain distinct unless - // explicitly linked via `link()`. - - /// Two contacts that share only a display name (no email or phone overlap) - /// must NOT be merged — they may be homonymous individuals. - #[tokio::test] - async fn same_display_name_from_different_sources_does_not_merge() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - // Source A — email-backed identity - let id_a = r - .resolve_or_create(&Handle::Email("alice@company-a.com".into())) - .await - .unwrap(); - r.link( - &Handle::Email("alice@company-a.com".into()), - Handle::DisplayName("Alice Smith".into()), - ) - .await - .unwrap(); - - // Source B — different email; the same display name surfaces again, - // but as a *separate* DisplayName-backed mint (NOT linked to either - // email). This is the actual collision scenario: two ingestion paths - // both encounter "Alice Smith" without any cross-source identifier. - let id_b = r - .resolve_or_create(&Handle::Email("alice@company-b.com".into())) - .await - .unwrap(); - // The display-name resolver must already pin to id_a (linked above), - // so a second mint of the same DisplayName does NOT spawn a third - // identity — but crucially it also does NOT silently merge id_b into id_a. - let id_name_again = r - .resolve_or_create(&Handle::DisplayName("Alice Smith".into())) - .await - .unwrap(); - - // The two email-backed identities must be distinct. - assert_ne!( - id_a, id_b, - "two email handles with identical display names must not be merged without explicit link" - ); - - // The repeated DisplayName mint resolves to the linked identity (id_a), - // NOT to id_b. If display names auto-merged, id_b would have collapsed - // into id_a; if they minted fresh on every call, this would be a third id. - assert_eq!( - id_name_again, id_a, - "repeated DisplayName mint should resolve to the existing linked identity" - ); - assert_ne!( - id_name_again, id_b, - "DisplayName collision must not silently merge id_b into id_a" - ); - - // Resolving the display name returns the ONE identity that was explicitly linked. - let via_name = r - .resolve(&Handle::DisplayName("Alice Smith".into())) - .await - .unwrap(); - assert_eq!( - via_name, - Some(id_a), - "display name resolves to the explicitly linked identity" - ); - - // company-b Alice is still addressable by email only. - let via_b_email = r - .resolve(&Handle::Email("alice@company-b.com".into())) - .await - .unwrap(); - assert_eq!(via_b_email, Some(id_b)); - } - - /// Minting the same email handle from two logically distinct call sites - /// must always collapse to one `PersonId` (idempotent mint). This is the - /// safe side of cross-source: we never mint duplicates for an identical - /// canonical handle. - #[tokio::test] - async fn same_email_from_two_sources_collapses_to_one_person() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - // Simulate two different ingestion paths (gmail vs slack) that both - // surface the same email address. - let from_gmail = r - .resolve_or_create(&Handle::Email("shared@example.com".into())) - .await - .unwrap(); - let from_slack = r - .resolve_or_create(&Handle::Email("shared@example.com".into())) - .await - .unwrap(); - - assert_eq!( - from_gmail, from_slack, - "identical canonical email from two ingestion paths must resolve to one PersonId" - ); - - // Exactly one person in the store. - let people = s.list().await.unwrap(); - assert_eq!( - people.len(), - 1, - "no duplicate person rows must exist for the same canonical email" - ); - } - - /// An iMessage phone handle from one source and an email from a different - /// source for the SAME real person must stay distinct until explicitly linked. - /// Memory must not unsafely merge the same person's identities across sources - /// (issue#1538). - #[tokio::test] - async fn phone_and_email_from_different_sources_are_not_merged_without_link() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - // iMessage source sees only a phone. - let id_phone = r - .resolve_or_create(&Handle::IMessage("+15550001234".into())) - .await - .unwrap(); - - // Gmail source sees only an email. - let id_email = r - .resolve_or_create(&Handle::Email("sam@example.com".into())) - .await - .unwrap(); - - // Without an explicit link these are separate identities. This is the - // contract under test — cross-source handles for the same real person - // must NOT auto-merge. Asserting post-link merge semantics is out of - // scope: link()'s exact propagation rule (does the email handle - // afterwards canonically resolve to the phone PersonId, or remain - // independent with only the link table updated?) is a separate - // behavior tested in store_tests.rs. - assert_ne!( - id_phone, id_email, - "phone and email from unrelated sources must not be auto-merged" - ); - } -} diff --git a/core/src/people/scorer.rs b/core/src/people/scorer.rs deleted file mode 100644 index dc9745f..0000000 --- a/core/src/people/scorer.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Scoring: recency × frequency × reciprocity × depth. -//! -//! Each component is deterministic given the same interaction list + `now` -//! timestamp, and each is clamped to `[0,1]`. The composite is the product; -//! clamping the product is redundant but kept for defense-in-depth. -//! -//! Weights (half-life / caps) are module constants so tests are stable. -//! They can move to config later without breaking the API. - -use chrono::{DateTime, Utc}; - -use crate::people::types::{Interaction, ScoreComponents}; - -/// Recency half-life in days. An interaction this many days old contributes -/// 0.5 to the recency signal; older interactions decay exponentially. -pub const RECENCY_HALF_LIFE_DAYS: f32 = 14.0; - -/// Frequency is measured within this rolling window (days). Only interactions -/// more recent than `now - FREQUENCY_WINDOW_DAYS` count toward frequency. -pub const FREQUENCY_WINDOW_DAYS: u32 = 30; - -/// Frequency saturates at this many interactions inside `FREQUENCY_WINDOW_DAYS`. -/// 50+ qualifying interactions yields frequency = 1.0. -pub const FREQUENCY_CAP: f32 = 50.0; - -/// Depth saturates when the mean message length reaches this many chars. -pub const DEPTH_CAP_CHARS: f32 = 500.0; - -/// Compute component scores for a person given their interaction list. -/// `now` is passed in so tests can fix time. -pub fn score(interactions: &[Interaction], now: DateTime) -> ScoreComponents { - if interactions.is_empty() { - return ScoreComponents { - recency: 0.0, - frequency: 0.0, - reciprocity: 0.0, - depth: 0.0, - score: 0.0, - }; - } - - // Recency: highest-signal (= most recent) interaction drives the score. - let newest = interactions.iter().map(|i| i.ts).max().unwrap_or(now); - let age_days = ((now - newest).num_seconds() as f32 / 86_400.0).max(0.0); - let recency = (-(age_days * 2f32.ln() / RECENCY_HALF_LIFE_DAYS)) - .exp() - .clamp(0.0, 1.0); - - // Frequency: count within the rolling window, saturated at FREQUENCY_CAP. - // Using a window (rather than total-ever) prevents an old burst of - // messages from inflating the score of a now-silent contact. - let window_cutoff = now - chrono::Duration::days(FREQUENCY_WINDOW_DAYS as i64); - let window_count = interactions - .iter() - .filter(|i| i.ts >= window_cutoff) - .count() as f32; - let frequency = (window_count / FREQUENCY_CAP).clamp(0.0, 1.0); - - // Reciprocity: balance of outbound vs inbound — perfect balance = 1.0, - // all-one-direction = 0.0. Uses all interactions (not windowed) so that - // the long-term pattern is captured even when recent volume is low. - let (out_n, in_n) = interactions.iter().fold((0u32, 0u32), |(o, i), x| { - if x.is_outbound { - (o + 1, i) - } else { - (o, i + 1) - } - }); - let reciprocity = if out_n + in_n == 0 { - 0.0 - } else { - let o = out_n as f32; - let i = in_n as f32; - let min = o.min(i); - let max = o.max(i); - (min / max).clamp(0.0, 1.0) - }; - - // Depth: mean interaction length, saturated at DEPTH_CAP_CHARS. - let count = interactions.len() as f32; - let total_len: u64 = interactions.iter().map(|x| x.length as u64).sum(); - let mean_len = total_len as f32 / count.max(1.0); - let depth = (mean_len / DEPTH_CAP_CHARS).clamp(0.0, 1.0); - - let composite = (recency * frequency * reciprocity * depth).clamp(0.0, 1.0); - - ScoreComponents { - recency, - frequency, - reciprocity, - depth, - score: composite, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::people::types::PersonId; - use chrono::Duration; - - fn mk(ts: DateTime, outbound: bool, length: u32) -> Interaction { - Interaction { - person_id: PersonId::new(), - ts, - is_outbound: outbound, - length, - } - } - - #[test] - fn empty_interactions_score_zero() { - let s = score(&[], Utc::now()); - assert_eq!(s.score, 0.0); - assert_eq!(s.recency, 0.0); - assert_eq!(s.frequency, 0.0); - } - - #[test] - fn recency_half_life_matches_config() { - let now = Utc::now(); - let half_ago = now - Duration::days(RECENCY_HALF_LIFE_DAYS as i64); - let s = score(&[mk(half_ago, true, 100)], now); - // Half-life point → recency ≈ 0.5 (allow small float slack). - assert!((s.recency - 0.5).abs() < 0.05, "got {}", s.recency); - } - - #[test] - fn all_components_clamped_to_unit_interval() { - let now = Utc::now(); - let interactions: Vec = (0..200) - .map(|i| mk(now - Duration::hours(i), i % 2 == 0, 10_000)) - .collect(); - let s = score(&interactions, now); - for c in [s.recency, s.frequency, s.reciprocity, s.depth, s.score] { - assert!((0.0..=1.0).contains(&c), "component out of range: {c}"); - } - // 200 interactions all within a few days → window_count ≥ FREQUENCY_CAP - assert_eq!(s.frequency, 1.0); - assert_eq!(s.depth, 1.0); - } - - #[test] - fn one_sided_conversation_has_zero_reciprocity() { - let now = Utc::now(); - let v: Vec<_> = (0..5) - .map(|i| mk(now - Duration::hours(i), true, 100)) - .collect(); - let s = score(&v, now); - assert_eq!(s.reciprocity, 0.0); - assert_eq!( - s.score, 0.0, - "composite must be zero when any factor is zero" - ); - } - - #[test] - fn deterministic_given_same_inputs() { - let now = Utc::now(); - let v = vec![ - mk(now - Duration::days(1), true, 100), - mk(now - Duration::days(2), false, 150), - mk(now - Duration::days(3), true, 200), - ]; - let a = score(&v, now); - let b = score(&v, now); - assert_eq!(a.score, b.score); - assert_eq!(a.recency, b.recency); - } - - #[test] - fn old_burst_does_not_inflate_frequency_score() { - // 100 interactions from 90 days ago (outside FREQUENCY_WINDOW_DAYS=30) - // should contribute 0 to frequency; 1 interaction today should give - // 1/FREQUENCY_CAP. - let now = Utc::now(); - let mut v: Vec = (0..100) - .map(|i| mk(now - Duration::days(90 + i), true, 100)) - .collect(); - // Add one recent interaction to avoid zero reciprocity forcing score=0 - v.push(mk(now - Duration::hours(1), false, 100)); - let s = score(&v, now); - // Only 1 interaction falls within the 30-day window. - let expected_frequency = 1.0 / FREQUENCY_CAP; - assert!( - (s.frequency - expected_frequency).abs() < 0.001, - "frequency should be {expected_frequency}, got {}", - s.frequency - ); - } - - #[test] - fn interactions_exactly_at_window_boundary_are_included() { - let now = Utc::now(); - // Interaction exactly FREQUENCY_WINDOW_DAYS ago — should be included - // (boundary is inclusive via >=). - let boundary = now - Duration::days(FREQUENCY_WINDOW_DAYS as i64); - let v = vec![ - mk(boundary, true, 100), - mk(now - Duration::hours(1), false, 100), - ]; - let s = score(&v, now); - let expected = 2.0 / FREQUENCY_CAP; - assert!( - (s.frequency - expected).abs() < 0.001, - "expected {expected} got {}", - s.frequency - ); - } -} diff --git a/core/src/people/store.rs b/core/src/people/store.rs deleted file mode 100644 index 2ceec52..0000000 --- a/core/src/people/store.rs +++ /dev/null @@ -1,653 +0,0 @@ -//! SQLite-backed store for people + handle aliases + interactions. -//! -//! Connection is wrapped in `Arc>` so handlers and tests -//! can share ownership across tokio tasks; operations are synchronous and -//! fast (all single-row CRUD or small aggregates). - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock, RwLock}; - -use chrono::{DateTime, TimeZone, Utc}; -use rusqlite::{params, Connection, OptionalExtension, Result as SqlResult}; -use tokio::sync::Mutex; - -use crate::people::migrations; -use crate::people::types::{Handle, Interaction, Person, PersonId}; - -pub type ConnHandle = Arc>; -type PersonRow = ( - String, - Option, - Option, - Option, - i64, - i64, -); - -/// Process-global handle to the `PeopleStore`, tagged with the workspace it is -/// bound to. Controller handlers are free functions with no `&self`, so they -/// fetch the store via `get()`. Seeded at core boot and re-bound on active-user -/// switch via [`init_from_workspace`]. Absent at test time unless a test seeds -/// it; most tests construct stores directly with `open_in_memory`. -#[derive(Clone)] -struct GlobalPeopleStore { - workspace_dir: PathBuf, - store: Arc, -} - -type GlobalStoreSlot = RwLock>; - -static GLOBAL: OnceLock = OnceLock::new(); - -fn global_slot() -> &'static GlobalStoreSlot { - GLOBAL.get_or_init(GlobalStoreSlot::default) -} - -/// Initialise or re-bind the process-global people store from a workspace -/// directory, opening `/people/people.db` (schema migrations run on -/// open). -/// -/// Mirrors [`crate::global::init`]: safe to call repeatedly. -/// A call for the **same** workspace returns the existing store; a call for a -/// **different** workspace replaces the global handle so a post-login -/// active-user switch (or `restart_core_process`, which restarts the embedded -/// core in the same Tauri process) does not keep people controllers/tools -/// reading and writing the pre-login (or a previous user's) workspace. -/// -/// Wired into core boot (`src/core/jsonrpc.rs`) and the active-user rebind -/// sites (`credentials::ops`, `app_state::ops`) alongside `memory::global`. -/// Without the boot seed every people controller / `people_*` tool fails with -/// "people store not initialised" (Sentry TAURI-RUST-8NM); without the rebind -/// they'd write the wrong workspace after login (#4378). -pub fn init_from_workspace(workspace_dir: &Path) -> Result, String> { - let slot = global_slot(); - if let Some(existing) = slot - .read() - .map_err(|e| format!("[people:store] read lock poisoned: {e}"))? - .as_ref() - { - if existing.workspace_dir == workspace_dir { - log::debug!("[people:store] already initialised for current workspace"); - return Ok(Arc::clone(&existing.store)); - } - } - - let db_path = workspace_dir.join("people").join("people.db"); - let store = Arc::new( - PeopleStore::open_at(&db_path).map_err(|e| format!("people store open failed: {e}"))?, - ); - - let mut guard = slot - .write() - .map_err(|e| format!("[people:store] write lock poisoned: {e}"))?; - // Re-check under the write lock: a concurrent caller may have seeded the - // same workspace while we were opening — reuse theirs. A different-workspace - // entry is replaced (rebind). - if let Some(existing) = guard.as_ref() { - if existing.workspace_dir == workspace_dir { - return Ok(Arc::clone(&existing.store)); - } - } - log::info!( - "[people:store] bound store workspace={}", - workspace_dir.display() - ); - *guard = Some(GlobalPeopleStore { - workspace_dir: workspace_dir.to_path_buf(), - store: Arc::clone(&store), - }); - Ok(store) -} - -pub fn get() -> Result, &'static str> { - global_slot() - .read() - .ok() - .and_then(|guard| guard.as_ref().map(|entry| Arc::clone(&entry.store))) - .ok_or("people store not initialised — core startup hasn't completed") -} - -/// Per-workspace store cache keyed by workspace dir. Backs [`for_workspace`], -/// the context-scoped accessor (the host's context-scoped `CoreContext::people`). -/// Distinct from the single `GLOBAL` slot above (which tracks the one -/// active-user workspace for the legacy free-function handlers): this map lets -/// multiple workspaces' stores coexist in one process, which is what per-context -/// isolation (Phase 3) needs. -static STORES: OnceLock>>> = - OnceLock::new(); - -/// Open (or return the cached) people store for a specific workspace dir. Unlike -/// [`get`], this is not tied to the single active-user global — two different -/// workspaces resolve to two isolated stores, and the same workspace always -/// resolves to the same cached `Arc`. Opening `/people/people.db` -/// runs schema migrations. -pub fn for_workspace(workspace_dir: &Path) -> Result, String> { - let cache = STORES.get_or_init(Default::default); - if let Some(store) = cache - .read() - .map_err(|e| format!("[people:store] cache read lock poisoned: {e}"))? - .get(workspace_dir) - { - return Ok(Arc::clone(store)); - } - - let db_path = workspace_dir.join("people").join("people.db"); - let store = Arc::new( - PeopleStore::open_at(&db_path).map_err(|e| format!("people store open failed: {e}"))?, - ); - - let mut guard = cache - .write() - .map_err(|e| format!("[people:store] cache write lock poisoned: {e}"))?; - // Re-check under the write lock: a concurrent caller may have opened the - // same workspace while we were opening — reuse theirs so callers always - // share one store per workspace. - let entry = guard - .entry(workspace_dir.to_path_buf()) - .or_insert_with(|| Arc::clone(&store)); - Ok(Arc::clone(entry)) -} - -pub struct PeopleStore { - pub conn: ConnHandle, -} - -impl PeopleStore { - pub fn open_in_memory() -> SqlResult { - let conn = Connection::open_in_memory()?; - migrations::run(&conn)?; - Ok(Self { - conn: Arc::new(Mutex::new(conn)), - }) - } - - pub fn open_at(path: &std::path::Path) -> SqlResult { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let conn = Connection::open(path)?; - migrations::run(&conn)?; - Ok(Self { - conn: Arc::new(Mutex::new(conn)), - }) - } - - /// Insert a new person and its initial set of handles, atomically. - pub async fn insert_person(&self, person: &Person, handles: &[Handle]) -> SqlResult<()> { - let conn = self.conn.clone(); - let person = person.clone(); - let handles: Vec = handles.iter().map(|h| h.canonicalize()).collect(); - tokio::task::spawn_blocking(move || { - let mut guard = conn.blocking_lock(); - let tx = guard.transaction()?; - tx.execute( - "INSERT INTO people(id, display_name, primary_email, primary_phone, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - person.id.to_string(), - person.display_name, - person.primary_email, - person.primary_phone, - person.created_at.timestamp(), - person.updated_at.timestamp(), - ], - )?; - for h in &handles { - let (kind, value) = h.as_key(); - tx.execute( - "INSERT OR IGNORE INTO handle_aliases(kind, value, person_id, created_at) \ - VALUES (?1, ?2, ?3, CAST(strftime('%s','now') AS INTEGER))", - params![kind, value, person.id.to_string()], - )?; - } - tx.commit() - }) - .await - .map_err(|e| rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ))? - } - - /// Resolve an existing canonical handle or insert a new person and alias - /// under one connection lock. Returns the database-authoritative id plus - /// whether this call created the row. - pub async fn resolve_or_insert_person( - &self, - person: &Person, - handle: &Handle, - ) -> SqlResult<(PersonId, bool)> { - let conn = self.conn.clone(); - let person = person.clone(); - let handle = handle.canonicalize(); - tokio::task::spawn_blocking(move || -> SqlResult<(PersonId, bool)> { - let mut guard = conn.blocking_lock(); - let tx = guard.transaction()?; - let (kind, value) = handle.as_key(); - let existing: Option = tx - .query_row( - "SELECT person_id FROM handle_aliases WHERE kind = ?1 AND value = ?2", - params![kind, value], - |row| row.get(0), - ) - .optional()?; - if let Some(id) = existing { - let id = uuid::Uuid::parse_str(&id) - .map(PersonId) - .map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string()))?; - return Ok((id, false)); - } - - tx.execute( - "INSERT INTO people(id, display_name, primary_email, primary_phone, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - person.id.to_string(), - person.display_name, - person.primary_email, - person.primary_phone, - person.created_at.timestamp(), - person.updated_at.timestamp(), - ], - )?; - tx.execute( - "INSERT INTO handle_aliases(kind, value, person_id, created_at) \ - VALUES (?1, ?2, ?3, CAST(strftime('%s','now') AS INTEGER))", - params![kind, value, person.id.to_string()], - )?; - tx.commit()?; - Ok((person.id, true)) - }) - .await - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ) - })? - } - - /// Attach a handle alias to an existing person. Idempotent via - /// `INSERT OR IGNORE` on `(kind, value)`. - pub async fn add_alias(&self, person_id: PersonId, handle: Handle) -> SqlResult<()> { - let conn = self.conn.clone(); - let handle = handle.canonicalize(); - tokio::task::spawn_blocking(move || { - let guard = conn.blocking_lock(); - let (kind, value) = handle.as_key(); - guard.execute( - "INSERT OR IGNORE INTO handle_aliases(kind, value, person_id, created_at) \ - VALUES (?1, ?2, ?3, CAST(strftime('%s','now') AS INTEGER))", - params![kind, value, person_id.to_string()], - )?; - Ok(()) - }) - .await - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ) - })? - } - - /// Resolve a canonicalized handle to a `PersonId`, or `None` if unknown. - pub async fn lookup(&self, handle: &Handle) -> SqlResult> { - let conn = self.conn.clone(); - let handle = handle.canonicalize(); - tokio::task::spawn_blocking(move || { - let guard = conn.blocking_lock(); - let (kind, value) = handle.as_key(); - let id: Option = guard - .query_row( - "SELECT person_id FROM handle_aliases WHERE kind = ?1 AND value = ?2", - params![kind, value], - |row| row.get(0), - ) - .optional()?; - Ok(id.and_then(|s| uuid::Uuid::parse_str(&s).ok().map(PersonId))) - }) - .await - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ) - })? - } - - /// Load a person and all their aliases. - pub async fn get(&self, person_id: PersonId) -> SqlResult> { - let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || -> SqlResult> { - let guard = conn.blocking_lock(); - let row: Option = - guard - .query_row( - "SELECT id, display_name, primary_email, primary_phone, created_at, updated_at \ - FROM people WHERE id = ?1", - params![person_id.to_string()], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)), - ) - .optional()?; - let Some((id_str, display_name, primary_email, primary_phone, created, updated)) = row - else { - return Ok(None); - }; - let id = uuid::Uuid::parse_str(&id_str) - .map(PersonId) - .map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string()))?; - let handles = load_handles(&guard, &id)?; - Ok(Some(Person { - id, - display_name, - primary_email, - primary_phone, - handles, - created_at: ts_to_dt(created), - updated_at: ts_to_dt(updated), - })) - }) - .await - .map_err(|e| rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ))? - } - - /// List all people (unordered — scorer applies ranking separately). - pub async fn list(&self) -> SqlResult> { - let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || -> SqlResult> { - let guard = conn.blocking_lock(); - let mut stmt = guard.prepare( - "SELECT id, display_name, primary_email, primary_phone, created_at, updated_at \ - FROM people ORDER BY display_name", - )?; - let rows = stmt.query_map([], |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, Option>(1)?, - r.get::<_, Option>(2)?, - r.get::<_, Option>(3)?, - r.get::<_, i64>(4)?, - r.get::<_, i64>(5)?, - )) - })?; - let mut out = Vec::new(); - for r in rows { - let (id_str, display_name, primary_email, primary_phone, created, updated) = r?; - let id = uuid::Uuid::parse_str(&id_str) - .map(PersonId) - .map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string()))?; - let handles = load_handles(&guard, &id)?; - out.push(Person { - id, - display_name, - primary_email, - primary_phone, - handles, - created_at: ts_to_dt(created), - updated_at: ts_to_dt(updated), - }); - } - Ok(out) - }) - .await - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ) - })? - } - - /// Record a single interaction. - pub async fn record_interaction(&self, i: Interaction) -> SqlResult<()> { - let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let guard = conn.blocking_lock(); - guard.execute( - "INSERT INTO interactions(person_id, ts, is_outbound, length) \ - VALUES (?1, ?2, ?3, ?4)", - params![ - i.person_id.to_string(), - i.ts.timestamp(), - if i.is_outbound { 1_i64 } else { 0_i64 }, - i.length as i64, - ], - )?; - Ok(()) - }) - .await - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ) - })? - } - - /// Fetch all interactions for a person, newest first. - pub async fn interactions_for(&self, person_id: PersonId) -> SqlResult> { - let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || -> SqlResult> { - let guard = conn.blocking_lock(); - let mut stmt = guard.prepare( - "SELECT ts, is_outbound, length FROM interactions \ - WHERE person_id = ?1 ORDER BY ts DESC", - )?; - let rows = stmt.query_map(params![person_id.to_string()], |r| { - Ok(( - r.get::<_, i64>(0)?, - r.get::<_, i64>(1)?, - r.get::<_, i64>(2)?, - )) - })?; - let mut out = Vec::new(); - for r in rows { - let (ts, is_out, length) = r?; - out.push(Interaction { - person_id, - ts: ts_to_dt(ts), - is_outbound: is_out != 0, - length: length.max(0) as u32, - }); - } - Ok(out) - }) - .await - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ) - })? - } - - /// Fetch interactions for several people in one query, keyed by person id. - pub async fn batch_interactions_for( - &self, - person_ids: &[PersonId], - ) -> SqlResult>> { - if person_ids.is_empty() { - return Ok(HashMap::new()); - } - let conn = self.conn.clone(); - let ids: Vec = person_ids.to_vec(); - tokio::task::spawn_blocking(move || -> SqlResult>> { - let guard = conn.blocking_lock(); - let placeholders = std::iter::repeat_n("?", ids.len()) - .collect::>() - .join(","); - let sql = format!( - "SELECT person_id, ts, is_outbound, length FROM interactions \ - WHERE person_id IN ({placeholders}) ORDER BY person_id, ts DESC" - ); - let id_strings: Vec = ids.iter().map(ToString::to_string).collect(); - let mut stmt = guard.prepare(&sql)?; - let rows = stmt.query_map(rusqlite::params_from_iter(id_strings.iter()), |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, i64>(1)?, - r.get::<_, i64>(2)?, - r.get::<_, i64>(3)?, - )) - })?; - let mut out: HashMap> = HashMap::new(); - for r in rows { - let (id_str, ts, is_out, length) = r?; - let person_id = uuid::Uuid::parse_str(&id_str) - .map(PersonId) - .map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string()))?; - out.entry(person_id).or_default().push(Interaction { - person_id, - ts: ts_to_dt(ts), - is_outbound: is_out != 0, - length: length.max(0) as u32, - }); - } - Ok(out) - }) - .await - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::SystemIoFailure, - extended_code: 0, - }, - Some(e.to_string()), - ) - })? - } -} - -fn load_handles(conn: &Connection, id: &PersonId) -> SqlResult> { - let mut stmt = conn.prepare( - "SELECT kind, value FROM handle_aliases WHERE person_id = ?1 ORDER BY kind, value", - )?; - let rows = stmt.query_map(params![id.to_string()], |r| { - Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) - })?; - let mut out = Vec::new(); - for r in rows { - let (kind, value) = r?; - let h = match kind.as_str() { - "imessage" => Handle::IMessage(value), - "email" => Handle::Email(value), - "display_name" => Handle::DisplayName(value), - other => { - return Err(rusqlite::Error::InvalidColumnName(format!( - "unknown handle kind: {other}" - ))); - } - }; - out.push(h); - } - Ok(out) -} - -fn ts_to_dt(ts: i64) -> DateTime { - Utc.timestamp_opt(ts, 0) - .single() - .unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn insert_list_and_lookup_round_trip() { - let s = PeopleStore::open_in_memory().unwrap(); - let now = Utc::now(); - let p = Person { - id: PersonId::new(), - display_name: Some("Sarah Lee".into()), - primary_email: Some("sarah@example.com".into()), - primary_phone: None, - handles: vec![], - created_at: now, - updated_at: now, - }; - s.insert_person( - &p, - &[ - Handle::Email("Sarah@Example.com".into()), - Handle::DisplayName("Sarah Lee".into()), - ], - ) - .await - .unwrap(); - - let got = s - .lookup(&Handle::Email("sarah@example.com".into())) - .await - .unwrap(); - assert_eq!(got, Some(p.id)); - - let list = s.list().await.unwrap(); - assert_eq!(list.len(), 1); - assert_eq!(list[0].handles.len(), 2); - } - - #[tokio::test] - async fn interactions_round_trip() { - let s = PeopleStore::open_in_memory().unwrap(); - let now = Utc::now(); - let pid = PersonId::new(); - let p = Person { - id: pid, - display_name: Some("X".into()), - primary_email: None, - primary_phone: None, - handles: vec![], - created_at: now, - updated_at: now, - }; - s.insert_person(&p, &[]).await.unwrap(); - s.record_interaction(Interaction { - person_id: pid, - ts: now, - is_outbound: true, - length: 100, - }) - .await - .unwrap(); - s.record_interaction(Interaction { - person_id: pid, - ts: now, - is_outbound: false, - length: 50, - }) - .await - .unwrap(); - let ints = s.interactions_for(pid).await.unwrap(); - assert_eq!(ints.len(), 2); - } -} diff --git a/core/src/people/tests.rs b/core/src/people/tests.rs deleted file mode 100644 index fc91e19..0000000 --- a/core/src/people/tests.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Cross-file integration tests for the people domain. - -use std::sync::Arc; - -use chrono::Utc; - -#[cfg(not(target_os = "macos"))] -use crate::people::address_book; -use crate::people::resolver::HandleResolver; -use crate::people::store::PeopleStore; -use crate::people::types::{Handle, PersonId}; - -#[tokio::test] -async fn resolver_and_store_cooperate_across_handle_kinds() { - let s = PeopleStore::open_in_memory().unwrap(); - let r = HandleResolver::new(&s); - - // Email mints. - let id = r - .resolve_or_create(&Handle::Email("a@b.c".into())) - .await - .unwrap(); - // iMessage handle linked to same person. - let id2 = r - .link( - &Handle::Email("a@b.c".into()), - Handle::IMessage("+15551234".into()), - ) - .await - .unwrap(); - assert_eq!(id, id2); - - // Resolving by the linked iMessage handle returns the same id. - let via_imsg = r - .resolve(&Handle::IMessage("+15551234".into())) - .await - .unwrap(); - assert_eq!(via_imsg, Some(id)); -} - -#[cfg(not(target_os = "macos"))] -#[test] -fn address_book_is_empty_on_non_mac() { - assert!(address_book::read().unwrap().is_empty()); -} - -/// Regression for Sentry TAURI-RUST-8NM (store never seeded → `get()` always -/// errored) and its #4378 follow-up (store stayed bound to the pre-login -/// workspace after an active-user switch). Verify `init_from_workspace` seeds -/// the global + creates the on-disk db, is an idempotent no-op for the same -/// workspace, and **rebinds** to a different workspace like `memory::global`. -/// -/// Serialised (not `#[tokio::test]` parallel) because it mutates the -/// process-global store slot other people tests may observe via `get()`. -#[test] -fn init_from_workspace_seeds_and_rebinds_global_store() { - use crate::people::store; - - let ws_a = tempfile::tempdir().unwrap(); - let store_a = store::init_from_workspace(ws_a.path()).unwrap(); - assert!( - ws_a.path().join("people").join("people.db").exists(), - "seed must create /people/people.db" - ); - - // Previously-dead global is now reachable — the 8NM fix. - let via_global = store::get().expect("people store reachable after seed"); - assert!(Arc::ptr_eq(&store_a, &via_global)); - - // Same workspace → idempotent no-op, returns the same instance. - let again = store::init_from_workspace(ws_a.path()).unwrap(); - assert!(Arc::ptr_eq(&store_a, &again)); - - // Different workspace (active-user switch) → rebind to a new store. #4378. - let ws_b = tempfile::tempdir().unwrap(); - let store_b = store::init_from_workspace(ws_b.path()).unwrap(); - assert!( - !Arc::ptr_eq(&store_a, &store_b), - "a new workspace must rebind to a fresh store, not reuse the old one" - ); - let after_switch = store::get().expect("people store reachable after rebind"); - assert!( - Arc::ptr_eq(&store_b, &after_switch), - "get() must return the rebound (workspace B) store after a switch" - ); -} - -#[test] -fn person_id_uuid_format() { - let id = PersonId::new(); - // Round-trips through a string. - let s = id.to_string(); - let parsed: uuid::Uuid = s.parse().unwrap(); - assert_eq!(parsed, id.0); - let _now = Utc::now(); -} diff --git a/core/src/people/types.rs b/core/src/people/types.rs deleted file mode 100644 index 34a0ec7..0000000 --- a/core/src/people/types.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Core types for the people domain. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Canonical, stable identifier for a person across handles. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct PersonId(pub Uuid); - -impl PersonId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for PersonId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for PersonId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -/// A handle is an opaque label by which the user or a source knows a person. -/// `IMessage(h)` is an iMessage chat handle (phone in E.164, or apple id -/// email). `Email(e)` and `DisplayName(n)` are the other two kinds the A5 -/// resolver accepts. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(tag = "kind", content = "value", rename_all = "snake_case")] -pub enum Handle { - IMessage(String), - Email(String), - DisplayName(String), -} - -impl Handle { - /// Return a canonical, case-folded, whitespace-trimmed form used both - /// for storage and for the resolver lookup key. Emails are lowercased; - /// iMessage handles strip surrounding whitespace and lowercase email- - /// style handles; display names are whitespace-collapsed and trimmed. - pub fn canonicalize(&self) -> Handle { - match self { - Handle::IMessage(s) => { - let t = s.trim(); - // An apple id email handle ("foo@bar.com") is treated the - // same regardless of case; phone-style handles ("+1…") have - // no case. Lowercasing is safe for both. - Handle::IMessage(t.to_lowercase()) - } - Handle::Email(s) => Handle::Email(s.trim().to_lowercase()), - Handle::DisplayName(s) => { - let collapsed: String = s.split_whitespace().collect::>().join(" "); - Handle::DisplayName(collapsed) - } - } - } - - /// `(kind, value)` tuple suitable for use as a SQL key. - pub fn as_key(&self) -> (&'static str, &str) { - match self { - Handle::IMessage(s) => ("imessage", s.as_str()), - Handle::Email(s) => ("email", s.as_str()), - Handle::DisplayName(s) => ("display_name", s.as_str()), - } - } -} - -/// Stored representation of a person plus display metadata. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Person { - pub id: PersonId, - pub display_name: Option, - pub primary_email: Option, - pub primary_phone: Option, - pub handles: Vec, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// A single interaction observed with a person. The scorer aggregates -/// these. `is_outbound = true` means the user sent it; that's what drives -/// reciprocity. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Interaction { - pub person_id: PersonId, - pub ts: DateTime, - pub is_outbound: bool, - /// Token or character count used as a proxy for "depth". Clamped in - /// scoring; callers may pass e.g. message body length. - pub length: u32, -} - -/// Per-component breakdown of a person-score in `[0,1]`. Exposed so that -/// callers (UI, nudge engine) can explain ranking. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct ScoreComponents { - pub recency: f32, - pub frequency: f32, - pub reciprocity: f32, - pub depth: f32, - /// Final composite score. `recency * frequency * reciprocity * depth`, - /// clamped to `[0,1]`. - pub score: f32, -} - -/// Lightweight row returned from the macOS Address Book. We keep this a -/// plain data struct so `address_book::read()` can return the same shape -/// on every OS (empty on non-mac). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AddressBookContact { - pub display_name: Option, - pub emails: Vec, - pub phones: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn handle_canonicalize_lowercases_emails_and_imessage() { - assert_eq!( - Handle::Email(" Foo@Example.COM ".into()).canonicalize(), - Handle::Email("foo@example.com".into()) - ); - assert_eq!( - Handle::IMessage("+1 (555) 123".into()).canonicalize(), - Handle::IMessage("+1 (555) 123".into()) - ); - assert_eq!( - Handle::IMessage(" Foo@Bar.com ".into()).canonicalize(), - Handle::IMessage("foo@bar.com".into()) - ); - } - - #[test] - fn handle_canonicalize_collapses_display_name_whitespace() { - assert_eq!( - Handle::DisplayName(" Sarah Lee ".into()).canonicalize(), - Handle::DisplayName("Sarah Lee".into()) - ); - } - - #[test] - fn handle_as_key_returns_correct_kind() { - assert_eq!(Handle::Email("a@b.c".into()).as_key(), ("email", "a@b.c")); - assert_eq!(Handle::IMessage("+1".into()).as_key(), ("imessage", "+1")); - assert_eq!( - Handle::DisplayName("X".into()).as_key(), - ("display_name", "X") - ); - } -} diff --git a/core/src/store/client.rs b/core/src/store/client.rs index e93ba9c..24cd438 100644 --- a/core/src/store/client.rs +++ b/core/src/store/client.rs @@ -96,6 +96,18 @@ impl MemoryClient { /// This is public for the `tinymemory-module` provider, which implements /// the TinyMemory contract over this exact client. Product hosts must use /// the guarded provider and must not retain this raw engine handle. + pub fn unified_handle(&self) -> Arc { + Arc::clone(&self.inner) + } + + /// Returns an `Arc` handle backed by the same + /// [`UnifiedMemory`] this client wraps. + /// + /// Prefer this over [`Self::unified_handle`]: the trait is the narrower + /// surface, and a caller that only needs `Memory` should not be able to + /// reach the concrete store's inherent methods. `unified_handle` exists + /// for the module provider's scored-recall path, which needs a query the + /// trait does not carry. pub fn memory_handle(&self) -> Arc { Arc::clone(&self.inner) as Arc } diff --git a/core/src/store/factories.rs b/core/src/store/factories.rs index 1938b3a..e230136 100644 --- a/core/src/store/factories.rs +++ b/core/src/store/factories.rs @@ -603,6 +603,40 @@ pub fn create_memory_client_with_local_ai( Ok(crate::store::MemoryClient::from_unified_memory(store)) } +/// Like [`create_memory_client_with_local_ai`], but rooted at an explicit +/// memory subdirectory instead of the shared `"memory"` tree. +/// +/// Exists for the module's `OpenStore`: a host with per-profile memory needs +/// more than one store in a process, and each one is an ordinary client rooted +/// at `/`. Kept as a separate entry point rather than +/// adding a parameter to the function above, because every existing caller +/// wants the shared tree and a defaulted subdir argument is the kind of thing +/// that silently routes a store somewhere nobody intended. +/// +/// # Errors +/// +/// Propagates whatever opening the store under `memory_subdir` failed with. +pub fn create_memory_client_in_subdir( + memory: &MemoryConfig, + local_embedding_model: Option<&str>, + embedding_api_key: &str, + embedding_routes: &[EmbeddingRouteConfig], + storage_provider: Option<&StorageProviderConfig>, + workspace_dir: &Path, + memory_subdir: &str, +) -> anyhow::Result { + let store = create_unified_memory_full( + memory, + embedding_routes, + storage_provider, + local_embedding_model, + embedding_api_key, + workspace_dir, + memory_subdir, + )?; + Ok(crate::store::MemoryClient::from_unified_memory(store)) +} + /// Create a memory instance specifically for migration purposes. /// /// The unified namespace memory core has a single workspace-scoped diff --git a/core/src/store/namespace_store/fts5.rs b/core/src/store/namespace_store/fts5.rs index 90b1f2c..2bb495a 100644 --- a/core/src/store/namespace_store/fts5.rs +++ b/core/src/store/namespace_store/fts5.rs @@ -71,7 +71,20 @@ END; "#; /// Insert an episodic entry. -pub fn episodic_insert(conn: &Arc>, entry: &EpisodicEntry) -> anyhow::Result<()> { +/// Insert one episodic turn, returning the row id it was assigned. +/// +/// # Why the id comes back from here +/// +/// Callers used to insert and then issue `SELECT last_insert_rowid()`. That is +/// **connection-local** state: this store hands the same `Arc>` +/// to several writers, so an interleaved insert between the two statements +/// returns the wrong id — and the caller files the turn under the wrong +/// conversation segment. Reading it here, still under the lock taken for the +/// insert, is the only place it can be read correctly. +pub fn episodic_insert( + conn: &Arc>, + entry: &EpisodicEntry, +) -> anyhow::Result { if safety::has_likely_secret(&entry.session_id) || safety::has_likely_secret(&entry.role) { tracing::warn!( "[memory:safety] episodic insert rejected secret-like session/role session_chars={} role_chars={}", @@ -139,12 +152,14 @@ pub fn episodic_insert(conn: &Arc>, entry: &EpisodicEntry) -> entry.cost_microdollars as i64, ], )?; + // Still holding the lock taken above — see the doc comment. + let id = conn.last_insert_rowid(); tracing::debug!( - "[fts5] inserted episodic entry: session={}, role={}", + "[fts5] inserted episodic entry: session={}, role={}, id={id}", entry.session_id, entry.role ); - Ok(()) + Ok(id) } /// Full-text search over episodic entries. diff --git a/core/src/tree/retrieval/cover.rs b/core/src/tree/retrieval/cover.rs index 6f72f9a..d5b7b09 100644 --- a/core/src/tree/retrieval/cover.rs +++ b/core/src/tree/retrieval/cover.rs @@ -8,6 +8,10 @@ use crate::Config; const DEFAULT_LIMIT: usize = 200; +/// Cover a window using the **ambient** source scope. +/// +/// Correct for an in-process caller, which shares this task-local. A caller +/// reached over a transport does not — see [`cover_window_scoped`]. pub async fn cover_window( config: &Config, since_ms: i64, @@ -15,9 +19,40 @@ pub async fn cover_window( source_id: Option<&str>, source_kind: Option, limit: usize, +) -> Result { + cover_window_scoped( + config, + since_ms, + until_ms, + source_id, + source_kind, + limit, + current_source_scope(), + ) + .await +} + +/// Cover a window using an **explicitly supplied** source scope. +/// +/// # Why this exists separately +/// +/// [`cover_window`] reads the source scope from a task-local, which is +/// invisible to a caller in another process — or, in the module's case, on the +/// other side of a bus call within this one. The scope would silently read as +/// absent there, and "absent" means *unrestricted*, so a per-profile source gate +/// would quietly stop applying. That is a permission check failing open, so the +/// transport-facing path takes the scope as an argument and never infers it. +#[allow(clippy::too_many_arguments)] +pub async fn cover_window_scoped( + config: &Config, + since_ms: i64, + until_ms: i64, + source_id: Option<&str>, + source_kind: Option, + limit: usize, + scope: Option>, ) -> Result { let limit = if limit == 0 { DEFAULT_LIMIT } else { limit }; - let scope = current_source_scope(); if source_id.is_some_and(|id| scope.as_ref().is_some_and(|set| !set.contains(id))) { return Ok(QueryResponse::empty()); } diff --git a/core/src/tree/retrieval/drill_down.rs b/core/src/tree/retrieval/drill_down.rs index d6fe480..d74f428 100644 --- a/core/src/tree/retrieval/drill_down.rs +++ b/core/src/tree/retrieval/drill_down.rs @@ -7,12 +7,41 @@ use crate::tree::retrieval::types::RetrievalHit; use crate::tree::score::embed::{build_embedder_from_config, InertEmbedder}; use crate::Config; +/// Walk a summary tree from `node_id`, using the **ambient** scope. +/// +/// Correct in-process; see [`drill_down_scoped`] for the transport-facing path +/// and why it cannot use this one. pub async fn drill_down( config: &Config, node_id: &str, max_depth: u32, query: Option<&str>, limit: Option, +) -> Result> { + drill_down_scoped( + config, + node_id, + max_depth, + query, + limit, + current_source_scope(), + ) + .await +} + +/// Walk a summary tree from `node_id`, using an **explicitly supplied** scope. +/// +/// Exists for the same reason as +/// [`fast_retrieve_scoped`](super::fast::fast_retrieve_scoped): a task-local +/// scope does not cross a transport, and reading it as absent means +/// unrestricted — a source gate failing open. +pub async fn drill_down_scoped( + config: &Config, + node_id: &str, + max_depth: u32, + query: Option<&str>, + limit: Option, + scope: Option>, ) -> Result> { log::debug!( "[retrieval::drill_down] tinycortex max_depth={} has_query={} limit={:?}", @@ -27,10 +56,10 @@ pub async fn drill_down( build_embedder_from_config(config)? }; let bridge = EmbedderBridge(embedder.as_ref()); - let engine_limit = current_source_scope() - .as_ref() - .map(|_| None) - .unwrap_or(limit); + // A scoped walk has to over-fetch: the engine cannot filter by scope, so + // limiting before the retain below would cap the result set with rows that + // are about to be discarded. + let engine_limit = scope.as_ref().map(|_| None).unwrap_or(limit); let mut hits = tinycortex::memory::retrieval::drill_down( &engine_config(config), node_id, @@ -40,7 +69,7 @@ pub async fn drill_down( engine_limit, ) .await?; - if let Some(set) = current_source_scope() { + if let Some(set) = scope { hits.retain(|hit| set.contains(&hit.tree_scope)); } if let Some(limit) = limit { diff --git a/core/src/tree/retrieval/fast.rs b/core/src/tree/retrieval/fast.rs index 5b219e1..926e8a6 100644 --- a/core/src/tree/retrieval/fast.rs +++ b/core/src/tree/retrieval/fast.rs @@ -12,10 +12,29 @@ use crate::Config; pub use tinycortex::memory::retrieval::FastRetrieveOptions; +/// Deterministic graph-walk retrieval using the **ambient** source scope. +/// +/// Correct in-process; see [`fast_retrieve_scoped`] for the transport-facing +/// path and why it cannot use this one. pub async fn fast_retrieve( config: &Config, query: &str, options: FastRetrieveOptions, +) -> Result { + fast_retrieve_scoped(config, query, options, current_source_scope()).await +} + +/// Deterministic graph-walk retrieval using an **explicitly supplied** scope. +/// +/// Exists for the same reason as +/// [`cover_window_scoped`](super::cover::cover_window_scoped): a task-local +/// source scope does not cross a transport, and reading it as absent means +/// unrestricted — a source gate failing open. +pub async fn fast_retrieve_scoped( + config: &Config, + query: &str, + options: FastRetrieveOptions, + scope: Option>, ) -> Result { let query_entities = nlp::extract_query_entities(config, query).await; let entity_ids: Vec<_> = query_entities @@ -35,7 +54,7 @@ pub async fn fast_retrieve( query, &entity_ids, &EmbedderBridge(embedder.as_ref()), - current_source_scope().as_ref(), + scope.as_ref(), options, ) .await diff --git a/core/src/tree/retrieval/fetch.rs b/core/src/tree/retrieval/fetch.rs index 79fdaa4..d45c4dd 100644 --- a/core/src/tree/retrieval/fetch.rs +++ b/core/src/tree/retrieval/fetch.rs @@ -9,12 +9,31 @@ use crate::Config; pub use tinycortex::memory::retrieval::MAX_BATCH; +/// Fetch leaf chunks by id, using the **ambient** scope. +/// +/// Correct in-process; see [`fetch_leaves_scoped`] for the transport-facing +/// path and why it cannot use this one. pub async fn fetch_leaves(config: &Config, chunk_ids: &[String]) -> Result> { + fetch_leaves_scoped(config, chunk_ids, current_source_scope()).await +} + +/// Fetch leaf chunks by id, using an **explicitly supplied** scope. +/// +/// Exists for the same reason as +/// [`fast_retrieve_scoped`](super::fast::fast_retrieve_scoped): the task-local +/// scope belongs to the host's task and does not cross a transport, so a bus +/// caller reading it would find it absent — and absent means unrestricted, +/// which is a source gate failing open. +pub async fn fetch_leaves_scoped( + config: &Config, + chunk_ids: &[String], + scope: Option>, +) -> Result> { log::debug!( "[retrieval::fetch] tinycortex requested={}", chunk_ids.len() ); - let permitted_ids = if let Some(set) = current_source_scope() { + let permitted_ids = if let Some(set) = scope { let chunks = get_chunks_batch(config, chunk_ids)?; chunk_ids .iter() diff --git a/core/src/tree/retrieval/mod.rs b/core/src/tree/retrieval/mod.rs index eb6910b..2c76aa1 100644 --- a/core/src/tree/retrieval/mod.rs +++ b/core/src/tree/retrieval/mod.rs @@ -33,10 +33,10 @@ mod integration_tests; #[cfg(test)] mod source_scope_tests; -pub use cover::cover_window; +pub use cover::{cover_window, cover_window_scoped}; pub use drill_down::drill_down; -pub use fast::{fast_retrieve, FastRetrieveOptions}; +pub use fast::{fast_retrieve, fast_retrieve_scoped, FastRetrieveOptions}; pub use fetch::fetch_leaves; pub use search::search_entities; -pub use source::query_source; +pub use source::{query_source, query_source_scoped, SourceQuery}; pub use types::{EntityMatch, NodeKind, QueryResponse, RetrievalHit}; diff --git a/core/src/tree/retrieval/source.rs b/core/src/tree/retrieval/source.rs index fa91a6a..dbc0f62 100644 --- a/core/src/tree/retrieval/source.rs +++ b/core/src/tree/retrieval/source.rs @@ -10,6 +10,31 @@ use crate::Config; const DEFAULT_LIMIT: usize = 10; +/// What to retrieve, separated from *whose sources* may answer it. +/// +/// The five fields below all describe the query; `scope` describes the caller's +/// authority. Keeping them apart is what lets `query_source_scoped` take three +/// arguments instead of seven — and it puts the security-relevant argument on +/// its own, where a call site cannot bury it among five optional filters. +#[derive(Clone, Copy, Debug, Default)] +pub struct SourceQuery<'a> { + /// Restrict to one source, by id. + pub source_id: Option<&'a str>, + /// Restrict to one kind of source. + pub source_kind: Option, + /// Only consider material from the last N days. + pub time_window_days: Option, + /// Semantic query. `None` (or blank) retrieves without ranking by meaning. + pub query: Option<&'a str>, + /// Row cap; `0` means "no caller preference", which this module replaces + /// with its own default rather than returning nothing. + pub limit: usize, +} + +/// Ranked retrieval over a source's summary tree, using the **ambient** scope. +/// +/// Correct in-process; see [`query_source_scoped`] for the transport-facing +/// path and why it cannot use this one. pub async fn query_source( config: &Config, source_id: Option<&str>, @@ -18,8 +43,40 @@ pub async fn query_source( query: Option<&str>, limit: usize, ) -> Result { + query_source_scoped( + config, + SourceQuery { + source_id, + source_kind, + time_window_days, + query, + limit, + }, + current_source_scope(), + ) + .await +} + +/// Ranked retrieval over a source's summary tree, using an **explicitly +/// supplied** scope. +/// +/// Exists for the same reason as +/// [`fast_retrieve_scoped`](super::fast::fast_retrieve_scoped): a task-local +/// source scope does not cross a transport, and reading it as absent means +/// unrestricted — a source gate failing open. +pub async fn query_source_scoped( + config: &Config, + request: SourceQuery<'_>, + scope: Option>, +) -> Result { + let SourceQuery { + source_id, + source_kind, + time_window_days, + query, + limit, + } = request; let limit = if limit == 0 { DEFAULT_LIMIT } else { limit }; - let scope = current_source_scope(); if source_id.is_some_and(|id| scope.as_ref().is_some_and(|set| !set.contains(id))) { log::debug!("[retrieval::source] explicit source excluded by active scope"); return Ok(QueryResponse::empty()); diff --git a/crates/tinymemory-module/Cargo.lock b/crates/tinymemory-module/Cargo.lock index 1ee3fbd..30ffb20 100644 --- a/crates/tinymemory-module/Cargo.lock +++ b/crates/tinymemory-module/Cargo.lock @@ -1607,6 +1607,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1857,7 +1866,7 @@ dependencies = [ "thiserror 2.0.20", "tinybus-macros", "tokio", - "toml", + "toml 0.8.23", "tracing", "ureq", "zip", @@ -1909,7 +1918,7 @@ dependencies = [ "tinyagents", "tinycortex-api", "tokio", - "toml", + "toml 1.1.4+spec-1.1.0", "tracing", "uuid", "walkdir", @@ -1967,7 +1976,6 @@ dependencies = [ "chrono", "dirs", "futures", - "git2", "log", "parking_lot", "rand 0.8.7", @@ -2010,6 +2018,7 @@ dependencies = [ "tinymemory-core", "tinymemory-tinycortex", "tokio", + "uuid", ] [[package]] @@ -2118,11 +2127,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -2132,6 +2156,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2140,10 +2173,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -2152,6 +2194,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -2701,6 +2749,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/crates/tinymemory-module/Cargo.toml b/crates/tinymemory-module/Cargo.toml index 6c55c4c..c8482f3 100644 --- a/crates/tinymemory-module/Cargo.toml +++ b/crates/tinymemory-module/Cargo.toml @@ -33,7 +33,10 @@ tinymemory = { path = "../.." } # loads this binary compiles neither. tinymemory-core = { path = "../../core", features = ["memory-git"] } tinymemory-tinycortex = { path = "../../adapters/tinycortex" } -tinycortex = { version = "0.1" } +# `people` is enabled here rather than inherited: the module serves the +# `MemoryPeople` family directly off the engine's people store, so it needs the +# gate on even though `tinymemory-core` only re-exports the domain. +tinycortex = { version = "0.1", features = ["people"] } tinyagents = { version = "2.1" } # TinyBus provides the typed service interface and the dynamic module host ABI. # Reached by path now that this crate is its own workspace root: the nested @@ -49,6 +52,9 @@ async-trait = "0.1" # `EmbeddingProvider::embed` is anyhow-typed. anyhow = "1" chrono = "0.4" +# `PersonRef` crosses the contract as an opaque string; the engine keys people +# by `Uuid`, so the People family parses one at the boundary. +uuid = "1" # Diagnostics. Never carries a namespace key or entry content — see `service`. log = "0.4" # Module configuration is JSON supplied by the host at load time. diff --git a/crates/tinymemory-module/src/lib.rs b/crates/tinymemory-module/src/lib.rs index 9d48288..9cad0eb 100644 --- a/crates/tinymemory-module/src/lib.rs +++ b/crates/tinymemory-module/src/lib.rs @@ -154,7 +154,7 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() })?; let provider = provider::ModuleMemoryProvider::new(&config, Arc::new(client)); - service::serve(&connection, Arc::new(provider)).await + service::serve(&connection, Arc::new(provider), config).await } /// Claim this process's single setup slot. @@ -216,6 +216,15 @@ mod exports { "Capabilities", "Health", "Shutdown", + "OpenStore", + "InsertTurn", + "SessionTurns", + "OpenSegment", + "CreateSegment", + "AppendTurn", + "CloseSegment", + "SetSegmentSummary", + "UpsertSegmentEmbedding", "Store", "Get", "Forget", @@ -224,6 +233,40 @@ mod exports { "Recall", "ExportPage", "ImportRecords", + // People. + "ListPeople", + "GetPerson", + "ResolveHandle", + "AddHandleAlias", + "ScorePerson", + "RecordInteraction", + "SeedFromAddressBook", + // Chunks. + "ListChunks", + "GetChunk", + "ChunkDetail", + "StorageKinds", + "ChunkEmbeddings", + // Retrieval. + "FastRetrieve", + "CoverWindow", + "RetrieveSource", + "RetrieveChildren", + "RetrieveLeaves", + "RecallNamespaceScored", + "SearchEntities", + // Profile. + "ListActiveFacets", + "ListAllFacets", + "GetFacet", + "FacetsByType", + "UpsertFacet", + "UpsertProviderFacet", + "SetFacetUserState", + "DeleteFacet", + "DeleteFacetById", + "DropFacetsBelow", + "WorkflowIdentityMatches", "IngestDocument", "IngestChat", "PutDocument", @@ -233,6 +276,9 @@ mod exports { "DeleteDocument", "ClearNamespace", "QueryDocuments", + // Predates the five families this port added; it was implemented + // but never declared, so it was unreachable over the bus too. + "RecallDocuments", "Append", "QuerySource", "DrillDown", diff --git a/crates/tinymemory-module/src/provider.rs b/crates/tinymemory-module/src/provider.rs index 710f56a..48e46a2 100644 --- a/crates/tinymemory-module/src/provider.rs +++ b/crates/tinymemory-module/src/provider.rs @@ -22,16 +22,21 @@ use tinymemory_api::provider::types::{ SourceScope, }; use tinymemory_api::provider::{ - MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryGoals, MemoryGraph, - MemoryIngest, MemoryMaintenance, MemoryPortability, MemoryProvider, MemoryRecall, - MemorySourceSink, MemoryToolMemory, MemoryTree, + AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, ConversationSegment, + CoverWindowQuery, EntityMatch, EpisodicTurn, FacetType, FastRetrieveQuery, MemoryChunks, + MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryEpisodic, MemoryGoals, + MemoryGraph, MemoryIngest, MemoryMaintenance, MemoryPeople, MemoryPortability, MemoryProfile, + MemoryProvider, MemoryRecall, MemoryRetrieval, MemorySourceSink, MemoryToolMemory, MemoryTree, + PersonHandle, PersonInteraction, PersonRecord, PersonScore, ProfileFacet, RankedPerson, + ResolvedPerson, RetrievalHit, RetrievalResponse, SourceRetrievalQuery, UserState, }; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::tool_memory::ToolMemoryRule; use tinymemory_api::tree::{IngestRequest, QueryResult, TreeStatus}; use tinymemory_api::types::{ GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, - NamespaceDocumentInput, NamespaceRetrievalContext, NamespaceSummary, StoredMemoryDocument, + NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, NamespaceSummary, + StoredMemoryDocument, }; use tinymemory_core::store::{MemoryClient, MemoryClientRef}; use tinymemory_tinycortex::TinycortexMemory; @@ -1194,4 +1199,991 @@ impl MemoryProvider for ModuleMemoryProvider { fn as_maintenance(&self) -> Option<&dyn MemoryMaintenance> { Some(self) } + fn as_people(&self) -> Option<&dyn MemoryPeople> { + Some(self) + } + fn as_chunks(&self) -> Option<&dyn MemoryChunks> { + Some(self) + } + fn as_retrieval(&self) -> Option<&dyn MemoryRetrieval> { + Some(self) + } + fn as_profile(&self) -> Option<&dyn MemoryProfile> { + Some(self) + } + fn as_episodic(&self) -> Option<&dyn MemoryEpisodic> { + Some(self) + } +} + +// ── People ─────────────────────────────────────────────────────────────────── +// +// The conversions below destructure both sides exhaustively rather than +// round-tripping through `Self::cross`. That is deliberate. `cross` is a serde +// value round-trip, so it agrees only while the two crates' field *names* agree +// — and they already do not: the engine's `Interaction` names its timestamp +// `ts` where the contract names it `at`. A round-trip would compile and then +// fail at runtime on the first call. +// +// Destructuring makes the opposite trade: a field added or renamed on either +// side is a compile error here, which is the same rule +// `tinymemory-tinycortex::convert` follows and the same reasoning that governs +// the two copies of the contract itself. + +/// The engine's people store for this module's workspace. +/// +/// `for_workspace` caches per workspace directory, so this is a map lookup +/// after the first call rather than a database open. +fn people_store( + workspace: &std::path::Path, +) -> Result, MemoryError> { + tinycortex::memory::people::store::for_workspace(workspace) + .map_err(|error| MemoryError::Other(anyhow::anyhow!("open people store: {error}"))) +} + +fn handle_to_engine(handle: &PersonHandle) -> tinycortex::memory::people::types::Handle { + use tinycortex::memory::people::types::Handle as EngineHandle; + match handle { + PersonHandle::IMessage(value) => EngineHandle::IMessage(value.clone()), + PersonHandle::Email(value) => EngineHandle::Email(value.clone()), + PersonHandle::DisplayName(value) => EngineHandle::DisplayName(value.clone()), + } +} + +fn handle_to_contract(handle: tinycortex::memory::people::types::Handle) -> PersonHandle { + use tinycortex::memory::people::types::Handle as EngineHandle; + match handle { + EngineHandle::IMessage(value) => PersonHandle::IMessage(value), + EngineHandle::Email(value) => PersonHandle::Email(value), + EngineHandle::DisplayName(value) => PersonHandle::DisplayName(value), + } +} + +fn person_to_contract(person: tinycortex::memory::people::types::Person) -> PersonRecord { + let tinycortex::memory::people::types::Person { + id, + display_name, + primary_email, + primary_phone, + handles, + created_at, + updated_at, + } = person; + PersonRecord { + id: id.to_string(), + display_name, + primary_email, + primary_phone, + handles: handles.into_iter().map(handle_to_contract).collect(), + created_at: created_at.to_rfc3339(), + updated_at: updated_at.to_rfc3339(), + } +} + +fn score_to_contract( + score: tinycortex::memory::people::types::ScoreComponents, + interaction_count: usize, +) -> PersonScore { + let tinycortex::memory::people::types::ScoreComponents { + recency, + frequency, + reciprocity, + depth, + score, + } = score; + PersonScore { + recency, + frequency, + reciprocity, + depth, + score, + interaction_count, + } +} + +/// Parse a caller-supplied person id. +/// +/// `PersonRef` is opaque to the caller by contract, so an unparseable one is a +/// caller mistake — `Invalid`, not `NotFound`. Reporting `NotFound` would tell +/// a caller the id was well-formed but absent, which would send them looking +/// for a deleted person rather than at the id they built. +fn parse_person_id( + person_id: &str, +) -> Result { + person_id + .parse::() + .map(tinycortex::memory::people::types::PersonId) + .map_err(|_| MemoryError::Invalid(format!("malformed person id: {person_id}"))) +} + +#[async_trait] +impl MemoryPeople for ModuleMemoryProvider { + async fn list_people(&self, limit: Option) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let people = store + .list() + .await + .map_err(|error| Self::other("list people", error))?; + + let ids: Vec<_> = people.iter().map(|person| person.id).collect(); + let interactions = store + .batch_interactions_for(&ids) + .await + .map_err(|error| Self::other("load interactions", error))?; + + let now = Utc::now(); + let mut ranked: Vec = people + .into_iter() + .map(|person| { + let observed = interactions.get(&person.id).map_or(&[][..], Vec::as_slice); + let closeness = tinycortex::memory::people::scorer::score(observed, now); + RankedPerson { + person: person_to_contract(person), + score: score_to_contract(closeness, observed.len()), + } + }) + .collect(); + + // Descending by composite score. `total_cmp` rather than `partial_cmp`: + // a NaN from a degenerate score would make `partial_cmp` return `None`, + // and an ordering that is not total is undefined behaviour's + // well-behaved cousin — `sort_by` may panic or produce garbage order. + ranked.sort_by(|a, b| b.score.score.total_cmp(&a.score.score)); + if let Some(limit) = limit { + ranked.truncate(limit); + } + Ok(ranked) + } + + async fn get_person(&self, person_id: &str) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let id = parse_person_id(person_id)?; + Ok(store + .get(id) + .await + .map_err(|error| Self::other("get person", error))? + .map(person_to_contract)) + } + + async fn resolve_handle( + &self, + handle: &PersonHandle, + create_if_missing: bool, + ) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let resolver = tinycortex::memory::people::resolver::HandleResolver::new(&store); + let engine_handle = handle_to_engine(handle); + + if create_if_missing { + let (id, created) = resolver + .resolve_or_create_with_status(&engine_handle) + .await + .map_err(|error| Self::other("resolve or create handle", error))?; + return Ok(Some(ResolvedPerson { + id: id.to_string(), + created, + })); + } + + Ok(resolver + .resolve(&engine_handle) + .await + .map_err(|error| Self::other("resolve handle", error))? + .map(|id| ResolvedPerson { + id: id.to_string(), + created: false, + })) + } + + async fn add_handle_alias( + &self, + person_id: &str, + handle: &PersonHandle, + ) -> Result<(), MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let id = parse_person_id(person_id)?; + if store + .get(id) + .await + .map_err(|error| Self::other("look up person", error))? + .is_none() + { + return Err(MemoryError::NotFound(format!("person {person_id}"))); + } + store + .add_alias(id, handle_to_engine(handle).canonicalize()) + .await + .map_err(|error| Self::other("add handle alias", error)) + } + + async fn score_person(&self, person_id: &str) -> Result, MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let id = parse_person_id(person_id)?; + if store + .get(id) + .await + .map_err(|error| Self::other("look up person", error))? + .is_none() + { + return Ok(None); + } + let interactions = store + .interactions_for(id) + .await + .map_err(|error| Self::other("load interactions", error))?; + Ok(Some(score_to_contract( + tinycortex::memory::people::scorer::score(&interactions, Utc::now()), + interactions.len(), + ))) + } + + async fn record_interaction(&self, interaction: &PersonInteraction) -> Result<(), MemoryError> { + let store = people_store(&self.config.workspace_dir)?; + let PersonInteraction { + person_id, + at, + is_outbound, + length, + } = interaction; + let id = parse_person_id(person_id)?; + let ts = chrono::DateTime::parse_from_rfc3339(at) + .map_err(|error| MemoryError::Invalid(format!("malformed interaction time: {error}")))? + .with_timezone(&Utc); + if store + .get(id) + .await + .map_err(|error| Self::other("look up person", error))? + .is_none() + { + return Err(MemoryError::NotFound(format!("person {person_id}"))); + } + store + .record_interaction(tinycortex::memory::people::types::Interaction { + person_id: id, + ts, + is_outbound: *is_outbound, + length: *length, + }) + .await + .map_err(|error| Self::other("record interaction", error)) + } + + async fn seed_from_address_book(&self) -> Result { + let store = people_store(&self.config.workspace_dir)?; + let resolver = tinycortex::memory::people::resolver::HandleResolver::new(&store); + let source = tinycortex::memory::people::address_book::SystemContactsSource; + let (seeded, skipped) = resolver + .seed_from_address_book(&source) + .await + .map_err(|error| Self::other("seed from address book", error))?; + Ok(AddressBookSeedOutcome { seeded, skipped }) + } +} + +// ── Chunks and Retrieval ───────────────────────────────────────────────────── +// +// Both families take the source scope as an **argument** and never read the +// ambient one. `tinymemory_core`'s in-process entry points resolve it from a +// task-local, which the host sets on its own side of the bus — it is simply not +// present in this process. Reading it here would yield `None`, and `None` means +// *unrestricted*, so a per-profile source gate would fail open. That is why the +// `*_scoped` variants exist and why these call them. + +/// Convert a contract scope into the engine's allowlist form. +fn scope_to_engine(scope: Option<&SourceScope>) -> Option> { + scope.map(|scope| scope.allow.iter().cloned().collect()) +} + +#[async_trait] +impl MemoryChunks for ModuleMemoryProvider { + async fn list_chunks( + &self, + query: &ChunkQuery, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + let ChunkQuery { + source_kind, + source_id, + owner, + since_ms, + until_ms, + limit, + offset, + exclude_dropped, + } = query.clone(); + let engine_query = tinymemory_core::store::chunks::ListChunksQuery { + source_kind: source_kind + .map(|kind| Self::cross(&kind, "convert source kind")) + .transpose()?, + source_id, + owner, + since_ms, + until_ms, + limit, + offset, + source_scope: scope_to_engine(scope), + exclude_dropped, + }; + let chunks = blocking(self.config.clone(), "list chunks", move |config| { + tinymemory_core::store::chunks::list_chunks(config, &engine_query) + }) + .await?; + Self::cross(&chunks, "convert chunks") + } + + async fn get_chunk(&self, chunk_id: &str) -> Result, MemoryError> { + let id = chunk_id.to_string(); + let chunk = blocking(self.config.clone(), "get chunk", move |config| { + tinymemory_core::store::chunks::get_chunk(config, &id) + }) + .await?; + match chunk { + Some(chunk) => Ok(Some(Self::cross(&chunk, "convert chunk")?)), + None => Ok(None), + } + } + + async fn chunk_detail(&self, chunk_id: &str) -> Result, MemoryError> { + let id = chunk_id.to_string(); + let detail = blocking(self.config.clone(), "chunk detail", move |config| { + let Some(chunk) = tinymemory_core::store::chunks::get_chunk(config, &id)? else { + return Ok(None); + }; + // The vault read is best-effort: a missing body is reported as + // `None` so the caller can fall back to the row's own content, + // rather than failing the whole detail view over a preview. + let body = tinymemory_core::store::content::read::read_chunk_body(config, &id).ok(); + let has_embedding = + tinymemory_core::store::chunks::get_chunk_embedding(config, &id)?.is_some(); + let lifecycle_status = + tinymemory_core::store::chunks::get_chunk_lifecycle_status(config, &id)?; + let content_path = tinymemory_core::store::chunks::get_chunk_content_path(config, &id)?; + Ok(Some(( + chunk, + body, + has_embedding, + lifecycle_status, + content_path, + ))) + }) + .await?; + + let Some((chunk, body, has_embedding, lifecycle_status, content_path)) = detail else { + return Ok(None); + }; + Ok(Some(ChunkDetail { + chunk: Self::cross(&chunk, "convert chunk")?, + body, + content_path, + lifecycle_status, + has_embedding, + })) + } + + async fn storage_kinds(&self) -> Result, MemoryError> { + Ok(tinymemory_core::store::MemoryKind::ALL + .iter() + .map(|kind| kind.as_str().to_string()) + .collect()) + } + + async fn chunk_embeddings( + &self, + chunk_ids: &[String], + model_signature: &str, + ) -> Result, MemoryError> { + let ids = chunk_ids.to_vec(); + let signature = model_signature.to_string(); + let vectors = blocking( + self.config.clone(), + "load chunk embeddings", + move |config| { + tinymemory_core::store::chunks::get_chunk_embeddings_for_signature_batch( + config, &ids, &signature, + ) + }, + ) + .await?; + // Sorted so the response is deterministic: the engine returns a + // `HashMap`, whose iteration order varies per process and would make an + // otherwise-identical call return a differently-ordered list. + let mut embeddings: Vec = vectors + .into_iter() + .map(|(chunk_id, vector)| ChunkEmbedding { chunk_id, vector }) + .collect(); + embeddings.sort_by(|a, b| a.chunk_id.cmp(&b.chunk_id)); + Ok(embeddings) + } +} + +#[async_trait] +impl MemoryRetrieval for ModuleMemoryProvider { + async fn fast_retrieve( + &self, + query: &str, + options: FastRetrieveQuery, + scope: Option<&SourceScope>, + ) -> Result { + if query.trim().is_empty() { + return Err(MemoryError::Invalid("query must not be empty".to_string())); + } + let engine_options = tinymemory_core::tree::retrieval::FastRetrieveOptions { + limit: options.limit, + max_hops: options.max_hops, + time_window_days: options.time_window_days, + }; + let response = tinymemory_core::tree::retrieval::fast_retrieve_scoped( + &self.config, + query, + engine_options, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("fast retrieve", error))?; + Self::cross(&response, "convert retrieval response") + } + + async fn cover_window( + &self, + window: &CoverWindowQuery, + scope: Option<&SourceScope>, + ) -> Result { + let CoverWindowQuery { + since_ms, + until_ms, + source_id, + source_kind, + limit, + } = window.clone(); + let engine_kind = source_kind + .map(|kind| Self::cross(&kind, "convert source kind")) + .transpose()?; + let response = tinymemory_core::tree::retrieval::cover_window_scoped( + &self.config, + since_ms, + until_ms, + source_id.as_deref(), + engine_kind, + // 0 is the engine's "no caller preference" sentinel, not a request + // for zero rows: `cover_window_scoped` substitutes its own + // DEFAULT_LIMIT for it. Mapping `None` to 0 therefore asks for the + // default, which is what an absent limit means. + limit.unwrap_or(0), + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("cover window", error))?; + Self::cross(&response, "convert retrieval response") + } + + async fn retrieve_source( + &self, + query: &SourceRetrievalQuery, + scope: Option<&SourceScope>, + ) -> Result { + let SourceRetrievalQuery { + source_id, + source_kind, + time_window_days, + query: text, + limit, + } = query.clone(); + let engine_kind = source_kind + .map(|kind| Self::cross(&kind, "convert source kind")) + .transpose()?; + let response = tinymemory_core::tree::retrieval::source::query_source_scoped( + &self.config, + tinymemory_core::tree::retrieval::source::SourceQuery { + source_id: source_id.as_deref(), + source_kind: engine_kind, + time_window_days, + query: text.as_deref(), + limit, + }, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("retrieve source", error))?; + Self::cross(&response, "convert retrieval response") + } + + async fn retrieve_children( + &self, + node_id: &str, + max_depth: u32, + query: Option<&str>, + limit: Option, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + let hits = tinymemory_core::tree::retrieval::drill_down::drill_down_scoped( + &self.config, + node_id, + max_depth, + query, + limit, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("drill down", error))?; + Self::cross(&hits, "convert retrieval hits") + } + + async fn retrieve_leaves( + &self, + chunk_ids: &[String], + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + let hits = tinymemory_core::tree::retrieval::fetch::fetch_leaves_scoped( + &self.config, + chunk_ids, + scope_to_engine(scope), + ) + .await + .map_err(|error| Self::other("fetch leaves", error))?; + Self::cross(&hits, "convert retrieval hits") + } + + async fn recall_namespace_scored( + &self, + namespace: &str, + query: &str, + limit: usize, + exclude_session_id: Option<&str>, + ) -> Result, MemoryError> { + let hits = self + .client + .unified_handle() + .query_namespace_hits_excluding_session( + namespace, + query, + u32::try_from(limit).unwrap_or(u32::MAX), + exclude_session_id, + ) + .await + .map_err(|error| Self::other("recall namespace scored", error))?; + Self::cross(&hits, "convert namespace hits") + } + + async fn search_entities( + &self, + query: &str, + kinds: Option<&[String]>, + limit: usize, + ) -> Result, MemoryError> { + // Request kinds are validated, unlike response kinds which pass through + // as an open vocabulary. An unknown filter that silently matched nothing + // would be indistinguishable from a genuine empty result. + let engine_kinds = match kinds { + Some(kinds) => Some( + kinds + .iter() + .map(|kind| { + tinymemory_core::tree::score::extract::EntityKind::parse(kind).map_err( + |_| MemoryError::Invalid(format!("unknown entity kind: {kind}")), + ) + }) + .collect::, MemoryError>>()?, + ), + None => None, + }; + let matches = tinymemory_core::tree::retrieval::search_entities( + &self.config, + query, + engine_kinds, + limit, + ) + .await + .map_err(|error| Self::other("search entities", error))?; + Self::cross(&matches, "convert entity matches") + } +} + +// ── Profile ────────────────────────────────────────────────────────────────── +// +// `ProfileStore`'s methods are synchronous and hold a `parking_lot::Mutex` +// across a SQLite call, so each one goes through `spawn_blocking` rather than +// being awaited on the runtime thread. The store is cheap to obtain — it is a +// handle over the client's connection, not an open — so it is fetched inside +// the blocking closure rather than held across an await. + +fn facet_type_to_engine( + facet_type: FacetType, +) -> tinymemory_core::store::namespace_store::profile::FacetType { + use tinymemory_core::store::namespace_store::profile::FacetType as Engine; + match facet_type { + FacetType::Preference => Engine::Preference, + FacetType::Workflow => Engine::Workflow, + FacetType::Role => Engine::Role, + FacetType::Personality => Engine::Personality, + FacetType::Context => Engine::Context, + } +} + +#[async_trait] +impl MemoryProfile for ModuleMemoryProvider { + async fn list_active_facets(&self) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let facets = tokio::task::spawn_blocking(move || client.profile_store().list_active()) + .await + .map_err(|e| Self::other("join list_active_facets", e))? + .map_err(|e| Self::other("list_active_facets", e))?; + Self::cross(&facets, "convert facets") + } + + async fn list_all_facets(&self) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let facets = tokio::task::spawn_blocking(move || client.profile_store().list_all()) + .await + .map_err(|e| Self::other("join list_all_facets", e))? + .map_err(|e| Self::other("list_all_facets", e))?; + Self::cross(&facets, "convert facets") + } + + async fn get_facet(&self, key: &str) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let key = key.to_string(); + let facet = tokio::task::spawn_blocking(move || client.profile_store().get(&key)) + .await + .map_err(|e| Self::other("join get_facet", e))? + .map_err(|e| Self::other("get_facet", e))?; + match facet { + Some(facet) => Ok(Some(Self::cross(&facet, "convert facet")?)), + None => Ok(None), + } + } + + async fn facets_by_type( + &self, + facet_type: FacetType, + ) -> Result, MemoryError> { + let client = Arc::clone(&self.client); + let engine = facet_type_to_engine(facet_type); + let facets = + tokio::task::spawn_blocking(move || client.profile_store().facets_by_type(&engine)) + .await + .map_err(|e| Self::other("join facets_by_type", e))? + .map_err(|e| Self::other("facets_by_type", e))?; + Self::cross(&facets, "convert facets") + } + + async fn upsert_facet(&self, facet: &ProfileFacet) -> Result<(), MemoryError> { + let client = Arc::clone(&self.client); + let engine: tinymemory_core::store::namespace_store::profile::ProfileFacet = + Self::cross(facet, "convert facet")?; + tokio::task::spawn_blocking(move || client.profile_store().upsert_full(&engine)) + .await + .map_err(|e| Self::other("join upsert_facet", e))? + .map_err(|e| Self::other("upsert_facet", e)) + } + + async fn upsert_provider_facet( + &self, + facet_id: &str, + facet_type: FacetType, + key: &str, + value: &str, + confidence: f64, + segment_id: Option<&str>, + observed_at: f64, + ) -> Result<(), MemoryError> { + let client = Arc::clone(&self.client); + let engine = facet_type_to_engine(facet_type); + let (facet_id, key, value) = (facet_id.to_string(), key.to_string(), value.to_string()); + let segment_id = segment_id.map(str::to_string); + tokio::task::spawn_blocking(move || { + client.profile_store().upsert_provider_facet( + &facet_id, + &engine, + &key, + &value, + confidence, + segment_id.as_deref(), + observed_at, + ) + }) + .await + .map_err(|e| Self::other("join upsert_provider_facet", e))? + .map_err(|e| Self::other("upsert_provider_facet", e)) + } + + async fn set_facet_user_state( + &self, + key: &str, + user_state: UserState, + ) -> Result { + use tinymemory_core::store::namespace_store::profile::UserState as Engine; + let client = Arc::clone(&self.client); + let key = key.to_string(); + let engine = match user_state { + UserState::Auto => Engine::Auto, + UserState::Pinned => Engine::Pinned, + UserState::Forgotten => Engine::Forgotten, + }; + tokio::task::spawn_blocking(move || client.profile_store().set_user_state(&key, engine)) + .await + .map_err(|e| Self::other("join set_facet_user_state", e))? + .map_err(|e| Self::other("set_facet_user_state", e)) + } + + async fn delete_facet(&self, key: &str) -> Result { + let client = Arc::clone(&self.client); + let key = key.to_string(); + tokio::task::spawn_blocking(move || client.profile_store().delete(&key)) + .await + .map_err(|e| Self::other("join delete_facet", e))? + .map_err(|e| Self::other("delete_facet", e)) + } + + async fn delete_facet_by_id(&self, facet_id: &str) -> Result { + let client = Arc::clone(&self.client); + let facet_id = facet_id.to_string(); + tokio::task::spawn_blocking(move || client.profile_store().delete_by_facet_id(&facet_id)) + .await + .map_err(|e| Self::other("join delete_facet_by_id", e))? + .map_err(|e| Self::other("delete_facet_by_id", e)) + } + + async fn drop_facets_below(&self, threshold: f64) -> Result { + let client = Arc::clone(&self.client); + tokio::task::spawn_blocking(move || client.profile_store().drop_below_threshold(threshold)) + .await + .map_err(|e| Self::other("join drop_facets_below", e))? + .map_err(|e| Self::other("drop_facets_below", e)) + } + + async fn workflow_identity_matches(&self, key_pattern: &str, canonical_value: &str) -> bool { + let client = Arc::clone(&self.client); + let (pattern, value) = (key_pattern.to_string(), canonical_value.to_string()); + tokio::task::spawn_blocking(move || { + client + .profile_store() + .skill_identity_matches(&pattern, &value) + }) + .await + // A join failure reads as "no", like every other error on this + // predicate — see the trait docs. But it is logged first: the two + // cases behind it are a cancelled task and a panic inside + // `skill_identity_matches`, and a panic is a defect. Answering a bare + // `false` would make that defect look exactly like a legitimate + // non-match, which is the one reading that guarantees nobody + // investigates it. + .inspect_err(|error| { + log::error!( + "[tinymemory:module] workflow_identity_matches join failed, answering false: \ + {error}" + ); + }) + .unwrap_or(false) + } +} + +/// Episodic capture: the turn-by-turn record and its segment lifecycle. +/// +/// Every method hops to `spawn_blocking` for the same reason the profile family +/// does — these are synchronous `rusqlite` calls behind a `parking_lot::Mutex`, +/// and blocking a tinybus executor thread on a database lock would stall every +/// other call the module is serving. +/// +/// The boundary-detection and summary-composition halves of the archivist are +/// **not** here: they touch no database and are host policy. See the family's +/// contract docs. +#[async_trait] +impl MemoryEpisodic for ModuleMemoryProvider { + async fn insert_turn(&self, turn: &EpisodicTurn) -> Result { + let conn = self.client.profile_conn(); + let entry = tinymemory_core::store::fts5::EpisodicEntry { + id: None, + session_id: turn.session_id.clone(), + timestamp: turn.timestamp, + role: turn.role.clone(), + content: turn.content.clone(), + lesson: turn.lesson.clone(), + tool_calls_json: turn.tool_calls_json.clone(), + // The contract carries this signed because a cost is a plain number + // on the wire; the engine column is unsigned. A negative value is + // not meaningful, so it clamps rather than wrapping. + cost_microdollars: u64::try_from(turn.cost_microdollars).unwrap_or(0), + }; + tokio::task::spawn_blocking(move || { + tinymemory_core::store::fts5::episodic_insert(&conn, &entry) + }) + .await + .map_err(|e| Self::other("join insert_turn", e))? + .map_err(|e| Self::other("insert_turn", e)) + } + + async fn session_turns(&self, session_id: &str) -> Result, MemoryError> { + let conn = self.client.profile_conn(); + let session_id = session_id.to_string(); + let entries = tokio::task::spawn_blocking(move || { + tinymemory_core::store::fts5::episodic_session_entries(&conn, &session_id) + }) + .await + .map_err(|e| Self::other("join session_turns", e))? + .map_err(|e| Self::other("session_turns", e))?; + Ok(entries.into_iter().map(episodic_to_contract).collect()) + } + + async fn open_segment( + &self, + session_id: &str, + ) -> Result, MemoryError> { + let conn = self.client.profile_conn(); + let session_id = session_id.to_string(); + let segment = tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::open_segment_for_session(&conn, &session_id) + }) + .await + .map_err(|e| Self::other("join open_segment", e))? + .map_err(|e| Self::other("open_segment", e))?; + Ok(segment.map(segment_to_contract)) + } + + async fn create_segment( + &self, + segment_id: &str, + session_id: &str, + namespace: &str, + start_episodic_id: i64, + start_timestamp: f64, + now: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let (segment_id, session_id, namespace) = ( + segment_id.to_string(), + session_id.to_string(), + namespace.to_string(), + ); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_create( + &conn, + &segment_id, + &session_id, + &namespace, + start_episodic_id, + // Per-session seq numbering is the archivist store's, and it is + // not part of this contract; legacy rows carry `None` too. + None, + start_timestamp, + now, + ) + }) + .await + .map_err(|e| Self::other("join create_segment", e))? + .map_err(|e| Self::other("create_segment", e)) + } + + async fn append_turn( + &self, + segment_id: &str, + episodic_id: i64, + timestamp: f64, + now: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let segment_id = segment_id.to_string(); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_append_turn( + &conn, + &segment_id, + episodic_id, + None, + timestamp, + now, + ) + }) + .await + .map_err(|e| Self::other("join append_turn", e))? + .map_err(|e| Self::other("append_turn", e)) + } + + async fn close_segment(&self, segment_id: &str, now: f64) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let segment_id = segment_id.to_string(); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_close(&conn, &segment_id, now) + }) + .await + .map_err(|e| Self::other("join close_segment", e))? + .map_err(|e| Self::other("close_segment", e)) + } + + async fn set_segment_summary( + &self, + segment_id: &str, + summary: &str, + now: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let (segment_id, summary) = (segment_id.to_string(), summary.to_string()); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_set_summary(&conn, &segment_id, &summary, now) + }) + .await + .map_err(|e| Self::other("join set_segment_summary", e))? + .map_err(|e| Self::other("set_segment_summary", e)) + } + + async fn upsert_segment_embedding( + &self, + segment_id: &str, + model_signature: &str, + embedding: &[f32], + created_at: f64, + ) -> Result<(), MemoryError> { + let conn = self.client.profile_conn(); + let (segment_id, model_signature) = (segment_id.to_string(), model_signature.to_string()); + let embedding = embedding.to_vec(); + tokio::task::spawn_blocking(move || { + tinymemory_core::store::segments::segment_embedding_upsert( + &conn, + &segment_id, + &model_signature, + &embedding, + created_at, + ) + }) + .await + .map_err(|e| Self::other("join upsert_segment_embedding", e))? + .map_err(|e| Self::other("upsert_segment_embedding", e)) + } +} + +/// Engine episodic row -> contract turn. +fn episodic_to_contract(entry: tinymemory_core::store::fts5::EpisodicEntry) -> EpisodicTurn { + EpisodicTurn { + id: entry.id, + session_id: entry.session_id, + timestamp: entry.timestamp, + role: entry.role, + content: entry.content, + lesson: entry.lesson, + tool_calls_json: entry.tool_calls_json, + cost_microdollars: i64::try_from(entry.cost_microdollars).unwrap_or(i64::MAX), + } +} + +/// Engine segment row -> contract segment. +/// +/// Written out rather than derived: the engine row carries several fields the +/// contract deliberately does not expose (`topic_keywords`, the seq numbers, +/// `created_at`), and a blanket conversion would quietly start shipping them if +/// the contract ever grew a matching name. +fn segment_to_contract( + segment: tinymemory_core::store::segments::ConversationSegment, +) -> ConversationSegment { + use tinymemory_core::store::segments::SegmentStatus; + ConversationSegment { + segment_id: segment.segment_id, + session_id: segment.session_id, + namespace: segment.namespace, + start_episodic_id: segment.start_episodic_id, + end_episodic_id: segment.end_episodic_id, + start_timestamp: segment.start_timestamp, + end_timestamp: segment.end_timestamp, + turn_count: segment.turn_count, + summary: segment.summary, + embedding: segment.embedding, + open: matches!(segment.status, SegmentStatus::Open), + } } diff --git a/crates/tinymemory-module/src/service/mod.rs b/crates/tinymemory-module/src/service/mod.rs index 56dafd1..d959eaf 100644 --- a/crates/tinymemory-module/src/service/mod.rs +++ b/crates/tinymemory-module/src/service/mod.rs @@ -8,6 +8,7 @@ //! Capabilities() -> Capabilities //! Health() -> MemoryHealth //! Shutdown() -> () +//! OpenStore(memory_subdir) -> object_path //! //! Store(namespace, key, content, category, session_id, taint) -> () //! Get(namespace, key) -> Option @@ -17,8 +18,44 @@ //! Recall(query, limit, opts, scope) -> [MemoryEntry] //! ExportPage(cursor, limit) -> ExportPage //! ImportRecords(records) -> ImportOutcome +//! +//! ListPeople(limit) -> [RankedPerson] +//! GetPerson(person_id) -> Option +//! ResolveHandle(handle, create_if_missing) -> Option +//! AddHandleAlias(person_id, handle) -> () +//! ScorePerson(person_id) -> Option +//! RecordInteraction(interaction) -> () +//! SeedFromAddressBook() -> AddressBookSeedOutcome +//! +//! ListChunks(query, scope) -> [Chunk] +//! GetChunk(chunk_id) -> Option +//! ChunkDetail(chunk_id) -> Option +//! ChunkEmbeddings(chunk_ids, model_signature) -> [ChunkEmbedding] +//! StorageKinds() -> [String] +//! +//! ListActiveFacets() / ListAllFacets() -> [ProfileFacet] +//! GetFacet(key) / FacetsByType(type) -> facet(s) +//! UpsertFacet(facet) / UpsertProviderFacet(…) -> () +//! SetFacetUserState(key, state) / DeleteFacet(key) -> bool +//! DeleteFacetById(id) / DropFacetsBelow(threshold) -> bool / usize +//! WorkflowIdentityMatches(pattern, value) -> bool +//! +//! FastRetrieve(query, options, scope) -> RetrievalResponse +//! CoverWindow(window, scope) -> RetrievalResponse +//! SearchEntities(query, kinds, limit) -> [EntityMatch] +//! RecallNamespaceScored(ns, query, limit, exclude) -> [NamespaceMemoryHit] +//! RetrieveSource(query, scope) -> RetrievalResponse +//! RetrieveChildren(node_id, max_depth, query, limit, scope) -> [RetrievalHit] +//! RetrieveLeaves(chunk_ids, scope) -> [RetrievalHit] //! ``` //! +//! # Source scope crosses as an argument, never as ambient state +//! +//! Every scoped method above takes `scope` explicitly. In-process the engine +//! resolves it from a task-local; that task-local belongs to the *host's* task +//! and does not exist on this side of a bus call. Inferring it here would read +//! as absent, and absent means unrestricted — a source gate failing open. +//! //! # Why the method list mirrors a trait exactly //! //! These are `tinymemory_api`'s [`MemoryProvider`] and all of its capability @@ -68,8 +105,14 @@ //! query.** All three are user memory content, and a module error must not carry //! payload values. +use std::collections::HashMap; use std::sync::Arc; +// Deliberately the async mutex, not `std::sync::Mutex`: the open path holds +// this guard across an `.await` (see `open_store`), which a std guard cannot +// be held across. +use tokio::sync::Mutex; + use tinybus::{Connection, Error as BusError, Result as BusResult}; use tinymemory_api::capabilities::{Capabilities, Capability}; use tinymemory_api::chunks::Chunk; @@ -83,13 +126,25 @@ use tinymemory_api::provider::types::{ // `MemoryCore`, `MemoryRecall` and `MemoryPortability` are deliberately not // imported: they are supertraits of `MemoryProvider`, so their methods are // already callable on the trait object. +use tinymemory_api::provider::chunks::{ChunkDetail, ChunkEmbedding, ChunkQuery}; +use tinymemory_api::provider::episodic::{ConversationSegment, EpisodicTurn}; +use tinymemory_api::provider::people::{ + AddressBookSeedOutcome, PersonHandle, PersonInteraction, PersonRecord, PersonScore, + RankedPerson, ResolvedPerson, +}; +use tinymemory_api::provider::profile::{FacetType, ProfileFacet, UserState}; +use tinymemory_api::provider::retrieval::{ + CoverWindowQuery, EntityMatch, FastRetrieveQuery, RetrievalHit, RetrievalResponse, + SourceRetrievalQuery, +}; use tinymemory_api::provider::MemoryProvider; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::tool_memory::ToolMemoryRule; use tinymemory_api::tree::{IngestRequest, QueryResult, TreeStatus}; use tinymemory_api::types::{ GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, - NamespaceDocumentInput, NamespaceRetrievalContext, NamespaceSummary, StoredMemoryDocument, + NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, NamespaceSummary, + StoredMemoryDocument, }; use tinymemory_api::wire; @@ -99,18 +154,94 @@ pub const BUS_NAME: &str = "ai.tinyhumans.tinymemory.Memory"; /// Object path exported by the `TinyMemory` module. pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinymemory/Memory"; -/// The served object: a bound driver and nothing else. +/// How many stores one module process will open, across every subtree. +/// +/// Sized for "a host with per-profile memory", which is the case `OpenStore` +/// exists for — one store per profile, and a host with sixty-four live profiles +/// in one process is already outside what this was built for. It is a backstop +/// against a caller that opens stores in a loop, not a quota anyone should +/// meet. +pub(crate) const MAX_OPEN_STORES: usize = 64; + +/// The served object: a bound driver, plus what it needs to open a sibling +/// store on request. pub(crate) struct MemoryService { provider: Arc, + /// Everything needed to build a second store under a different subtree. + /// + /// `None` on the objects that `OpenStore` itself creates: a store opened + /// this way cannot open further stores. That is not a limitation worth + /// lifting — the host asks the root object, which knows the workspace — and + /// it keeps the recursion finite by construction. + opener: Option>, +} + +/// The root object's ability to bring up additional stores under the same +/// workspace. +pub(crate) struct StoreOpener { + connection: Connection, + config: crate::config::ModuleConfig, + /// Subtrees already served, so a second `OpenStore` for the same one + /// returns the existing object instead of opening the database twice. + /// + /// Two live handles to one SQLite file is not a hypothetical problem: the + /// engine runs migrations on open, and concurrent migration attempts on the + /// same file are exactly the kind of corruption that is invisible until it + /// is not. + /// + /// The guard is therefore held across the whole open, not just the lookup — + /// a lock released between the check and the insert would let two callers + /// through and produce exactly the double-open it is here to prevent. That + /// is why this is a `tokio::sync::Mutex`. + served: Mutex>, } impl MemoryService { - /// Serve `provider`. + /// Serve `provider` as a leaf object — one store, no opener. pub(crate) fn new(provider: Arc) -> Self { - Self { provider } + Self { + provider, + opener: None, + } + } + + /// Serve `provider` as the root object, able to open sibling stores. + pub(crate) fn root(provider: Arc, opener: Arc) -> Self { + Self { + provider, + opener: Some(opener), + } + } +} + +impl StoreOpener { + pub(crate) fn new(connection: Connection, config: crate::config::ModuleConfig) -> Self { + Self { + connection, + config, + served: Mutex::new(HashMap::new()), + } } } +/// Object path for a store rooted at `memory_subdir`. +/// +/// Derived rather than free-form so a caller cannot name an arbitrary bus path, +/// and sanitised to the characters an object path allows — a subdir reaches +/// this from a profile id, and an id that fails validation must produce a +/// refusal, not a malformed path. +fn object_path_for_subdir(memory_subdir: &str) -> Option { + if memory_subdir.is_empty() + || memory_subdir.len() > 128 + || !memory_subdir + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return None; + } + Some(format!("{OBJECT_PATH}/stores/{memory_subdir}")) +} + macro_rules! require_family { ($service:expr, $accessor:ident, $capability:expr) => { $service @@ -161,6 +292,115 @@ impl MemoryService { .map_err(|error| into_bus_error(&error)) } + /// Bring up a store rooted at `/` and return the + /// object path serving it. + /// + /// # Why the module opens stores rather than the host selecting one per call + /// + /// A host with per-profile memory needs more than one store in a process. + /// The alternative was a store selector threaded through every method on + /// every capability family — a change to the shape of the whole contract, + /// to express something that is not a property of a memory operation at + /// all. Which store you are talking to is settled when you are handed a + /// driver, exactly like which workspace you are bound to. + /// + /// So the root object opens stores and hands back object paths. Each is an + /// ordinary [`MemoryService`] exporting the identical interface, and the + /// contract does not change at all: `MemoryProvider` still describes one + /// store, and a proxy still talks to one store. + /// + /// Idempotent per subtree — see [`StoreOpener::served`] for why opening the + /// same database twice is worth going out of the way to avoid. + async fn open_store(&self, memory_subdir: String) -> BusResult { + let Some(opener) = self.opener.as_ref() else { + return Err(BusError::MethodFailed { + name: "ai.tinyhumans.tinymemory.Error.Invalid".to_string(), + message: "only the root memory object can open stores".to_string(), + }); + }; + let Some(path) = object_path_for_subdir(&memory_subdir) else { + // The subdir is rejected by shape, and the message says so without + // echoing it: it derives from a profile id, which is user data. + return Err(BusError::MethodFailed { + name: "ai.tinyhumans.tinymemory.Error.Invalid".to_string(), + message: "memory subdirectory is empty, over-long, or contains \ + characters outside [A-Za-z0-9_-]" + .to_string(), + }); + }; + + // The guard is taken here and held to the end of the method, so the + // check and the insert cannot be split by the open in between. An + // earlier version dropped it before opening the store, which read as + // idempotent but was not: two concurrent calls for the same subtree + // both missed the map, both opened the database, and both ran + // migrations against one file — the corruption this map exists to + // prevent, arrived at through the map. + // + // It serializes opens of *different* subtrees too. That is accepted + // rather than worked around: an open happens once per profile, and a + // per-key lock map costs more complexity than the contention it saves. + let mut served = opener.served.lock().await; + if let Some(existing) = served.get(&memory_subdir) { + log::debug!("[tinymemory:module] open_store reusing already-served subtree"); + return Ok(existing.clone()); + } + + // Each store is a SQLite file, an object path and a set of file + // descriptors that live until the process exits — nothing here ever + // closes one, because tinybus does not unserve. A caller that opens a + // fresh subdir in a loop would therefore exhaust descriptors with no + // way back short of a restart. The cap is far above any real host (one + // store per profile) and exists so that a bug is refused by name + // instead of degrading the whole process. + if served.len() >= MAX_OPEN_STORES { + log::error!( + "[tinymemory:module] open_store refused: already serving {MAX_OPEN_STORES} stores" + ); + return Err(BusError::MethodFailed { + name: "ai.tinyhumans.tinymemory.Error.Invalid".to_string(), + message: format!( + "this module already serves the maximum of {MAX_OPEN_STORES} memory stores" + ), + }); + } + + let client = tinymemory_core::store::factories::create_memory_client_in_subdir( + &opener.config.memory, + None, + "", + &opener.config.embedding_routes, + opener.config.storage_provider.as_ref(), + &opener.config.workspace_dir, + &memory_subdir, + ) + .map_err(|error| { + // Same reasoning as `setup`: the factory error names this process's + // filesystem layout, which the caller has no business learning. + log::error!("[tinymemory:module] open_store create store failed: {error}"); + BusError::MethodFailed { + name: "ai.tinyhumans.tinymemory.Error.Other".to_string(), + message: "could not open the requested memory store".to_string(), + } + })?; + + let provider = crate::provider::ModuleMemoryProvider::new(&opener.config, Arc::new(client)); + opener + .connection + .serve_at( + path.as_str().try_into()?, + MemoryService::new(Arc::new(provider)), + ) + .await?; + + // Recorded only after `serve_at` succeeds, so a failed open is retried + // rather than caching a path nothing answers on. Both early returns + // above leave the map untouched for the same reason. + served.insert(memory_subdir, path.clone()); + log::info!("[tinymemory:module] open_store now serving an additional memory subtree"); + Ok(path) + } + /// Upsert an entry keyed by `(namespace, key)`. /// /// `taint` is a required argument rather than a defaulted one, mirroring the @@ -627,6 +867,461 @@ impl MemoryService { .await .map_err(|error| into_bus_error(&error)) } + + // ── People ────────────────────────────────────────────────────────────── + + /// Known people, ranked by closeness. + /// + /// Size-checked like the other list-returning methods. `limit` bounds the + /// *count* but not the bytes — a store of people each carrying many handles + /// can still overflow a frame — so the ceiling is enforced on the encoded + /// response rather than trusted to the caller's limit. + async fn list_people(&self, limit: Option) -> BusResult> { + let people = require_family!(self, as_people, Capability::People) + .list_people(limit) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&people, "ListPeople")?; + Ok(people) + } + + async fn get_person(&self, person_id: String) -> BusResult> { + require_family!(self, as_people, Capability::People) + .get_person(&person_id) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn resolve_handle( + &self, + handle: PersonHandle, + create_if_missing: bool, + ) -> BusResult> { + require_family!(self, as_people, Capability::People) + .resolve_handle(&handle, create_if_missing) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn add_handle_alias(&self, person_id: String, handle: PersonHandle) -> BusResult<()> { + require_family!(self, as_people, Capability::People) + .add_handle_alias(&person_id, &handle) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn score_person(&self, person_id: String) -> BusResult> { + require_family!(self, as_people, Capability::People) + .score_person(&person_id) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn record_interaction(&self, interaction: PersonInteraction) -> BusResult<()> { + require_family!(self, as_people, Capability::People) + .record_interaction(&interaction) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn seed_from_address_book(&self) -> BusResult { + require_family!(self, as_people, Capability::People) + .seed_from_address_book() + .await + .map_err(|error| into_bus_error(&error)) + } + + // ── Chunks ────────────────────────────────────────────────────────────── + + /// Chunks matching the query, size-checked. + /// + /// `ChunkQuery::limit` bounds rows, not bytes, and a chunk carries full + /// content — so this is one of the methods where the ceiling matters most. + async fn list_chunks( + &self, + query: ChunkQuery, + scope: Option, + ) -> BusResult> { + let chunks = require_family!(self, as_chunks, Capability::Chunks) + .list_chunks(&query, scope.as_ref()) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&chunks, "ListChunks")?; + Ok(chunks) + } + + /// One chunk, size-checked. + /// + /// A single object is checked for the same reason a list is: the ceiling is + /// a property of the frame, not of the row count, and one chunk carries + /// full content with no bound of its own. A list of one that is refused + /// while the singular read of the same chunk succeeds would be an odd + /// contract to explain. + async fn get_chunk(&self, chunk_id: String) -> BusResult> { + let chunk = require_family!(self, as_chunks, Capability::Chunks) + .get_chunk(&chunk_id) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&chunk, "GetChunk")?; + Ok(chunk) + } + + /// One chunk plus its metadata, size-checked. + async fn chunk_detail(&self, chunk_id: String) -> BusResult> { + let detail = require_family!(self, as_chunks, Capability::Chunks) + .chunk_detail(&chunk_id) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&detail, "ChunkDetail")?; + Ok(detail) + } + + async fn storage_kinds(&self) -> BusResult> { + require_family!(self, as_chunks, Capability::Chunks) + .storage_kinds() + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Embedding vectors are the largest thing this interface returns. + /// + /// A 1536-dimension vector encodes to roughly 10 KiB of JSON, so a few + /// hundred chunks reach the frame ceiling on their own. Checked for the same + /// reason `List` is, and refused by name rather than truncated — a short + /// batch is indistinguishable from "those chunks have no vector". + async fn chunk_embeddings( + &self, + chunk_ids: Vec, + model_signature: String, + ) -> BusResult> { + let embeddings = require_family!(self, as_chunks, Capability::Chunks) + .chunk_embeddings(&chunk_ids, &model_signature) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&embeddings, "ChunkEmbeddings")?; + Ok(embeddings) + } + + // ── Retrieval ─────────────────────────────────────────────────────────── + + async fn fast_retrieve( + &self, + query: String, + options: FastRetrieveQuery, + scope: Option, + ) -> BusResult { + let response = require_family!(self, as_retrieval, Capability::Retrieval) + .fast_retrieve(&query, options, scope.as_ref()) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&response, "FastRetrieve")?; + Ok(response) + } + + async fn cover_window( + &self, + window: CoverWindowQuery, + scope: Option, + ) -> BusResult { + let response = require_family!(self, as_retrieval, Capability::Retrieval) + .cover_window(&window, scope.as_ref()) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&response, "CoverWindow")?; + Ok(response) + } + + // ── Profile ───────────────────────────────────────────────────────────── + + async fn list_active_facets(&self) -> BusResult> { + let facets = require_family!(self, as_profile, Capability::Profile) + .list_active_facets() + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&facets, "ListActiveFacets")?; + Ok(facets) + } + + async fn list_all_facets(&self) -> BusResult> { + let facets = require_family!(self, as_profile, Capability::Profile) + .list_all_facets() + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&facets, "ListAllFacets")?; + Ok(facets) + } + + async fn get_facet(&self, key: String) -> BusResult> { + require_family!(self, as_profile, Capability::Profile) + .get_facet(&key) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn facets_by_type(&self, facet_type: FacetType) -> BusResult> { + let facets = require_family!(self, as_profile, Capability::Profile) + .facets_by_type(facet_type) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&facets, "FacetsByType")?; + Ok(facets) + } + + // ── Episodic ──────────────────────────────────────────────────────────── + + /// Record one turn, answering with the row id the engine assigned it. + async fn insert_turn(&self, turn: EpisodicTurn) -> BusResult { + require_family!(self, as_episodic, Capability::Episodic) + .insert_turn(&turn) + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Every recorded turn for one session, oldest first. + async fn session_turns(&self, session_id: String) -> BusResult> { + let turns = require_family!(self, as_episodic, Capability::Episodic) + .session_turns(&session_id) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&turns, "SessionTurns")?; + Ok(turns) + } + + /// The open segment for a session, if there is one. + async fn open_segment(&self, session_id: String) -> BusResult> { + require_family!(self, as_episodic, Capability::Episodic) + .open_segment(&session_id) + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Start a new segment. + #[allow( + clippy::too_many_arguments, + reason = "mirrors `MemoryEpisodic::create_segment`; the service layer must \ + not reshape a contract signature" + )] + async fn create_segment( + &self, + segment_id: String, + session_id: String, + namespace: String, + start_episodic_id: i64, + start_timestamp: f64, + now: f64, + ) -> BusResult<()> { + require_family!(self, as_episodic, Capability::Episodic) + .create_segment( + &segment_id, + &session_id, + &namespace, + start_episodic_id, + start_timestamp, + now, + ) + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Extend a segment to include one more turn. + async fn append_turn( + &self, + segment_id: String, + episodic_id: i64, + timestamp: f64, + now: f64, + ) -> BusResult<()> { + require_family!(self, as_episodic, Capability::Episodic) + .append_turn(&segment_id, episodic_id, timestamp, now) + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Mark a segment closed. + async fn close_segment(&self, segment_id: String, now: f64) -> BusResult<()> { + require_family!(self, as_episodic, Capability::Episodic) + .close_segment(&segment_id, now) + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Attach a summary to a closed segment. + async fn set_segment_summary( + &self, + segment_id: String, + summary: String, + now: f64, + ) -> BusResult<()> { + require_family!(self, as_episodic, Capability::Episodic) + .set_segment_summary(&segment_id, &summary, now) + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Store a segment's embedding under `model_signature`. + async fn upsert_segment_embedding( + &self, + segment_id: String, + model_signature: String, + embedding: Vec, + created_at: f64, + ) -> BusResult<()> { + require_family!(self, as_episodic, Capability::Episodic) + .upsert_segment_embedding(&segment_id, &model_signature, &embedding, created_at) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn upsert_facet(&self, facet: ProfileFacet) -> BusResult<()> { + require_family!(self, as_profile, Capability::Profile) + .upsert_facet(&facet) + .await + .map_err(|error| into_bus_error(&error)) + } + + #[allow( + clippy::too_many_arguments, + reason = "mirrors `MemoryProfile::upsert_provider_facet`; the service layer \ + must not reshape a contract signature" + )] + async fn upsert_provider_facet( + &self, + facet_id: String, + facet_type: FacetType, + key: String, + value: String, + confidence: f64, + segment_id: Option, + observed_at: f64, + ) -> BusResult<()> { + require_family!(self, as_profile, Capability::Profile) + .upsert_provider_facet( + &facet_id, + facet_type, + &key, + &value, + confidence, + segment_id.as_deref(), + observed_at, + ) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn set_facet_user_state(&self, key: String, user_state: UserState) -> BusResult { + require_family!(self, as_profile, Capability::Profile) + .set_facet_user_state(&key, user_state) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn delete_facet(&self, key: String) -> BusResult { + require_family!(self, as_profile, Capability::Profile) + .delete_facet(&key) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn delete_facet_by_id(&self, facet_id: String) -> BusResult { + require_family!(self, as_profile, Capability::Profile) + .delete_facet_by_id(&facet_id) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn drop_facets_below(&self, threshold: f64) -> BusResult { + require_family!(self, as_profile, Capability::Profile) + .drop_facets_below(threshold) + .await + .map_err(|error| into_bus_error(&error)) + } + + /// Returns `bool`, not `BusResult` on the trait — but the wire needs a + /// result, so an absent family answers `false` rather than erroring, which + /// is the trait's documented reading of "cannot tell" for this predicate. + async fn workflow_identity_matches( + &self, + key_pattern: String, + canonical_value: String, + ) -> BusResult { + let Some(profile) = self.provider.as_profile() else { + return Ok(false); + }; + Ok(profile + .workflow_identity_matches(&key_pattern, &canonical_value) + .await) + } + + async fn retrieve_source( + &self, + query: SourceRetrievalQuery, + scope: Option, + ) -> BusResult { + let response = require_family!(self, as_retrieval, Capability::Retrieval) + .retrieve_source(&query, scope.as_ref()) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&response, "RetrieveSource")?; + Ok(response) + } + + async fn retrieve_children( + &self, + node_id: String, + max_depth: u32, + query: Option, + limit: Option, + scope: Option, + ) -> BusResult> { + let hits = require_family!(self, as_retrieval, Capability::Retrieval) + .retrieve_children(&node_id, max_depth, query.as_deref(), limit, scope.as_ref()) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&hits, "RetrieveChildren")?; + Ok(hits) + } + + async fn retrieve_leaves( + &self, + chunk_ids: Vec, + scope: Option, + ) -> BusResult> { + let hits = require_family!(self, as_retrieval, Capability::Retrieval) + .retrieve_leaves(&chunk_ids, scope.as_ref()) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&hits, "RetrieveLeaves")?; + Ok(hits) + } + + async fn recall_namespace_scored( + &self, + namespace: String, + query: String, + limit: usize, + exclude_session_id: Option, + ) -> BusResult> { + let hits = require_family!(self, as_retrieval, Capability::Retrieval) + .recall_namespace_scored(&namespace, &query, limit, exclude_session_id.as_deref()) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&hits, "RecallNamespaceScored")?; + Ok(hits) + } + + async fn search_entities( + &self, + query: String, + kinds: Option>, + limit: usize, + ) -> BusResult> { + let matches = require_family!(self, as_retrieval, Capability::Retrieval) + .search_entities(&query, kinds.as_deref(), limit) + .await + .map_err(|error| into_bus_error(&error))?; + ensure_response_fits(&matches, "SearchEntities")?; + Ok(matches) + } } /// The response-size ceiling for a method that returns a list of entries. @@ -699,9 +1394,14 @@ fn into_bus_error(error: &MemoryError) -> BusError { pub(crate) async fn serve( connection: &Connection, provider: Arc, + config: crate::config::ModuleConfig, ) -> BusResult<()> { + let opener = Arc::new(StoreOpener::new(connection.clone(), config)); connection - .serve_at(OBJECT_PATH.try_into()?, MemoryService::new(provider)) + .serve_at( + OBJECT_PATH.try_into()?, + MemoryService::root(provider, opener), + ) .await?; connection.request_name(BUS_NAME).await?; Ok(()) diff --git a/crates/tinymemory-module/src/service/test.rs b/crates/tinymemory-module/src/service/test.rs index 71d14aa..7cd7b3f 100644 --- a/crates/tinymemory-module/src/service/test.rs +++ b/crates/tinymemory-module/src/service/test.rs @@ -223,3 +223,68 @@ fn the_per_entry_overhead_is_counted_so_many_tiny_entries_still_trip_it() { "entries with no content must still be counted" ); } + +/// Every method the service implements must also be declared in the manifest. +/// +/// The manifest's `methods` list is admission surface: the host may only call a +/// member the artifact declared, so an implemented-but-undeclared method is +/// simply unreachable — no error, no warning, just a family that is silently +/// missing from the bus. +/// +/// This is not hypothetical. Thirty-one methods sat in exactly that state: the +/// whole of People, Chunks, Retrieval and Profile, plus `RecallDocuments`, +/// which predates them. The E2E `the_manifest_declares_every_method_the_module +/// _serves` did not catch it, and could not — it compares the manifest against +/// a hand-written list, so a method missing from *both* is invisible to it, and +/// it is `#[ignore]`d besides because it needs a real dlopen'ed artifact. +/// +/// Comparing against the implementation removes the hand-written list from the +/// loop entirely: `members()` is generated by `#[interface]` from the `impl` +/// block itself, so it cannot drift from what is really served. The manifest is +/// read out of `lib.rs` because the macro consumes those literals and offers no +/// constant to inspect. +#[test] +fn every_served_method_is_declared_in_the_manifest() { + let source = include_str!("../lib.rs"); + let list = source + .split_once("methods = [") + .expect("the module_export! block declares methods") + .1 + .split_once(']') + .expect("the methods list is closed") + .0; + let declared: std::collections::BTreeSet<&str> = list + .lines() + .filter_map(|line| { + let line = line.trim(); + // Skip the group comments; only quoted names count. + line.strip_prefix('"')? + .split_once('"') + .map(|(name, _)| name) + }) + .collect(); + + let service = super::MemoryService::new(std::sync::Arc::new( + tinymemory_api::null::NullMemoryProvider, + )); + let served: std::collections::BTreeSet = tinybus::service::Interface::members(&service) + .iter() + .map(|member| member.as_str().to_string()) + .collect(); + let served: std::collections::BTreeSet<&str> = served.iter().map(String::as_str).collect(); + + let undeclared: Vec<_> = served.difference(&declared).collect(); + assert!( + undeclared.is_empty(), + "these methods are served but not declared in the manifest, so no host can call them: \ + {undeclared:?}" + ); + + // The converse is a different failure — a host admitted for a method that + // answers `unknown_method` — so it is worth pinning in the same place. + let unserved: Vec<_> = declared.difference(&served).collect(); + assert!( + unserved.is_empty(), + "these methods are declared in the manifest but not served: {unserved:?}" + ); +} diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index 3115081..79c1344 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -484,6 +484,15 @@ const EXPECTED_METHODS: &[&str] = &[ "Capabilities", "Health", "Shutdown", + "OpenStore", + "InsertTurn", + "SessionTurns", + "OpenSegment", + "CreateSegment", + "AppendTurn", + "CloseSegment", + "SetSegmentSummary", + "UpsertSegmentEmbedding", "Store", "Get", "Forget", @@ -492,6 +501,40 @@ const EXPECTED_METHODS: &[&str] = &[ "Recall", "ExportPage", "ImportRecords", + // People. + "ListPeople", + "GetPerson", + "ResolveHandle", + "AddHandleAlias", + "ScorePerson", + "RecordInteraction", + "SeedFromAddressBook", + // Chunks. + "ListChunks", + "GetChunk", + "ChunkDetail", + "StorageKinds", + "ChunkEmbeddings", + // Retrieval. + "FastRetrieve", + "CoverWindow", + "RetrieveSource", + "RetrieveChildren", + "RetrieveLeaves", + "RecallNamespaceScored", + "SearchEntities", + // Profile. + "ListActiveFacets", + "ListAllFacets", + "GetFacet", + "FacetsByType", + "UpsertFacet", + "UpsertProviderFacet", + "SetFacetUserState", + "DeleteFacet", + "DeleteFacetById", + "DropFacetsBelow", + "WorkflowIdentityMatches", "IngestDocument", "IngestChat", "PutDocument", @@ -501,6 +544,7 @@ const EXPECTED_METHODS: &[&str] = &[ "DeleteDocument", "ClearNamespace", "QueryDocuments", + "RecallDocuments", "Append", "QuerySource", "DrillDown", diff --git a/vendor/tinycortex b/vendor/tinycortex index 0a7a067..5fdeac9 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit 0a7a06710fce8dba1cdb06b3e4640c351bba800c +Subproject commit 5fdeac984c09d2dac65b61e92fd27e2c92ce1e6b