kiromi-ai-memory 0.2.2

Local-first multi-tenant memory store engine: Markdown/text content on object storage, metadata in SQLite, plugin-shaped embedder/storage/metadata, hybrid text+vector search.
Documentation
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Self-declared plugin capabilities (OpenDAL pattern).
//!
//! Each plugin trait carries a `capabilities()` returning a small struct of
//! booleans. `Builder::open` validates the engine's required-set against the
//! reported capabilities and rejects mismatches with `Error::CapabilityMissing`.
//!
//! Each `*Capabilities` struct is `#[non_exhaustive]`; out-of-crate consumers
//! construct via `Default::default()` plus targeted field updates (struct
//! literals and FRU spreads are blocked across crate boundaries by the attr).
//! Adding a field is non-breaking (Default = the conservative value);
//! removing or renaming is breaking.
//!
//! See spec § 12.11 and the survey doc at
//! `docs/superpowers/research/2026-05-02-extensibility-survey.md` § "Adopt #3".

#![allow(clippy::struct_excessive_bools)] // Capabilities are bool-shaped by design.

/// Capabilities a `Storage` backend declares.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StorageCapabilities {
    /// Supports `put`.
    pub put: bool,
    /// Supports `get`.
    pub get: bool,
    /// Supports `delete`.
    pub delete: bool,
    /// Supports `list_prefix`.
    pub list_prefix: bool,
    /// `put` overwrites atomically (last-writer-wins, no torn reads on a
    /// concurrent `get`).
    pub atomic_overwrite: bool,
    /// Supports byte-range reads. (Slice 1 storage is whole-blob; range reads
    /// arrive when an HTTP/object-store backend lands.)
    pub range_read: bool,
}

impl Default for StorageCapabilities {
    /// The conservative defaults. Slice-1 backends (LocalFs, InMemory) all
    /// satisfy these, so the engine's required-set matches the default.
    fn default() -> Self {
        Self {
            put: true,
            get: true,
            delete: true,
            list_prefix: true,
            atomic_overwrite: true,
            range_read: false,
        }
    }
}

/// Capabilities a `MetadataStore` backend declares.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MetadataCapabilities {
    /// Supports the `migrate()` workflow.
    pub migrate: bool,
    /// Supports the `audit_since` cursor read used by recovery.
    pub audit_since: bool,
    /// Supports the link table (slice 1: always true).
    pub links: bool,
}

impl Default for MetadataCapabilities {
    fn default() -> Self {
        Self {
            migrate: true,
            audit_since: true,
            links: true,
        }
    }
}

/// Capabilities an `Embedder` declares.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EmbedderCapabilities {
    /// Supports batched calls (`texts.len() > 1`).
    pub batched: bool,
    /// Honours `EmbedRole` — `Document` and `Query` may produce different
    /// vectors for the same text. Symmetric embedders set this `false`.
    pub asymmetric_query_passage: bool,
    /// Recommended ceiling on input token count, per call. `None` = unknown.
    pub max_input_tokens: Option<usize>,
}

impl Default for EmbedderCapabilities {
    /// Default: symmetric, batched, no declared token cap. `MockEmbedder` and
    /// any embedder that doesn't override `capabilities` lands here.
    fn default() -> Self {
        Self {
            batched: true,
            asymmetric_query_passage: false,
            max_input_tokens: None,
        }
    }
}

/// Engine's required-set against a `Storage` backend at open-time.
pub(crate) const REQUIRED_STORAGE: StorageCapabilities = StorageCapabilities {
    put: true,
    get: true,
    delete: true,
    list_prefix: true,
    atomic_overwrite: true,
    range_read: false,
};

/// Engine's required-set against a `MetadataStore` at open-time.
pub(crate) const REQUIRED_METADATA: MetadataCapabilities = MetadataCapabilities {
    migrate: true,
    audit_since: true,
    links: true,
};

/// Engine's required-set against an `Embedder` at open-time. Slice 1 needs
/// neither asymmetry nor a specific token cap from the engine's side; the
/// only invariant is that `embed` is callable, which a value of `()` covers.
pub(crate) const REQUIRED_EMBEDDER: EmbedderCapabilities = EmbedderCapabilities {
    batched: true,
    asymmetric_query_passage: false,
    max_input_tokens: None,
};

/// Identifies which plugin failed the capability check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Plugin {
    /// `Storage` backend.
    Storage,
    /// `MetadataStore`.
    Metadata,
    /// `Embedder` (only checked when one is configured).
    Embedder,
}

impl Plugin {
    /// Stable string tag for error messages.
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Plugin::Storage => "Storage",
            Plugin::Metadata => "MetadataStore",
            Plugin::Embedder => "Embedder",
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_satisfy_engine_required_set() {
        // Every shipped slice-1 backend uses the default impl, so the engine's
        // required-set must be a subset of the default. If this test breaks,
        // either the engine's needs grew or a default is too lax.
        let s = StorageCapabilities::default();
        assert!(s.put && s.get && s.delete && s.list_prefix && s.atomic_overwrite);

        let m = MetadataCapabilities::default();
        assert!(m.migrate && m.audit_since && m.links);

        let e = EmbedderCapabilities::default();
        assert!(e.batched);
    }
}