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
//! Option / option-builder structs for the public API.

use std::collections::BTreeMap;

use crate::attribute::AttributeValue;
use crate::link::LinkKind;
use crate::memory::MemoryRef;

/// Options for `Memory::append`.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct AppendOpts {
    /// Explicit links to record alongside the new memory.
    pub links: Vec<MemoryRef>,
    /// Caller-provided embedding. When set, the engine skips its `Embedder` and uses
    /// this vector. Length must match `schema_meta.embedder_dims`.
    pub embedding: Option<Vec<f32>>,
    /// Plan 11: caller-supplied typed metadata. Multiple
    /// [`AppendOpts::with_attribute`] calls accumulate; the same key
    /// overwrites the prior value before the SQL transaction runs.
    pub attributes: BTreeMap<String, AttributeValue>,
    /// Plan 15: when the FACT became operationally true. `None` means
    /// "no lower validity bound".
    pub valid_from_ms: Option<i64>,
    /// Plan 15: when the FACT stopped being true. `None` means "still
    /// valid at read time".
    pub valid_until_ms: Option<i64>,
    /// Plan 15: typed memory discriminator. `None` defaults to
    /// [`crate::memory::MemoryKind::Episodic`] at append time (legacy
    /// rows on disk stay NULL).
    pub kind: Option<crate::memory::MemoryKind>,
}

impl AppendOpts {
    /// Builder helper.
    #[must_use]
    pub fn with_links<I: IntoIterator<Item = MemoryRef>>(mut self, links: I) -> Self {
        self.links = links.into_iter().collect();
        self
    }

    /// Caller hands the engine a pre-computed embedding. Used by Apple Foundation
    /// Models, OpenAI proxies, Swift FFI consumers, etc. — anywhere the model
    /// runs outside the library.
    #[must_use]
    pub fn with_embedding(mut self, vector: Vec<f32>) -> Self {
        self.embedding = Some(vector);
        self
    }

    /// Plan 11: attach one typed metadata attribute (e.g. transcript-style
    /// `speaker`, `ts_start_ms`, `meeting_id`). The attribute is persisted
    /// in the same SQL transaction as the new memory row; an `attribute_set`
    /// audit entry is recorded per attribute.
    ///
    /// Multiple calls accumulate; later calls overwrite the same key.
    #[must_use]
    pub fn with_attribute(
        mut self,
        key: impl Into<String>,
        value: impl Into<AttributeValue>,
    ) -> Self {
        self.attributes.insert(key.into(), value.into());
        self
    }

    /// Plan 15: record both validity bounds at once.
    #[must_use]
    pub fn with_validity(mut self, from_ms: i64, until_ms: Option<i64>) -> Self {
        self.valid_from_ms = Some(from_ms);
        self.valid_until_ms = until_ms;
        self
    }

    /// Plan 15: record the lower validity bound only.
    #[must_use]
    pub fn valid_from(mut self, from_ms: i64) -> Self {
        self.valid_from_ms = Some(from_ms);
        self
    }

    /// Plan 15: record the upper validity bound only.
    #[must_use]
    pub fn valid_until(mut self, until_ms: i64) -> Self {
        self.valid_until_ms = Some(until_ms);
        self
    }

    /// Plan 15: tag the memory with a typed kind (episodic / semantic /
    /// procedural / archival / working).
    #[must_use]
    pub fn with_kind(mut self, kind: crate::memory::MemoryKind) -> Self {
        self.kind = Some(kind);
        self
    }

    /// Plan 16: attach an `Array<String>` keyword list at the canonical
    /// [`crate::canonical_keys::KEYWORDS`] key. Convenience for callers
    /// that just want to tag-as-they-go.
    #[must_use]
    pub fn with_keywords<I, S>(mut self, keywords: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let arr = AttributeValue::Array(
            keywords
                .into_iter()
                .map(|s| AttributeValue::String(s.into()))
                .collect(),
        );
        self.attributes
            .insert(crate::canonical_keys::KEYWORDS.to_string(), arr);
        self
    }

    /// Plan 16: attach an `Array<String>` tag list at the canonical
    /// [`crate::canonical_keys::TAGS`] key.
    #[must_use]
    pub fn with_tags<I, S>(mut self, tags: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let arr = AttributeValue::Array(
            tags.into_iter()
                .map(|s| AttributeValue::String(s.into()))
                .collect(),
        );
        self.attributes
            .insert(crate::canonical_keys::TAGS.to_string(), arr);
        self
    }

    /// Plan 16: attach a one-liner headline at the canonical
    /// [`crate::canonical_keys::HEADLINE`] key.
    #[must_use]
    pub fn with_headline(mut self, headline: impl Into<String>) -> Self {
        self.attributes.insert(
            crate::canonical_keys::HEADLINE.to_string(),
            AttributeValue::String(headline.into()),
        );
        self
    }
}

/// Plan 16: options for attaching a summary that pre-collect typed
/// attributes the caller wants set in the same write window.
///
/// The engine only consumes the attributes after the summary row commits;
/// the writes happen in distinct SQL transactions (one per attribute) so
/// callers don't pay a transactional penalty per call. For atomic
/// multi-attribute writes, use [`crate::Memory::evolve`] after attach.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct AttachSummaryOpts {
    /// Caller-supplied attributes to set on the new summary row.
    pub attributes: BTreeMap<String, AttributeValue>,
}

impl AttachSummaryOpts {
    /// Insert one typed attribute. Multiple calls accumulate; the same
    /// key overwrites the prior value.
    #[must_use]
    pub fn with_attribute(
        mut self,
        key: impl Into<String>,
        value: impl Into<AttributeValue>,
    ) -> Self {
        self.attributes.insert(key.into(), value.into());
        self
    }

    /// Convenience: attach an `Array<String>` keyword list at
    /// [`crate::canonical_keys::KEYWORDS`].
    #[must_use]
    pub fn with_keywords<I, S>(mut self, keywords: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let arr = AttributeValue::Array(
            keywords
                .into_iter()
                .map(|s| AttributeValue::String(s.into()))
                .collect(),
        );
        self.attributes
            .insert(crate::canonical_keys::KEYWORDS.to_string(), arr);
        self
    }

    /// Convenience: attach an `Array<String>` tag list at
    /// [`crate::canonical_keys::TAGS`].
    #[must_use]
    pub fn with_tags<I, S>(mut self, tags: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let arr = AttributeValue::Array(
            tags.into_iter()
                .map(|s| AttributeValue::String(s.into()))
                .collect(),
        );
        self.attributes
            .insert(crate::canonical_keys::TAGS.to_string(), arr);
        self
    }

    /// Convenience: attach a one-liner headline at
    /// [`crate::canonical_keys::HEADLINE`].
    #[must_use]
    pub fn with_headline(mut self, headline: impl Into<String>) -> Self {
        self.attributes.insert(
            crate::canonical_keys::HEADLINE.to_string(),
            AttributeValue::String(headline.into()),
        );
        self
    }
}

/// Options for `Memory::list`.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ListOpts {
    /// Hard cap on returned items (paginated). Defaults to 100. `0` = unlimited.
    pub limit: u32,
    /// Pagination cursor — opaque, returned by previous `Page`.
    pub cursor: Option<String>,
    /// Include soft-tombstoned rows.
    pub include_tombstoned: bool,
    /// Plan 15: filter to rows whose validity range covers this instant.
    /// A row matches when `(valid_from_ms IS NULL OR valid_from_ms <= t)
    /// AND (valid_until_ms IS NULL OR valid_until_ms > t)`.
    pub valid_at_ms: Option<i64>,
}

impl Default for ListOpts {
    fn default() -> Self {
        ListOpts {
            limit: 100,
            cursor: None,
            include_tombstoned: false,
            valid_at_ms: None,
        }
    }
}

impl ListOpts {
    /// Plan 15: keep only rows whose validity covers the supplied instant.
    #[must_use]
    pub fn with_valid_at(mut self, ts_ms: i64) -> Self {
        self.valid_at_ms = Some(ts_ms);
        self
    }
}

/// Plan 15: knobs on [`crate::Memory::links_of_with_opts`].
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct LinksOpts {
    /// When `Some(k)`, only links whose `kind == k` are returned. `None`
    /// returns links of every kind.
    pub kind_filter: Option<LinkKind>,
}

impl LinksOpts {
    /// Constrain to one link kind.
    #[must_use]
    pub fn with_kind(mut self, kind: LinkKind) -> Self {
        self.kind_filter = Some(kind);
        self
    }
}

/// Options for `Memory::delete_partition`.
#[derive(Debug, Clone, Default)]
pub struct DeleteOpts {
    hard: bool,
}

impl DeleteOpts {
    /// Soft delete (default): tombstone-only.
    #[must_use]
    pub fn soft() -> Self {
        DeleteOpts { hard: false }
    }

    /// Hard delete: physical removal on next compaction.
    #[must_use]
    pub fn hard() -> Self {
        DeleteOpts { hard: true }
    }

    /// Whether the caller asked for hard delete.
    #[must_use]
    pub fn is_hard(&self) -> bool {
        self.hard
    }
}

/// One page of results.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Page<T> {
    /// Items in this page.
    pub items: Vec<T>,
    /// Cursor for the next page; `None` means no more pages.
    pub next_cursor: Option<String>,
}

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

    #[test]
    fn list_opts_defaults() {
        let l = ListOpts::default();
        assert_eq!(l.limit, 100);
        assert!(l.cursor.is_none());
        assert!(!l.include_tombstoned);
    }

    #[test]
    fn delete_opts_soft_default() {
        assert!(!DeleteOpts::soft().is_hard());
        assert!(DeleteOpts::hard().is_hard());
    }

    #[test]
    fn with_embedding_sets_field() {
        let o = AppendOpts::default().with_embedding(vec![0.0; 384]);
        assert_eq!(o.embedding.as_ref().map(Vec::len), Some(384));
    }
}