1use 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
17pub const OKF_VERSION: &str = "memory/v1";
19
20const SECTION_FACT: &str = "Fact";
22const SECTION_EVIDENCE: &str = "Evidence Summary";
23const SECTION_SUPERSEDES: &str = "Supersedes";
24
25pub 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
190pub 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}