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
//! Summary identity, references, and read-side records.
//!
//! Plan 9 promoted summaries from the `inline_summary` column on `memory`
//! into a first-class entity. A summary attaches to one of three subjects
//! (`Memory`, `Partition`, `Tenant`), carries a [`SummaryStyle`], a version,
//! and the storage key for the markdown body. The engine never invokes a
//! summarizer itself — `attach_summary` is fully caller-driven.
//!
//! See spec § 12.16.

pub mod content;
pub mod migrate_inputs;

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};
use ulid::Ulid;

use crate::memory::{MemoryId, MemoryRef};
use crate::partition::PartitionPath;
use crate::summarizer::SummaryStyle;

/// Stable identifier for a summary — a 128-bit ULID.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct SummaryId(Ulid);

impl SummaryId {
    /// Generate a fresh ULID at the current wall-clock time.
    ///
    /// When the env var `KIROMI_AI_TEST_DETERMINISTIC_ULID` is set, returns
    /// successive ULIDs derived from a process-local counter so insta
    /// snapshots stay stable. Mirrors [`crate::MemoryId::generate`].
    #[must_use]
    pub fn generate() -> Self {
        if std::env::var_os("KIROMI_AI_TEST_DETERMINISTIC_ULID").is_some() {
            use std::sync::atomic::{AtomicU64, Ordering};
            static COUNTER: AtomicU64 = AtomicU64::new(1_000_000);
            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
            return SummaryId(Ulid::from_parts(n, u128::from(n)));
        }
        SummaryId(Ulid::new())
    }

    /// Construct from a raw `Ulid`.
    #[must_use]
    pub const fn from_ulid(u: Ulid) -> Self {
        SummaryId(u)
    }

    /// Underlying `Ulid`.
    #[must_use]
    pub const fn as_ulid(&self) -> Ulid {
        self.0
    }
}

impl fmt::Display for SummaryId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

impl FromStr for SummaryId {
    type Err = ulid::DecodeError;
    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        s.parse::<Ulid>().map(SummaryId)
    }
}

impl From<SummaryId> for String {
    fn from(id: SummaryId) -> String {
        id.0.to_string()
    }
}

impl TryFrom<String> for SummaryId {
    type Error = ulid::DecodeError;
    fn try_from(s: String) -> std::result::Result<Self, Self::Error> {
        s.parse()
    }
}

/// What a summary attaches to.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum SummarySubject {
    /// Per-memory summary (the closest cousin of the legacy inline_summary).
    Memory(MemoryRef),
    /// Per-partition rollup at any depth.
    Partition(PartitionPath),
    /// Whole-tenant memo.
    Tenant,
}

impl SummarySubject {
    /// Storage tag for the `summary.subject_kind` column.
    #[must_use]
    pub fn kind_str(&self) -> &'static str {
        match self {
            SummarySubject::Memory(_) => "memory",
            SummarySubject::Partition(_) => "partition",
            SummarySubject::Tenant => "tenant",
        }
    }

    /// Partition path that anchors the storage layout for the subject:
    /// the memory's parent partition, the partition itself, or `None` for
    /// the tenant memo (which lives under `metadata/tenant/...`).
    #[must_use]
    pub fn partition_path(&self) -> Option<&PartitionPath> {
        match self {
            SummarySubject::Memory(r) => Some(&r.partition),
            SummarySubject::Partition(p) => Some(p),
            SummarySubject::Tenant => None,
        }
    }

    /// Memory id for the subject, if any.
    #[must_use]
    pub fn memory_id(&self) -> Option<MemoryId> {
        match self {
            SummarySubject::Memory(r) => Some(r.id),
            _ => None,
        }
    }
}

/// Lightweight handle returned by `attach_summary` / `summaries_of`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SummaryRef {
    /// Summary id.
    pub id: SummaryId,
    /// Subject the summary attaches to.
    pub subject: SummarySubject,
    /// Style preset.
    pub style: SummaryStyle,
    /// 1-indexed version (latest wins; older versions are superseded).
    pub version: u32,
}

/// Full read-side summary record returned by `Memory::get_summary`.
///
/// Plan 11 promoted `content` from a plain `String` to a structured
/// [`crate::summary::content::SummaryContent`] (prose + typed blocks).
/// `SummaryContent: From<String>` so existing call sites that built a
/// record from a string still compile.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SummaryRecord {
    /// Reference.
    pub r#ref: SummaryRef,
    /// Structured body. The Markdown blob in storage carries `prose`;
    /// the JSON sidecar carries the full `SummaryContent`.
    pub content: crate::summary::content::SummaryContent,
    /// Caller-supplied summarizer id (e.g. `"openai:gpt-4.1:v1"`).
    pub summarizer_id: String,
    /// Inputs the summarizer consumed (other subjects). JSON-roundtripped.
    pub inputs: Vec<SummarySubject>,
    /// If non-None, this summary has been superseded by `superseded_by`.
    pub superseded_by: Option<SummaryId>,
    /// Created-at unix millis.
    pub created_at_ms: i64,
}

/// What `mark_partition_stale` flips on.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleKind {
    /// `partition.summary_stale = 1`.
    Summary,
    /// `partition.child_index_stale = 1`. Reserved for Plan 10.
    ChildIndex,
    /// Both.
    Both,
}

/// Filter for [`crate::Memory::subjects_needing_summary`].
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum Scope {
    /// Whole tenant.
    All,
    /// Tenant memo only.
    Tenant,
    /// Restrict to a partition (and its descendants).
    Partition(PartitionPath),
    /// Restrict to a single memory.
    Memory(MemoryRef),
}

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

    #[test]
    fn id_roundtrips_through_string() {
        let id = SummaryId::generate();
        let s = id.to_string();
        let back: SummaryId = s.parse().unwrap();
        assert_eq!(id, back);
    }

    #[test]
    fn subject_kind_strings_are_stable() {
        let m = SummarySubject::Memory(MemoryRef {
            id: MemoryId::generate(),
            partition: "user=alex".parse().unwrap(),
        });
        let p = SummarySubject::Partition("user=alex".parse().unwrap());
        let t = SummarySubject::Tenant;
        assert_eq!(m.kind_str(), "memory");
        assert_eq!(p.kind_str(), "partition");
        assert_eq!(t.kind_str(), "tenant");
    }

    #[test]
    fn subject_round_trips_through_json() {
        let p = SummarySubject::Partition("user=alex/year=2026".parse().unwrap());
        let j = serde_json::to_string(&p).unwrap();
        let back: SummarySubject = serde_json::from_str(&j).unwrap();
        assert_eq!(p, back);
    }

    #[test]
    fn subject_partition_path_helpers() {
        let mref = MemoryRef {
            id: MemoryId::generate(),
            partition: "user=alex".parse().unwrap(),
        };
        let m = SummarySubject::Memory(mref.clone());
        assert_eq!(
            m.partition_path().map(|p| p.as_str().to_string()),
            Some("user=alex".to_string())
        );
        assert_eq!(m.memory_id(), Some(mref.id));

        let p = SummarySubject::Partition("a=b".parse().unwrap());
        assert!(p.partition_path().is_some());
        assert!(p.memory_id().is_none());

        let t = SummarySubject::Tenant;
        assert!(t.partition_path().is_none());
        assert!(t.memory_id().is_none());
    }

    #[test]
    fn summary_id_serde_round_trip() {
        let id = SummaryId::generate();
        let j = serde_json::to_string(&id).unwrap();
        let back: SummaryId = serde_json::from_str(&j).unwrap();
        assert_eq!(id, back);
    }

    #[test]
    fn summary_id_from_ulid_and_as_ulid_round_trip() {
        let raw = ulid::Ulid::new();
        let id = SummaryId::from_ulid(raw);
        assert_eq!(id.as_ulid(), raw);
    }
}