gemini_memory_rs/okf/
record.rs

1//! Mapping between [`CanonicalMemory`] and its on-disk OKF representation.
2//!
3//! This is the only place that knows the file format. Everything else in the
4//! engine works with the domain type, so changing the serialization is a local
5//! edit rather than a sweep.
6
7use chrono::{DateTime, Utc};
8
9use super::document::OkfDocument;
10use super::yaml::{self, Yaml};
11use crate::core::{
12    CanonicalMemory, CanonicalPredicate, EntityRef, EvidenceCounters, MemoryError, MemoryId,
13    MemoryKind, MemorySource, MemoryStatus, MemoryValue, PrivacyMetadata, RetrievalMetadata,
14    SensitivityClass, SessionId, TemporalMetadata, TemporalScope, TurnId, UserId, ids::EntityId,
15};
16
17/// The format version written into every record.
18pub const OKF_VERSION: &str = "memory/v1";
19
20/// Section headings, in emission order.
21const SECTION_FACT: &str = "Fact";
22const SECTION_EVIDENCE: &str = "Evidence Summary";
23const SECTION_SUPERSEDES: &str = "Supersedes";
24
25/// Render a canonical memory as an OKF document.
26pub fn to_document(memory: &CanonicalMemory) -> OkfDocument {
27    let front = yaml::map(vec![
28        ("okf", Some(OKF_VERSION.into())),
29        ("id", Some(memory.id.as_str().into())),
30        ("owner", Some(memory.owner.as_str().into())),
31        ("kind", Some(memory.kind.to_string().into())),
32        ("predicate", Some(memory.predicate.as_str().into())),
33        ("status", Some(status_label(memory.status).into())),
34        (
35            "confidence",
36            Some(Yaml::Float(f64::from(memory.confidence))),
37        ),
38        (
39            "temporal_scope",
40            Some(temporal_scope_label(memory.temporal_scope).into()),
41        ),
42        (
43            "subject",
44            Some(yaml::map(vec![
45                ("id", Some(memory.subject.id.as_str().into())),
46                ("display", Some(memory.subject.display.clone().into())),
47                (
48                    "aliases",
49                    Some(yaml::seq_of_strings(memory.subject.aliases.clone())),
50                ),
51            ])),
52        ),
53        ("value", Some(value_to_yaml(&memory.value))),
54        (
55            "qualifier",
56            Some(
57                memory
58                    .qualifier
59                    .clone()
60                    .map(Yaml::Str)
61                    .unwrap_or(Yaml::Null),
62            ),
63        ),
64        (
65            "source",
66            Some(yaml::map(vec![
67                ("type", Some(memory.source.source_type.clone().into())),
68                (
69                    "session_id",
70                    Some(
71                        memory
72                            .source
73                            .session_id
74                            .as_ref()
75                            .map(|s| Yaml::Str(s.to_string()))
76                            .unwrap_or(Yaml::Null),
77                    ),
78                ),
79                (
80                    "turn_id",
81                    Some(
82                        memory
83                            .source
84                            .turn_id
85                            .map(|t| Yaml::Str(t.to_string()))
86                            .unwrap_or(Yaml::Null),
87                    ),
88                ),
89            ])),
90        ),
91        (
92            "temporal",
93            Some(yaml::map(vec![
94                ("created_at", Some(timestamp(memory.temporal.created_at))),
95                ("updated_at", Some(timestamp(memory.temporal.updated_at))),
96                (
97                    "last_confirmed_at",
98                    Some(timestamp(memory.temporal.last_confirmed_at)),
99                ),
100                ("valid_from", Some(timestamp(memory.temporal.valid_from))),
101                (
102                    "valid_to",
103                    Some(optional_timestamp(memory.temporal.valid_to)),
104                ),
105                (
106                    "expires_at",
107                    Some(optional_timestamp(memory.temporal.expires_at)),
108                ),
109            ])),
110        ),
111        (
112            "retrieval",
113            Some(yaml::map(vec![
114                ("subject", Some(memory.retrieval.subject.clone().into())),
115                (
116                    "tags",
117                    Some(yaml::seq_of_strings(memory.retrieval.tags.clone())),
118                ),
119                (
120                    "aliases",
121                    Some(yaml::seq_of_strings(memory.retrieval.aliases.clone())),
122                ),
123                (
124                    "entities",
125                    Some(yaml::seq_of_strings(memory.retrieval.entities.clone())),
126                ),
127                (
128                    "location",
129                    Some(
130                        memory
131                            .retrieval
132                            .location
133                            .clone()
134                            .map(Yaml::Str)
135                            .unwrap_or(Yaml::Null),
136                    ),
137                ),
138            ])),
139        ),
140        (
141            "evidence",
142            Some(yaml::map(vec![
143                ("count", Some(memory.evidence.count.into())),
144                (
145                    "distinct_sessions",
146                    Some(memory.evidence.distinct_sessions.into()),
147                ),
148                ("distinct_days", Some(memory.evidence.distinct_days.into())),
149            ])),
150        ),
151        (
152            "privacy",
153            Some(yaml::map(vec![
154                ("deletable", Some(memory.privacy.deletable.into())),
155                ("exportable", Some(memory.privacy.exportable.into())),
156                (
157                    "sensitivity",
158                    Some(sensitivity_label(memory.privacy.sensitivity).into()),
159                ),
160            ])),
161        ),
162        (
163            "superseded_by",
164            Some(
165                memory
166                    .superseded_by
167                    .as_ref()
168                    .map(|m| Yaml::Str(m.to_string()))
169                    .unwrap_or(Yaml::Null),
170            ),
171        ),
172    ]);
173
174    let mut sections = vec![
175        (SECTION_FACT, memory.statement.clone()),
176        (SECTION_EVIDENCE, memory.evidence_summary.clone()),
177    ];
178    if !memory.supersedes.is_empty() {
179        let list = memory
180            .supersedes
181            .iter()
182            .map(|id| format!("- {id}"))
183            .collect::<Vec<_>>()
184            .join("\n");
185        sections.push((SECTION_SUPERSEDES, list));
186    }
187    OkfDocument::new(front, sections)
188}
189
190/// Parse an OKF document back into a canonical memory.
191pub fn from_document(doc: &OkfDocument, path: &str) -> Result<CanonicalMemory, MemoryError> {
192    let err = |message: String| MemoryError::MalformedRecord {
193        path: path.to_string(),
194        message,
195    };
196
197    let version = doc
198        .front
199        .get("okf")
200        .and_then(Yaml::as_string)
201        .ok_or_else(|| err("missing `okf` version".to_string()))?;
202    if version != OKF_VERSION {
203        return Err(err(format!("unsupported OKF version `{version}`")));
204    }
205
206    let id = MemoryId::new(
207        doc.front
208            .get("id")
209            .and_then(Yaml::as_string)
210            .ok_or_else(|| err("missing `id`".to_string()))?,
211    );
212    let owner = UserId::new(
213        doc.front
214            .get("owner")
215            .and_then(Yaml::as_string)
216            .ok_or_else(|| err("missing `owner`".to_string()))?,
217    );
218    let kind = parse_kind(
219        &doc.front
220            .get("kind")
221            .and_then(Yaml::as_string)
222            .ok_or_else(|| err("missing `kind`".to_string()))?,
223    )
224    .ok_or_else(|| err("unknown `kind`".to_string()))?;
225    let predicate = CanonicalPredicate::new(
226        doc.front
227            .get("predicate")
228            .and_then(Yaml::as_string)
229            .ok_or_else(|| err("missing `predicate`".to_string()))?,
230    );
231    let status = parse_status(
232        &doc.front
233            .get("status")
234            .and_then(Yaml::as_string)
235            .unwrap_or_else(|| "active".to_string()),
236    )
237    .ok_or_else(|| err("unknown `status`".to_string()))?;
238    let confidence = doc
239        .front
240        .get("confidence")
241        .and_then(Yaml::as_f64)
242        .unwrap_or(0.0) as f32;
243    let temporal_scope = doc
244        .front
245        .get("temporal_scope")
246        .and_then(Yaml::as_string)
247        .and_then(|s| parse_temporal_scope(&s))
248        .unwrap_or(TemporalScope::Persistent);
249
250    let subject_node = doc.front.get("subject");
251    let subject = match subject_node {
252        Some(node) if node.get("display").is_some() => EntityRef {
253            id: EntityId::new(
254                node.get("id")
255                    .and_then(Yaml::as_string)
256                    .unwrap_or_else(|| "user".to_string()),
257            ),
258            display: node
259                .get("display")
260                .and_then(Yaml::as_string)
261                .unwrap_or_else(|| "user".to_string()),
262            aliases: node
263                .get("aliases")
264                .map(Yaml::as_string_list)
265                .unwrap_or_default(),
266        },
267        _ => EntityRef::user(),
268    };
269
270    let value = doc
271        .front
272        .get("value")
273        .map(yaml_to_value)
274        .transpose()
275        .map_err(err)?
276        .unwrap_or_else(|| MemoryValue::Text(String::new()));
277
278    let source_node = doc.front.get("source");
279    let source = MemorySource {
280        source_type: source_node
281            .and_then(|n| n.get("type"))
282            .and_then(Yaml::as_string)
283            .unwrap_or_else(|| "unknown".to_string()),
284        session_id: source_node
285            .and_then(|n| n.get("session_id"))
286            .and_then(Yaml::as_string)
287            .map(SessionId::new),
288        turn_id: source_node
289            .and_then(|n| n.get("turn_id"))
290            .and_then(Yaml::as_string)
291            .and_then(|raw| parse_turn_id(&raw)),
292    };
293
294    let temporal_node = doc
295        .front
296        .get("temporal")
297        .ok_or_else(|| err("missing `temporal` block".to_string()))?;
298    let created_at = required_time(temporal_node, "created_at", path)?;
299    let temporal = TemporalMetadata {
300        created_at,
301        updated_at: optional_time(temporal_node, "updated_at").unwrap_or(created_at),
302        last_confirmed_at: optional_time(temporal_node, "last_confirmed_at").unwrap_or(created_at),
303        valid_from: optional_time(temporal_node, "valid_from").unwrap_or(created_at),
304        valid_to: optional_time(temporal_node, "valid_to"),
305        expires_at: optional_time(temporal_node, "expires_at"),
306    };
307
308    let retrieval_node = doc.front.get("retrieval");
309    let retrieval = RetrievalMetadata {
310        subject: retrieval_node
311            .and_then(|n| n.get("subject"))
312            .and_then(Yaml::as_string)
313            .unwrap_or_else(|| subject.display.clone()),
314        tags: retrieval_node
315            .and_then(|n| n.get("tags"))
316            .map(Yaml::as_string_list)
317            .unwrap_or_default(),
318        aliases: retrieval_node
319            .and_then(|n| n.get("aliases"))
320            .map(Yaml::as_string_list)
321            .unwrap_or_default(),
322        entities: retrieval_node
323            .and_then(|n| n.get("entities"))
324            .map(Yaml::as_string_list)
325            .unwrap_or_default(),
326        location: retrieval_node
327            .and_then(|n| n.get("location"))
328            .and_then(Yaml::as_string),
329    };
330
331    let evidence_node = doc.front.get("evidence");
332    let evidence = EvidenceCounters {
333        count: evidence_node
334            .and_then(|n| n.get("count"))
335            .and_then(Yaml::as_u64)
336            .unwrap_or(1) as u32,
337        distinct_sessions: evidence_node
338            .and_then(|n| n.get("distinct_sessions"))
339            .and_then(Yaml::as_u64)
340            .unwrap_or(1) as u32,
341        distinct_days: evidence_node
342            .and_then(|n| n.get("distinct_days"))
343            .and_then(Yaml::as_u64)
344            .unwrap_or(1) as u32,
345    };
346
347    let privacy_node = doc.front.get("privacy");
348    let privacy = PrivacyMetadata {
349        deletable: privacy_node
350            .and_then(|n| n.get("deletable"))
351            .and_then(Yaml::as_bool)
352            .unwrap_or(true),
353        exportable: privacy_node
354            .and_then(|n| n.get("exportable"))
355            .and_then(Yaml::as_bool)
356            .unwrap_or(true),
357        sensitivity: privacy_node
358            .and_then(|n| n.get("sensitivity"))
359            .and_then(Yaml::as_string)
360            .and_then(|s| parse_sensitivity(&s))
361            .unwrap_or(SensitivityClass::Normal),
362    };
363
364    Ok(CanonicalMemory {
365        id,
366        owner,
367        kind,
368        predicate,
369        status,
370        confidence,
371        subject,
372        value,
373        statement: doc.section(SECTION_FACT).unwrap_or_default().to_string(),
374        evidence_summary: doc
375            .section(SECTION_EVIDENCE)
376            .unwrap_or_default()
377            .to_string(),
378        source,
379        temporal,
380        retrieval,
381        evidence,
382        privacy,
383        temporal_scope,
384        supersedes: doc
385            .section_list(SECTION_SUPERSEDES)
386            .into_iter()
387            .map(MemoryId::new)
388            .collect(),
389        superseded_by: doc
390            .front
391            .get("superseded_by")
392            .and_then(Yaml::as_string)
393            .map(MemoryId::new),
394        qualifier: doc.front.get("qualifier").and_then(Yaml::as_string),
395    })
396}
397
398fn value_to_yaml(value: &MemoryValue) -> Yaml {
399    match value {
400        MemoryValue::Text(t) => yaml::map(vec![
401            ("type", Some("text".into())),
402            ("value", Some(t.clone().into())),
403        ]),
404        MemoryValue::Bool(b) => yaml::map(vec![
405            ("type", Some("bool".into())),
406            ("value", Some((*b).into())),
407        ]),
408        MemoryValue::Number(n) => yaml::map(vec![
409            ("type", Some("number".into())),
410            ("value", Some(Yaml::Float(*n))),
411        ]),
412        MemoryValue::List(items) => yaml::map(vec![
413            ("type", Some("list".into())),
414            ("value", Some(yaml::seq_of_strings(items.clone()))),
415        ]),
416    }
417}
418
419fn yaml_to_value(node: &Yaml) -> Result<MemoryValue, String> {
420    let kind = node
421        .get("type")
422        .and_then(Yaml::as_string)
423        .ok_or_else(|| "value block missing `type`".to_string())?;
424    let raw = node.get("value").unwrap_or(&Yaml::Null);
425    match kind.as_str() {
426        "text" => Ok(MemoryValue::Text(raw.as_string().unwrap_or_default())),
427        "bool" => Ok(MemoryValue::Bool(raw.as_bool().unwrap_or(false))),
428        "number" => Ok(MemoryValue::Number(raw.as_f64().unwrap_or(0.0))),
429        "list" => Ok(MemoryValue::List(raw.as_string_list())),
430        other => Err(format!("unknown value type `{other}`")),
431    }
432}
433
434fn timestamp(value: DateTime<Utc>) -> Yaml {
435    Yaml::Str(value.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
436}
437
438fn optional_timestamp(value: Option<DateTime<Utc>>) -> Yaml {
439    value.map(timestamp).unwrap_or(Yaml::Null)
440}
441
442fn required_time(node: &Yaml, key: &str, path: &str) -> Result<DateTime<Utc>, MemoryError> {
443    optional_time(node, key).ok_or_else(|| MemoryError::MalformedRecord {
444        path: path.to_string(),
445        message: format!("missing or unparsable `temporal.{key}`"),
446    })
447}
448
449fn optional_time(node: &Yaml, key: &str) -> Option<DateTime<Utc>> {
450    node.get(key)
451        .and_then(Yaml::as_string)
452        .and_then(|raw| DateTime::parse_from_rfc3339(&raw).ok())
453        .map(|dt| dt.with_timezone(&Utc))
454}
455
456fn parse_turn_id(raw: &str) -> Option<TurnId> {
457    raw.strip_prefix("turn_")
458        .unwrap_or(raw)
459        .parse::<u64>()
460        .ok()
461        .map(TurnId)
462}
463
464fn status_label(status: MemoryStatus) -> &'static str {
465    match status {
466        MemoryStatus::Active => "active",
467        MemoryStatus::Staged => "staged",
468        MemoryStatus::Superseded => "superseded",
469        MemoryStatus::Expired => "expired",
470        MemoryStatus::Deleted => "deleted",
471    }
472}
473
474fn parse_status(raw: &str) -> Option<MemoryStatus> {
475    Some(match raw {
476        "active" => MemoryStatus::Active,
477        "staged" => MemoryStatus::Staged,
478        "superseded" => MemoryStatus::Superseded,
479        "expired" => MemoryStatus::Expired,
480        "deleted" => MemoryStatus::Deleted,
481        _ => return None,
482    })
483}
484
485fn temporal_scope_label(scope: TemporalScope) -> &'static str {
486    match scope {
487        TemporalScope::Persistent => "persistent",
488        TemporalScope::RecentHistory => "recent_history",
489        TemporalScope::Momentary => "momentary",
490        TemporalScope::Scheduled => "scheduled",
491    }
492}
493
494fn parse_temporal_scope(raw: &str) -> Option<TemporalScope> {
495    Some(match raw {
496        "persistent" => TemporalScope::Persistent,
497        "recent_history" => TemporalScope::RecentHistory,
498        "momentary" => TemporalScope::Momentary,
499        "scheduled" => TemporalScope::Scheduled,
500        _ => return None,
501    })
502}
503
504fn sensitivity_label(class: SensitivityClass) -> &'static str {
505    match class {
506        SensitivityClass::Normal => "normal",
507        SensitivityClass::Sensitive => "sensitive",
508        SensitivityClass::Restricted => "restricted",
509    }
510}
511
512fn parse_sensitivity(raw: &str) -> Option<SensitivityClass> {
513    Some(match raw {
514        "normal" => SensitivityClass::Normal,
515        "sensitive" => SensitivityClass::Sensitive,
516        "restricted" => SensitivityClass::Restricted,
517        _ => return None,
518    })
519}
520
521fn parse_kind(raw: &str) -> Option<MemoryKind> {
522    serde_json::from_value(serde_json::Value::String(raw.to_string())).ok()
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::core::{Explicitness, ids::SessionId};
529
530    fn sample() -> CanonicalMemory {
531        let now = DateTime::parse_from_rfc3339("2026-07-26T09:12:14Z")
532            .unwrap()
533            .with_timezone(&Utc);
534        CanonicalMemory {
535            id: MemoryId::new("mem_01K4D8P3"),
536            owner: UserId::new("usr_72ab"),
537            kind: MemoryKind::Preference,
538            predicate: CanonicalPredicate::new("dietary_identity"),
539            status: MemoryStatus::Active,
540            confidence: 1.0,
541            subject: EntityRef::user(),
542            value: MemoryValue::Text("pescatarian".into()),
543            statement: "The user is pescatarian.".into(),
544            evidence_summary: "Explicitly stated by the user.".into(),
545            source: MemorySource::from_explicitness(
546                Explicitness::ExplicitStatement,
547                SessionId::new("ses_01K4"),
548                TurnId(17),
549            ),
550            temporal: TemporalMetadata::created_at(now),
551            retrieval: RetrievalMetadata {
552                subject: "user".into(),
553                tags: vec!["food".into(), "diet".into(), "pescatarian".into()],
554                aliases: vec!["does not eat meat".into(), "eats fish".into()],
555                entities: vec![],
556                location: None,
557            },
558            evidence: EvidenceCounters::first(),
559            privacy: PrivacyMetadata::default(),
560            temporal_scope: TemporalScope::Persistent,
561            supersedes: vec![MemoryId::new("mem_01JVEGETARIAN")],
562            superseded_by: None,
563            qualifier: None,
564        }
565    }
566
567    #[test]
568    fn round_trips_through_markdown() {
569        let original = sample();
570        let markdown = to_document(&original).to_markdown();
571        let reparsed = OkfDocument::parse(&markdown, "sample.md").unwrap();
572        let recovered = from_document(&reparsed, "sample.md").unwrap();
573        assert_eq!(original, recovered);
574    }
575
576    #[test]
577    fn the_rendered_file_is_human_readable() {
578        let markdown = to_document(&sample()).to_markdown();
579        assert!(markdown.starts_with("---\nokf: memory/v1\n"));
580        assert!(markdown.contains("\n# Fact\nThe user is pescatarian.\n"));
581        assert!(markdown.contains("turn_id: turn_17"));
582        assert!(markdown.contains("created_at: 2026-07-26T09:12:14Z"));
583        assert!(markdown.contains("- mem_01JVEGETARIAN"));
584    }
585
586    #[test]
587    fn an_unknown_format_version_is_refused() {
588        let mut doc = to_document(&sample());
589        if let Yaml::Map(entries) = &mut doc.front {
590            entries[0].1 = Yaml::Str("memory/v99".into());
591        }
592        let err = from_document(&doc, "x.md").unwrap_err();
593        assert!(err.to_string().contains("unsupported OKF version"));
594    }
595
596    #[test]
597    fn hand_edited_records_missing_optional_blocks_still_parse() {
598        let minimal = r#"---
599okf: memory/v1
600id: mem_hand
601owner: usr_72ab
602kind: preference
603predicate: coffee_order
604status: active
605confidence: 0.9
606value:
607  type: text
608  value: flat white
609temporal:
610  created_at: 2026-07-26T09:12:14Z
611---
612# Fact
613The user drinks flat whites.
614"#;
615        let doc = OkfDocument::parse(minimal, "minimal.md").unwrap();
616        let memory = from_document(&doc, "minimal.md").unwrap();
617        assert_eq!(memory.statement, "The user drinks flat whites.");
618        assert_eq!(memory.evidence.count, 1);
619        assert_eq!(memory.temporal.updated_at, memory.temporal.created_at);
620        assert!(memory.privacy.deletable);
621    }
622
623    #[test]
624    fn statements_containing_colons_survive_the_round_trip() {
625        let mut memory = sample();
626        memory.statement = "The user said: quiet places only.".into();
627        memory.qualifier = Some("with family".into());
628        let markdown = to_document(&memory).to_markdown();
629        let recovered =
630            from_document(&OkfDocument::parse(&markdown, "x.md").unwrap(), "x.md").unwrap();
631        assert_eq!(recovered.statement, memory.statement);
632        assert_eq!(recovered.qualifier.as_deref(), Some("with family"));
633    }
634}