Skip to main content

kiromi_ai_memory/
memory.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2//! Memory identity, references, and read-side records.
3
4use std::fmt;
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8use ulid::Ulid;
9
10use crate::content::{Content, ContentHash};
11use crate::partition::PartitionPath;
12
13/// Stable identifier for a memory — a 128-bit ULID.
14#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15#[serde(into = "String", try_from = "String")]
16pub struct MemoryId(Ulid);
17
18impl MemoryId {
19    /// Generate a fresh ULID at the current wall-clock time.
20    ///
21    /// When the env var `KIROMI_AI_TEST_DETERMINISTIC_ULID` is set, returns
22    /// successive ULIDs derived from a process-local counter so insta snapshots
23    /// stay stable. Documented for testing only.
24    #[must_use]
25    pub fn generate() -> Self {
26        if std::env::var_os("KIROMI_AI_TEST_DETERMINISTIC_ULID").is_some() {
27            use std::sync::atomic::{AtomicU64, Ordering};
28            static COUNTER: AtomicU64 = AtomicU64::new(1);
29            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
30            // ULID = 48-bit timestamp + 80-bit randomness.
31            // `Ulid::from_parts(timestamp_ms, random)` — fudge both with the counter.
32            return MemoryId(Ulid::from_parts(n, u128::from(n)));
33        }
34        MemoryId(Ulid::new())
35    }
36
37    /// Construct from a raw `Ulid`.
38    #[must_use]
39    pub const fn from_ulid(u: Ulid) -> Self {
40        MemoryId(u)
41    }
42
43    /// Underlying `Ulid`.
44    #[must_use]
45    pub const fn as_ulid(&self) -> Ulid {
46        self.0
47    }
48}
49
50impl fmt::Display for MemoryId {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        self.0.fmt(f)
53    }
54}
55
56impl FromStr for MemoryId {
57    type Err = ulid::DecodeError;
58    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
59        s.parse::<Ulid>().map(MemoryId)
60    }
61}
62
63impl From<MemoryId> for String {
64    fn from(id: MemoryId) -> String {
65        id.0.to_string()
66    }
67}
68
69impl TryFrom<String> for MemoryId {
70    type Error = ulid::DecodeError;
71    fn try_from(s: String) -> std::result::Result<Self, Self::Error> {
72        s.parse()
73    }
74}
75
76/// Plan 15: typed memory discriminator.
77///
78/// - `Episodic`: "what happened" — transcripts, events.
79/// - `Semantic`: "facts" — distilled knowledge.
80/// - `Procedural`: "how to do X" — runbooks, playbooks.
81/// - `Archival`: long-term, low-access.
82/// - `Working`: scratch / agent state.
83#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
84#[non_exhaustive]
85pub enum MemoryKind {
86    /// "What happened" — transcripts, events.
87    #[default]
88    Episodic,
89    /// "Facts" — distilled knowledge.
90    Semantic,
91    /// "How to do X" — runbooks, playbooks.
92    Procedural,
93    /// Long-term, low-access.
94    Archival,
95    /// Scratch / agent state.
96    Working,
97}
98
99impl MemoryKind {
100    /// String tag persisted in `memory.kind`.
101    #[must_use]
102    pub const fn as_persisted_str(self) -> &'static str {
103        match self {
104            MemoryKind::Episodic => "episodic",
105            MemoryKind::Semantic => "semantic",
106            MemoryKind::Procedural => "procedural",
107            MemoryKind::Archival => "archival",
108            MemoryKind::Working => "working",
109        }
110    }
111
112    /// Parse from the persisted tag. Unknown / NULL rows yield `None`.
113    #[must_use]
114    pub fn from_persisted(s: &str) -> Option<Self> {
115        match s {
116            "episodic" => Some(MemoryKind::Episodic),
117            "semantic" => Some(MemoryKind::Semantic),
118            "procedural" => Some(MemoryKind::Procedural),
119            "archival" => Some(MemoryKind::Archival),
120            "working" => Some(MemoryKind::Working),
121            _ => None,
122        }
123    }
124}
125
126/// A handle returned by `append` / `get` / `list`. Carries enough context to look
127/// the row up without another partition-resolution round trip.
128#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
129pub struct MemoryRef {
130    /// Memory id.
131    pub id: MemoryId,
132    /// Partition the memory lives in.
133    pub partition: PartitionPath,
134}
135
136/// Full read-side memory record returned by `Memory::get`.
137#[derive(Debug, Clone, PartialEq, Eq)]
138#[non_exhaustive]
139pub struct MemoryRecord {
140    /// The reference.
141    pub r#ref: MemoryRef,
142    /// Materialised content.
143    pub content: Content,
144    /// Content hash captured at append.
145    pub hash: ContentHash,
146    /// Created-at unix millis.
147    pub created_at_ms: i64,
148    /// Updated-at unix millis (== created_at on first write).
149    pub updated_at_ms: i64,
150    /// Soft-tombstone flag.
151    pub tombstoned: bool,
152    /// Plan 15: when the FACT became operationally true. `None` = no
153    /// lower bound.
154    pub valid_from_ms: Option<i64>,
155    /// Plan 15: when the FACT stopped being true. `None` = still valid
156    /// at read time.
157    pub valid_until_ms: Option<i64>,
158    /// Plan 15: typed memory discriminator. `None` = unspecified
159    /// (legacy rows on disk before Plan 15).
160    pub kind: Option<MemoryKind>,
161}
162
163/// Flat, JSON-friendly projection of [`MemoryRecord`]. The CLI emits this
164/// shape under `--json` so the [`Content`] enum's serde representation never
165/// bleeds into the wire snapshot surface.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
167#[non_exhaustive]
168pub struct MemoryRecordWire<'a> {
169    /// Memory id (stringified ULID).
170    pub id: String,
171    /// Partition path.
172    pub partition: &'a str,
173    /// Content kind extension (`md` or `txt`).
174    pub kind: &'static str,
175    /// Body as UTF-8.
176    pub body: &'a str,
177    /// Body length in bytes.
178    pub bytes: usize,
179    /// Created-at unix millis.
180    pub created_at_ms: i64,
181    /// Updated-at unix millis.
182    pub updated_at_ms: i64,
183    /// Soft-tombstone flag.
184    pub tombstoned: bool,
185}
186
187impl MemoryRecord {
188    /// Project a [`MemoryRecord`] into the flat wire shape used by the CLI's
189    /// `--json` output and any other API surface that wants a stable, snapshot-
190    /// pinnable representation. Borrows the record — no allocation beyond the
191    /// id string.
192    #[must_use]
193    pub fn wire(&self) -> MemoryRecordWire<'_> {
194        MemoryRecordWire {
195            id: self.r#ref.id.to_string(),
196            partition: self.r#ref.partition.as_str(),
197            kind: self.content.kind().extension(),
198            body: self.content.as_str(),
199            bytes: self.content.byte_len(),
200            created_at_ms: self.created_at_ms,
201            updated_at_ms: self.updated_at_ms,
202            tombstoned: self.tombstoned,
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn id_roundtrips_through_string() {
213        let id = MemoryId::generate();
214        let s = id.to_string();
215        let back: MemoryId = s.parse().unwrap();
216        assert_eq!(id, back);
217    }
218
219    #[test]
220    fn id_roundtrips_through_serde() {
221        let id = MemoryId::generate();
222        let j = serde_json::to_string(&id).unwrap();
223        let back: MemoryId = serde_json::from_str(&j).unwrap();
224        assert_eq!(id, back);
225    }
226}