1use std::path::PathBuf;
24use std::sync::{Arc, Mutex};
25
26use async_trait::async_trait;
27use car_engine::ToolExecutor;
28use car_eventlog::Event;
29use car_memgine::{
30 MemgineEngine, ProactiveMaintenanceRequest, ProactiveMemoryDecision, ProactiveMemoryRequest,
31};
32use serde_json::{json, Value};
33
34pub use car_memgine::note_store::{ingest, Note, NoteKind};
40
41#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct SyncedFact {
52 pub subject: String,
53 pub body: String,
54 pub kind: NoteKind,
55}
56
57#[async_trait]
64pub trait MemorySync: Send + Sync {
65 async fn append_knowledge(
87 &self,
88 subject: &str,
89 body: &str,
90 kind: NoteKind,
91 ) -> Result<(), String>;
92
93 async fn pull_knowledge(&self) -> Result<Vec<SyncedFact>, String>;
99}
100
101pub struct MemoryTools {
103 inner: Mutex<Inner>,
104 path: PathBuf,
105 sync: Option<Arc<dyn MemorySync>>,
107}
108
109struct Inner {
110 engine: MemgineEngine,
111 notes: Vec<Note>,
112}
113
114impl MemoryTools {
115 pub fn open(path: PathBuf) -> Self {
118 let (engine, notes) = car_memgine::note_store::engine_from(&path);
119 Self {
120 inner: Mutex::new(Inner { engine, notes }),
121 path,
122 sync: None,
123 }
124 }
125
126 pub fn with_sync(mut self, sync: Option<Arc<dyn MemorySync>>) -> Self {
130 self.sync = sync;
131 self
132 }
133
134 pub fn tool_defs() -> Vec<Value> {
136 vec![
137 json!({
138 "name": "remember",
139 "description": "Save a durable fact about the user or task so you can recall it in \
140 future sessions. Use for things worth persisting, not transient \
141 details. `kind` decides how the fact is used later, so pick it \
142 deliberately.",
143 "parameters": {
144 "type": "object",
145 "properties": {
146 "subject": { "type": "string", "description": "Short label for the fact (e.g. 'project name')." },
147 "body": { "type": "string", "description": "The fact to remember." },
148 "kind": {
149 "type": "string",
150 "enum": ["fact", "preference", "procedure"],
151 "description": "'fact' (default): something true about the user, \
152 project, or environment; retrieved when relevant to \
153 the query. 'preference': a standing instruction about \
154 how the user wants you to work — surfaced in EVERY \
155 future session, so reserve it for rules you should \
156 never violate. 'procedure': how a task was done and \
157 whether it worked, for reuse next time."
158 }
159 },
160 "required": ["subject", "body"]
161 },
162 "mutating": true,
163 "tier": "full_access"
164 }),
165 json!({
166 "name": "recall",
167 "description": "Retrieve previously remembered facts relevant to a query. Use at the \
168 start of a task to recall what you know about the user or project.",
169 "parameters": {
170 "type": "object",
171 "properties": {
172 "query": { "type": "string", "description": "What to recall." }
173 },
174 "required": ["query"]
175 }
176 }),
177 ]
178 }
179
180 fn remember(&self, params: &Value) -> Result<(Value, NoteKind), String> {
184 let subject = normalize_subject(
187 params
188 .get("subject")
189 .and_then(Value::as_str)
190 .ok_or("remember requires a 'subject' string")?,
191 );
192 let body = params
193 .get("body")
194 .and_then(Value::as_str)
195 .ok_or("remember requires a 'body' string")?;
196 let kind = NoteKind::parse(params.get("kind").and_then(Value::as_str))
197 .map_err(|e| format!("remember: {e}"))?;
198 let mut g = self.inner.lock().map_err(|_| "memory lock poisoned")?;
199 let note = Note {
200 subject: subject.to_string(),
201 body: body.to_string(),
202 kind,
203 };
204 match g
209 .notes
210 .iter_mut()
211 .find(|n| n.subject.eq_ignore_ascii_case(subject))
212 {
213 Some(existing) => {
214 existing.body = body.to_string();
215 existing.kind = kind;
216 }
217 None => g.notes.push(note),
218 }
219 let mut engine = MemgineEngine::new(None);
222 for (i, n) in g.notes.iter().enumerate() {
223 ingest(&mut engine, i, n);
224 }
225 g.engine = engine;
226 car_memgine::note_store::save(&self.path, &g.notes)?;
228 Ok((
229 json!({ "remembered": subject, "total_facts": g.notes.len() }),
230 kind,
231 ))
232 }
233
234 async fn mirror_to_sync(&self, params: &Value, kind: NoteKind) {
239 let Some(sync) = &self.sync else {
240 return;
241 };
242 let (Some(subject), Some(body)) = (
243 params.get("subject").and_then(Value::as_str),
244 params.get("body").and_then(Value::as_str),
245 ) else {
246 return;
247 };
248 if let Err(e) = sync
252 .append_knowledge(normalize_subject(subject), body, kind)
253 .await
254 {
255 tracing::debug!(
256 target: "assistant.memory",
257 "knowledge sync append failed (non-fatal): {e}"
258 );
259 }
260 }
261
262 async fn pull_peer_knowledge(&self) -> Vec<SyncedFact> {
265 let Some(sync) = &self.sync else {
266 return Vec::new();
267 };
268 match sync.pull_knowledge().await {
269 Ok(facts) => facts,
270 Err(e) => {
271 tracing::debug!(
272 target: "assistant.memory",
273 "peer knowledge pull failed (non-fatal): {e}"
274 );
275 Vec::new()
276 }
277 }
278 }
279
280 async fn recall(&self, params: &Value) -> Result<Value, String> {
281 let query = params
282 .get("query")
283 .and_then(Value::as_str)
284 .ok_or("recall requires a 'query' string")?;
285 let peers = self.pull_peer_knowledge().await;
288 let mut g = self.inner.lock().map_err(|_| "memory lock poisoned")?;
289 if g.notes.is_empty() && peers.is_empty() {
290 return Ok(json!({ "query": query, "context": "", "note": "no facts remembered yet" }));
291 }
292 let local_keys: std::collections::HashSet<String> =
298 g.notes.iter().map(|n| subject_key(&n.subject)).collect();
299 let gaps: Vec<&SyncedFact> = peers
300 .iter()
301 .filter(|fact| !local_keys.contains(&subject_key(&fact.subject)))
302 .collect();
303 if gaps.is_empty() {
304 let context = g.engine.build_context(query);
306 return Ok(json!({ "query": query, "context": context }));
307 }
308 let mut engine = MemgineEngine::new(None);
312 let mut idx = 0;
313 for n in &g.notes {
314 ingest(&mut engine, idx, n);
315 idx += 1;
316 }
317 for fact in gaps {
318 ingest(
319 &mut engine,
320 idx,
321 &Note {
322 subject: fact.subject.clone(),
323 body: fact.body.clone(),
324 kind: fact.kind,
330 },
331 );
332 idx += 1;
333 }
334 let context = engine.build_context(query);
335 Ok(json!({ "query": query, "context": context }))
336 }
337
338 pub async fn proactive_intervention(
346 &self,
347 query: &str,
348 recent: Vec<String>,
349 events: &[Event],
350 ) -> Result<
351 (
352 car_memgine::ProactiveMaintenanceReport,
353 ProactiveMemoryDecision,
354 ),
355 String,
356 > {
357 let peers = self.pull_peer_knowledge().await;
358 let mut g = self.inner.lock().map_err(|_| "memory lock poisoned")?;
359
360 let local_keys: std::collections::HashSet<String> =
361 g.notes.iter().map(|n| subject_key(&n.subject)).collect();
362 let gaps: Vec<&SyncedFact> = peers
363 .iter()
364 .filter(|fact| !local_keys.contains(&subject_key(&fact.subject)))
365 .collect();
366
367 let maintenance_request = ProactiveMaintenanceRequest {
368 max_recent: 32,
369 tenant_id: None,
370 };
371 let mut request = ProactiveMemoryRequest {
372 query: query.to_string(),
373 recent,
374 ..Default::default()
375 };
376
377 if gaps.is_empty() {
378 let maintenance = g
379 .engine
380 .maintain_proactive_memory_from_events(events, &maintenance_request);
381 request.trigger.merge(maintenance.trigger.clone());
382 let decision = g.engine.proactive_intervention(&request);
383 return Ok((maintenance, decision));
384 }
385
386 let mut engine = MemgineEngine::new(None);
387 let mut idx = 0;
388 for n in &g.notes {
389 ingest(&mut engine, idx, n);
390 idx += 1;
391 }
392 for fact in gaps {
393 ingest(
394 &mut engine,
395 idx,
396 &Note {
397 subject: fact.subject.clone(),
398 body: fact.body.clone(),
399 kind: fact.kind,
405 },
406 );
407 idx += 1;
408 }
409 let maintenance =
410 engine.maintain_proactive_memory_from_events(events, &maintenance_request);
411 request.trigger.merge(maintenance.trigger.clone());
412 let decision = engine.proactive_intervention(&request);
413 Ok((maintenance, decision))
414 }
415}
416
417fn normalize_subject(subject: &str) -> &str {
420 subject.trim()
421}
422
423fn subject_key(subject: &str) -> String {
428 normalize_subject(subject).to_ascii_lowercase()
429}
430
431#[async_trait]
432impl ToolExecutor for MemoryTools {
433 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
434 match tool {
435 "remember" => {
436 let (out, kind) = self.remember(params)?;
437 self.mirror_to_sync(params, kind).await;
439 Ok(out)
440 }
441 "recall" => self.recall(params).await,
442 other => Err(format!("unknown tool: '{other}'")),
443 }
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn remember_declares_mutating_and_full_access() {
453 let defs = MemoryTools::tool_defs();
454 let remember = defs
455 .iter()
456 .find(|def| def["name"] == "remember")
457 .expect("remember tool def");
458 assert_eq!(remember["mutating"], true);
459 assert_eq!(remember["tier"], "full_access");
460 }
461
462 #[test]
463 fn remember_advertises_the_memory_taxonomy() {
464 let defs = MemoryTools::tool_defs();
465 let remember = defs.iter().find(|def| def["name"] == "remember").unwrap();
466 let kinds = remember["parameters"]["properties"]["kind"]["enum"]
467 .as_array()
468 .expect("kind is an enum — the schema is what teaches the model");
469 for expected in ["fact", "preference", "procedure"] {
470 assert!(kinds.iter().any(|k| k == expected), "missing {expected}");
471 }
472 let required = remember["parameters"]["required"].as_array().unwrap();
474 assert!(!required.iter().any(|r| r == "kind"));
475 }
476
477 #[tokio::test]
478 async fn preference_becomes_an_always_included_constraint() {
479 let dir = tempfile::tempdir().unwrap();
480 let mem = MemoryTools::open(dir.path().join("assistant.json"));
481 mem.execute(
482 "remember",
483 &json!({
484 "subject": "code review",
485 "body": "Never merge without a green CI run.",
486 "kind": "preference"
487 }),
488 )
489 .await
490 .unwrap();
491 mem.execute(
492 "remember",
493 &json!({ "subject": "project name", "body": "The project is called Zephyr." }),
494 )
495 .await
496 .unwrap();
497
498 let out = mem
501 .execute("recall", &json!({ "query": "what time is the standup" }))
502 .await
503 .unwrap();
504 let context = out["context"].as_str().unwrap();
505 assert!(
506 context.contains("Active Constraints") && context.contains("green CI run"),
507 "preference must land in the always-included constraints layer: {context}"
508 );
509 }
510
511 #[tokio::test]
512 async fn unknown_kind_is_rejected_rather_than_silently_stored() {
513 let dir = tempfile::tempdir().unwrap();
514 let mem = MemoryTools::open(dir.path().join("assistant.json"));
515 let err = mem
516 .execute(
517 "remember",
518 &json!({ "subject": "s", "body": "b", "kind": "identity" }),
519 )
520 .await
521 .unwrap_err();
522 assert!(err.contains("unknown kind 'identity'"), "{err}");
523 }
524
525 #[tokio::test]
526 async fn remembers_across_reopen() {
527 let dir = tempfile::tempdir().unwrap();
528 let path = dir.path().join("assistant.json");
529
530 {
531 let mem = MemoryTools::open(path.clone());
532 mem.execute(
533 "remember",
534 &json!({ "subject": "project name", "body": "The project is called Zephyr." }),
535 )
536 .await
537 .unwrap();
538 }
539
540 let mem = MemoryTools::open(path.clone());
542 let out = mem
543 .execute("recall", &json!({ "query": "what is my project called?" }))
544 .await
545 .unwrap();
546 let ctx = out["context"].as_str().unwrap();
547 assert!(
548 ctx.contains("Zephyr"),
549 "recall should surface the fact: {ctx}"
550 );
551 }
552
553 #[tokio::test]
554 async fn remember_supersedes_same_subject() {
555 let dir = tempfile::tempdir().unwrap();
556 let path = dir.path().join("m.json");
557 let mem = MemoryTools::open(path.clone());
558 mem.execute("remember", &json!({ "subject": "editor", "body": "vim" }))
559 .await
560 .unwrap();
561 let out = mem
562 .execute("remember", &json!({ "subject": "Editor", "body": "emacs" }))
563 .await
564 .unwrap();
565 assert_eq!(out["total_facts"], 1);
567 let notes: Vec<Note> =
568 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
569 assert_eq!(notes.len(), 1);
570 assert_eq!(notes[0].body, "emacs");
571 }
572
573 #[tokio::test]
574 async fn recall_on_empty_is_graceful() {
575 let dir = tempfile::tempdir().unwrap();
576 let mem = MemoryTools::open(dir.path().join("m.json"));
577 let out = mem
578 .execute("recall", &json!({ "query": "anything" }))
579 .await
580 .unwrap();
581 assert_eq!(out["context"], "");
582 }
583
584 #[tokio::test]
601 async fn recall_ranks_by_relevance_not_creation_order() {
602 let dir = tempfile::tempdir().unwrap();
603 let mem = MemoryTools::open(dir.path().join("m.json"));
604
605 for (subject, body) in [
608 ("deployment target", "Unclear which region we deploy to."),
609 ("db", "Postgres 16 in prod."),
610 ("owner", "Matt owns the release process."),
611 ] {
612 mem.execute("remember", &json!({ "subject": subject, "body": body }))
613 .await
614 .unwrap();
615 }
616
617 let out = mem
618 .execute("recall", &json!({ "query": "deployment target" }))
619 .await
620 .unwrap();
621 let ctx = out["context"].as_str().unwrap_or_default();
622
623 let facts: Vec<&str> = ctx
624 .lines()
625 .skip_while(|l| !l.starts_with("## Current Facts"))
626 .filter(|l| l.starts_with("- "))
627 .collect();
628 assert!(facts.len() >= 3, "expected all facts back, got: {ctx}");
629 assert!(
630 facts.last().unwrap().contains("deployment target"),
631 "the query-matching fact must rank last (relevance-ascending); \
632 got creation order, which means Fast mode is running.\nfacts: {facts:#?}"
633 );
634 }
635
636 struct RecordingSync(Mutex<Vec<SyncedFact>>);
637 #[async_trait]
638 impl MemorySync for RecordingSync {
639 async fn append_knowledge(
640 &self,
641 subject: &str,
642 body: &str,
643 kind: NoteKind,
644 ) -> Result<(), String> {
645 self.0.lock().unwrap().push(SyncedFact {
646 subject: subject.into(),
647 body: body.into(),
648 kind,
649 });
650 Ok(())
651 }
652 async fn pull_knowledge(&self) -> Result<Vec<SyncedFact>, String> {
653 Ok(Vec::new())
654 }
655 }
656
657 struct PeerSync(Vec<SyncedFact>);
659 #[async_trait]
660 impl MemorySync for PeerSync {
661 async fn append_knowledge(&self, _: &str, _: &str, _: NoteKind) -> Result<(), String> {
662 Ok(())
663 }
664 async fn pull_knowledge(&self) -> Result<Vec<SyncedFact>, String> {
665 Ok(self.0.clone())
666 }
667 }
668
669 fn peer_fact(subject: &str, body: &str, kind: NoteKind) -> SyncedFact {
671 SyncedFact {
672 subject: subject.into(),
673 body: body.into(),
674 kind,
675 }
676 }
677
678 #[tokio::test]
679 async fn remember_mirrors_normalized_subject_and_body_to_sync() {
680 let dir = tempfile::tempdir().unwrap();
681 let rec = Arc::new(RecordingSync(Mutex::new(Vec::new())));
682 let mem = MemoryTools::open(dir.path().join("m.json")).with_sync(Some(rec.clone()));
683 mem.execute(
686 "remember",
687 &json!({ "subject": " Editor ", "body": "vim" }),
688 )
689 .await
690 .unwrap();
691 let got = rec.0.lock().unwrap().clone();
692 assert_eq!(got, vec![peer_fact("Editor", "vim", NoteKind::Fact)]);
693 }
694
695 #[tokio::test]
696 async fn remember_without_sync_stays_local_only() {
697 let dir = tempfile::tempdir().unwrap();
699 let mem = MemoryTools::open(dir.path().join("m.json"));
700 let out = mem
701 .execute("remember", &json!({ "subject": "x", "body": "y" }))
702 .await
703 .unwrap();
704 assert_eq!(out["remembered"], "x");
705 }
706
707 struct FailingSync;
708 #[async_trait]
709 impl MemorySync for FailingSync {
710 async fn append_knowledge(&self, _: &str, _: &str, _: NoteKind) -> Result<(), String> {
711 Err("oplog unreachable".into())
712 }
713 async fn pull_knowledge(&self) -> Result<Vec<SyncedFact>, String> {
714 Err("oplog unreachable".into())
715 }
716 }
717
718 #[tokio::test]
719 async fn recall_surfaces_a_peer_fact_not_held_locally() {
720 let dir = tempfile::tempdir().unwrap();
722 let peer = Arc::new(PeerSync(vec![peer_fact(
723 "project",
724 "the project is Zephyr",
725 NoteKind::Fact,
726 )]));
727 let mem = MemoryTools::open(dir.path().join("m.json")).with_sync(Some(peer));
728 let out = mem
729 .execute("recall", &json!({ "query": "what is my project?" }))
730 .await
731 .unwrap();
732 assert!(
733 out["context"].as_str().unwrap().contains("Zephyr"),
734 "{}",
735 out["context"]
736 );
737 }
738
739 #[tokio::test]
752 async fn a_peer_preference_stays_a_standing_rule_and_a_peer_fact_does_not() {
753 let rule = "Never merge without a green CI run.";
754 let unrelated = "what time is the standup";
755
756 let recall_with = |kind| async move {
757 let dir = tempfile::tempdir().unwrap();
758 let peer = Arc::new(PeerSync(vec![peer_fact("code review", rule, kind)]));
759 let mem = MemoryTools::open(dir.path().join("m.json")).with_sync(Some(peer));
760 let out = mem
761 .execute("recall", &json!({ "query": unrelated }))
762 .await
763 .unwrap();
764 out["context"].as_str().unwrap().to_string()
765 };
766
767 let as_preference = recall_with(NoteKind::Preference).await;
768 assert!(
769 as_preference.contains("Active Constraints") && as_preference.contains(rule),
770 "a synced preference must land in the always-included constraints \
771 layer, exactly as it does on the device that set it: {as_preference}"
772 );
773
774 let as_fact = recall_with(NoteKind::Fact).await;
779 assert!(
780 !as_fact.contains("Active Constraints"),
781 "control: a plain fact is not a standing rule — if it reaches the \
782 constraints layer the test cannot tell the two kinds apart: {as_fact}"
783 );
784 }
785
786 #[tokio::test]
787 async fn remember_mirrors_the_kind_so_a_preference_crosses_the_wire() {
788 let dir = tempfile::tempdir().unwrap();
789 let rec = Arc::new(RecordingSync(Mutex::new(Vec::new())));
790 let mem = MemoryTools::open(dir.path().join("m.json")).with_sync(Some(rec.clone()));
791 mem.execute(
792 "remember",
793 &json!({
794 "subject": "code review",
795 "body": "Never merge without a green CI run.",
796 "kind": "preference"
797 }),
798 )
799 .await
800 .unwrap();
801 assert_eq!(
802 rec.0.lock().unwrap().clone(),
803 vec![peer_fact(
804 "code review",
805 "Never merge without a green CI run.",
806 NoteKind::Preference
807 )],
808 "the mirror must send the kind `remember` validated, not a default"
809 );
810 }
811
812 #[tokio::test]
813 async fn recall_keeps_local_over_a_peer_edit_of_the_same_subject() {
814 let dir = tempfile::tempdir().unwrap();
819 let peer = Arc::new(PeerSync(vec![peer_fact("editor", "emacs", NoteKind::Fact)]));
820 let mem = MemoryTools::open(dir.path().join("m.json")).with_sync(Some(peer));
821 mem.execute("remember", &json!({ "subject": "editor", "body": "vim" }))
822 .await
823 .unwrap();
824 let out = mem
825 .execute("recall", &json!({ "query": "which editor?" }))
826 .await
827 .unwrap();
828 let ctx = out["context"].as_str().unwrap().to_string();
829 assert!(ctx.contains("vim"), "{ctx}");
830 assert!(
831 !ctx.contains("emacs"),
832 "peer edit must not override local under Option B: {ctx}"
833 );
834 }
835
836 #[tokio::test]
837 async fn recall_peer_gate_is_case_insensitive() {
838 let dir = tempfile::tempdir().unwrap();
840 let peer = Arc::new(PeerSync(vec![peer_fact(
841 "pet",
842 "a peer dog",
843 NoteKind::Fact,
844 )]));
845 let mem = MemoryTools::open(dir.path().join("m.json")).with_sync(Some(peer));
846 mem.execute(
847 "remember",
848 &json!({ "subject": "Pet", "body": "my cat Mittens" }),
849 )
850 .await
851 .unwrap();
852 let out = mem
853 .execute("recall", &json!({ "query": "what pet?" }))
854 .await
855 .unwrap();
856 let ctx = out["context"].as_str().unwrap().to_string();
857 assert!(ctx.contains("Mittens"), "{ctx}");
858 assert!(
859 !ctx.contains("peer dog"),
860 "a case-variant peer subject must be gated: {ctx}"
861 );
862 }
863
864 #[tokio::test]
865 async fn recall_pull_failure_degrades_to_local() {
866 let dir = tempfile::tempdir().unwrap();
868 let mem =
869 MemoryTools::open(dir.path().join("m.json")).with_sync(Some(Arc::new(FailingSync)));
870 mem.execute("remember", &json!({ "subject": "city", "body": "Denver" }))
871 .await
872 .unwrap();
873 let out = mem
874 .execute("recall", &json!({ "query": "which city?" }))
875 .await
876 .unwrap();
877 assert!(out["context"].as_str().unwrap().contains("Denver"));
878 }
879
880 #[tokio::test]
881 async fn remember_survives_sync_failure() {
882 let dir = tempfile::tempdir().unwrap();
884 let path = dir.path().join("m.json");
885 let mem = MemoryTools::open(path.clone()).with_sync(Some(Arc::new(FailingSync)));
886 let out = mem
887 .execute("remember", &json!({ "subject": "a", "body": "b" }))
888 .await
889 .unwrap();
890 assert_eq!(out["remembered"], "a");
891 let notes: Vec<Note> =
893 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
894 assert_eq!(notes.len(), 1);
895 assert_eq!(notes[0].body, "b");
896 }
897
898 #[test]
904 fn synced_knowledge_survives_edits_and_converges_to_newest_body() {
905 use car_sync::fold::fold;
906 use car_sync::oplog::{DeviceLog, Scope, Surface};
907
908 let mut log = DeviceLog::new("device-a");
909 let vim = log.append(
910 Scope::Personal,
911 Surface::Knowledge,
912 json!({ "subject": "editor", "body": "vim" }),
913 );
914 let emacs = log.append(
915 Scope::Personal,
916 Surface::Knowledge,
917 json!({ "subject": "editor", "body": "emacs" }),
918 );
919
920 let state = fold(&[vim, emacs]);
921 let entries = state.log_entries("knowledge");
922 assert_eq!(entries.len(), 2);
924 let newest = entries
926 .iter()
927 .filter(|r| r.payload["subject"] == "editor")
928 .max_by(|a, b| a.hlc.cmp(&b.hlc))
929 .unwrap();
930 assert_eq!(newest.payload["body"], "emacs");
931 }
932
933 #[test]
934 fn identical_reremember_dedups_in_the_synced_fold() {
935 use car_sync::fold::fold;
936 use car_sync::oplog::{DeviceLog, Scope, Surface};
937
938 let mut log = DeviceLog::new("device-a");
939 let a = log.append(
940 Scope::Personal,
941 Surface::Knowledge,
942 json!({ "subject": "editor", "body": "vim" }),
943 );
944 let b = log.append(
945 Scope::Personal,
946 Surface::Knowledge,
947 json!({ "subject": "editor", "body": "vim" }),
948 );
949 let state = fold(&[a, b]);
951 assert_eq!(state.log_entries("knowledge").len(), 1);
952 }
953}