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
//! Memory content (Markdown / plain text) plus content-hash plumbing.

use serde::{Deserialize, Serialize};

/// MIME-ish kind tag for a memory's body.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ContentKind {
    /// `text/markdown`.
    Markdown,
    /// `text/plain`.
    Text,
}

impl ContentKind {
    /// File extension (without the dot).
    #[must_use]
    pub const fn extension(self) -> &'static str {
        match self {
            ContentKind::Markdown => "md",
            ContentKind::Text => "txt",
        }
    }
}

/// A memory's body. Owned-string variants — content is small enough that we
/// don't need byte-level handles in the foundation layer.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Content {
    /// Markdown body.
    Markdown(String),
    /// Plain text body.
    Text(String),
}

impl Content {
    /// Build a markdown content body.
    #[must_use]
    pub fn markdown(s: impl Into<String>) -> Self {
        Content::Markdown(s.into())
    }

    /// Build a plain-text content body.
    #[must_use]
    pub fn text(s: impl Into<String>) -> Self {
        Content::Text(s.into())
    }

    /// Kind tag.
    #[must_use]
    pub const fn kind(&self) -> ContentKind {
        match self {
            Content::Markdown(_) => ContentKind::Markdown,
            Content::Text(_) => ContentKind::Text,
        }
    }

    /// Borrow body as `&str`.
    #[must_use]
    pub fn as_str(&self) -> &str {
        match self {
            Content::Markdown(s) | Content::Text(s) => s.as_str(),
        }
    }

    /// Body length in bytes (UTF-8).
    #[must_use]
    pub fn byte_len(&self) -> usize {
        self.as_str().len()
    }
}

/// 32-byte BLAKE3 content hash.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContentHash(pub [u8; 32]);

impl ContentHash {
    /// Compute over a byte slice.
    #[must_use]
    pub fn of_bytes(bytes: &[u8]) -> Self {
        ContentHash(*blake3::hash(bytes).as_bytes())
    }

    /// Compute over a `Content` body.
    #[must_use]
    pub fn of_content(c: &Content) -> Self {
        Self::of_bytes(c.as_str().as_bytes())
    }

    /// Borrow as raw bytes.
    #[must_use]
    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    /// Render as lowercase hex (used in storage key construction).
    #[must_use]
    pub fn to_hex(&self) -> String {
        let mut out = String::with_capacity(64);
        for b in self.0 {
            out.push_str(&format!("{b:02x}"));
        }
        out
    }
}

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

    #[test]
    fn deterministic_hash() {
        let a = ContentHash::of_bytes(b"hello world");
        let b = ContentHash::of_bytes(b"hello world");
        assert_eq!(a, b);
    }

    #[test]
    fn different_inputs_hash_differently() {
        let a = ContentHash::of_bytes(b"a");
        let b = ContentHash::of_bytes(b"b");
        assert_ne!(a, b);
    }

    #[test]
    fn hex_is_64_chars() {
        let h = ContentHash::of_bytes(b"x");
        assert_eq!(h.to_hex().len(), 64);
    }

    #[test]
    fn extension_matches_kind() {
        assert_eq!(Content::markdown("a").kind().extension(), "md");
        assert_eq!(Content::text("a").kind().extension(), "txt");
    }
}