Skip to main content

car_server_core/assistant/
memory.rs

1//! Durable memory for the assistant: `remember` and `recall`.
2//!
3//! Backed by CAR's own graph memory ([`car_memgine::MemgineEngine`]) rather than
4//! a bespoke store — this is the capability the assistant exists to showcase.
5//! Remembered facts are persisted to `~/.car/memory/assistant.json` and
6//! re-ingested on startup, so the assistant remembers across sessions and
7//! process restarts.
8//!
9//! Recall runs the memgine's **full** retrieval path (`build_context`), not the
10//! Fast one. Fast mode skips exactly the parts that make this a graph rather
11//! than a list: skill lookup, PPR-based fact scoring, inline repair of stale
12//! dependents, and known-unknowns extraction. The assistant was on Fast, so the
13//! flagship agent advertised graph memory while doing keyword recall over flat
14//! notes — the differentiator was bypassed on the one path users actually hit.
15//!
16//! Full is affordable here specifically because this engine is built with
17//! `MemgineEngine::new(None)` and never has inference attached: the one
18//! genuinely expensive step Fast avoids is the embedding flush, and that is
19//! guarded by `self.inference.is_some()`, so it no-ops either way. What Full
20//! adds is in-memory graph work — no I/O, no model call. If inference is ever
21//! wired to this engine, re-measure before assuming that still holds.
22
23use 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
34/// The on-disk note format and the memory taxonomy now live in
35/// [`car_memgine::note_store`], so the MCP server can read and write the SAME
36/// store this assistant does. Re-exported here because `NoteKind` is part of
37/// this module's published surface (`car_server_core::assistant::NoteKind`)
38/// and callers should not have to care that it moved.
39pub use car_memgine::note_store::{ingest, Note, NoteKind};
40
41/// One fact as it crosses the sync wire.
42///
43/// `kind` rides along because it is not decoration — a `Preference` is saved as
44/// a memgine *constraint* and therefore lands in the always-included Active
45/// Constraints layer, while a `Fact` surfaces only when a recall query matches
46/// it. Reconstructing every peer-synced note as a plain fact (which is what
47/// happened before car#665) silently downgraded a standing rule to something
48/// the assistant would only sometimes see, on the second device and not the
49/// first, with nothing anywhere reporting an error.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct SyncedFact {
52    pub subject: String,
53    pub body: String,
54    pub kind: NoteKind,
55}
56
57/// A sink that mirrors remembered facts into CAR's synced oplog
58/// (`Surface::Knowledge`) so the assistant's memory can converge across a user's
59/// devices. Supplied ONLY in the daemon-attached `car do --serve` process, which
60/// reaches the daemon's single oplog owner over WS; `None` for one-shot `car do`,
61/// so the standalone CLI never becomes a second writer to the lock-guarded oplog.
62/// The write is best-effort: a sync failure must never fail the local remember.
63#[async_trait]
64pub trait MemorySync: Send + Sync {
65    /// Append a remembered fact to the knowledge oplog. The fact is
66    /// content-addressed by its `{subject, body, kind}` payload (no explicit id),
67    /// so on the grow-only Knowledge tier each distinct value is its own
68    /// immutable entity: an edit is a NEW op (both survive the fold), and an
69    /// identical re-remember dedups. The read side ([`Self::pull_knowledge`])
70    /// converges a subject to its newest-by-HLC body. `subject` is normalized
71    /// (trimmed) to match the local store's entity boundary.
72    ///
73    /// **`kind` is part of the content address, and that is a deliberate
74    /// migration decision (car#665).** Adding it changes the address of a fact
75    /// that was already synced under the old `{subject, body}` payload, so such
76    /// a fact re-remembered after the upgrade folds as a *second* entity rather
77    /// than deduping against the first. That is harmless here because
78    /// [`Self::pull_knowledge`] reduces newest-per-subject before anything sees
79    /// the facts: both entities share a subject, the kinded one carries the
80    /// later HLC, and the reduce keeps it. So the duplicate never reaches a
81    /// reader — it only costs one extra op on a tier that is grow-only by
82    /// design (an edit already appends). The alternative — inferring a kind on
83    /// the receiving side from a locally-known preference with the same subject
84    /// — would leave the wire lossy and only work when the same rule had been
85    /// set on both devices, which is exactly the case that needs no fixing.
86    async fn append_knowledge(
87        &self,
88        subject: &str,
89        body: &str,
90        kind: NoteKind,
91    ) -> Result<(), String>;
92
93    /// Pull peer-synced knowledge facts — the read side — already reduced to
94    /// newest-per-subject. Best-effort: an `Err` means "no peer facts available
95    /// right now", never a failure; `recall` degrades to local memory. The impl
96    /// pumps the oplog then reads the folded Knowledge facts, reducing
97    /// newest-per-subject by LAST-in-ascending-hlc order.
98    async fn pull_knowledge(&self) -> Result<Vec<SyncedFact>, String>;
99}
100
101/// `remember` / `recall`, backed by a persistent memgine graph.
102pub struct MemoryTools {
103    inner: Mutex<Inner>,
104    path: PathBuf,
105    /// Optional mirror into the synced knowledge oplog (daemon serve only).
106    sync: Option<Arc<dyn MemorySync>>,
107}
108
109struct Inner {
110    engine: MemgineEngine,
111    notes: Vec<Note>,
112}
113
114impl MemoryTools {
115    /// Open (or create) the assistant's memory at `path`, re-ingesting any
116    /// previously remembered facts so recall works immediately.
117    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    /// Attach a synced-oplog mirror so remembered facts converge across the
127    /// user's devices. Set only on the daemon-attached serve path; leaving it
128    /// unset keeps `remember` a purely local write (one-shot `car do`).
129    pub fn with_sync(mut self, sync: Option<Arc<dyn MemorySync>>) -> Self {
130        self.sync = sync;
131        self
132    }
133
134    /// The two model-facing tool schemas.
135    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    /// Returns the tool result plus the validated [`NoteKind`], so the caller's
181    /// sync mirror sends the kind this call actually stored rather than
182    /// re-parsing the raw params and risking a second, divergent answer.
183    fn remember(&self, params: &Value) -> Result<(Value, NoteKind), String> {
184        // Trim the subject up front so the local entity boundary matches the
185        // synced one (the mirror sends the same normalized subject).
186        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        // Supersede an existing fact with the same subject (case-insensitive) so
205        // an updated fact replaces the stale one instead of accumulating. A
206        // re-remember may also RECLASSIFY (a fact promoted to a preference), so
207        // the kind is superseded with the body.
208        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        // Re-ingest the whole set so the memgine graph mirrors the current notes
220        // (cheap for a personal store; correctness over cleverness).
221        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        // Persist the whole set (small; correctness over cleverness).
227        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    /// Best-effort mirror of a just-remembered fact into the synced knowledge
235    /// oplog. Only active when a [`MemorySync`] is attached (daemon serve). A
236    /// sync failure is logged and swallowed — the fact is already saved locally,
237    /// and convergence is best-effort, so it must never fail the tool call.
238    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        // Same normalized subject as the local store; the fact is content-addressed
249        // by {subject, body, kind} on the sync side (no explicit id). The kind is
250        // the one `remember` already validated, not a re-parse of the raw params.
251        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    /// Best-effort pull of peer-synced facts. Returns empty on no sink or any
263    /// error — recall must never fail because sync is unavailable.
264    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        // Best-effort peer pull FIRST — no lock held across the await. Peers are
286        // already reduced to newest-per-subject by the sink.
287        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        // Option B: local notes are authoritative for the subjects THIS device
293        // holds; peer facts fill only the gaps. A peer's newer edit to a locally
294        // held subject is intentionally NOT applied — local has no HLC to compare
295        // against, so this is conservative local authority, not cross-device
296        // newest-wins. Same case-insensitive trimmed key as the local supersede.
297        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            // Nothing new from peers — the maintained local engine has the answer.
305            let context = g.engine.build_context(query);
306            return Ok(json!({ "query": query, "context": context }));
307        }
308        // Build a fresh view = local notes + peer-gap facts for this recall (the
309        // peer facts live in the oplog and are re-pulled each recall, never
310        // written into the local notes file).
311        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                    // The peer's own kind, carried over the wire (car#665). A
325                    // preference must stay a preference here: reconstructing it
326                    // as a plain fact demoted the constraint to something
327                    // recall-only, so a standing rule quietly stopped applying
328                    // on this device and kept applying on the one that set it.
329                    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    /// Run the paper-style proactive memory pass for the assistant loop.
339    ///
340    /// This is deliberately host-side and deterministic: update compact memory
341    /// from the runtime trajectory, then decide whether one remembered fact is
342    /// strong enough to interrupt the next model turn. The model still has the
343    /// explicit `recall` tool, but it no longer has to remember to call it before
344    /// CAR can surface high-value memory.
345    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                    // The peer's own kind, carried over the wire (car#665). A
400                    // preference must stay a preference here: reconstructing it
401                    // as a plain fact demoted the constraint to something
402                    // recall-only, so a standing rule quietly stopped applying
403                    // on this device and kept applying on the one that set it.
404                    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
417/// The shared subject normalization for the local store and the synced mirror,
418/// so both draw the fact's entity boundary at the same place (trimmed).
419fn normalize_subject(subject: &str) -> &str {
420    subject.trim()
421}
422
423/// The case-insensitive, trimmed key for a subject — the same entity boundary
424/// the local supersede uses (`normalize_subject` + case-insensitive match). The
425/// peer-merge gate and the sink's newest-per-subject reduce must use this exact
426/// key, or `Pet`/`pet` split into two entities.
427fn 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                // Mirror into the synced oplog after the local write succeeds.
438                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        // Not required: an unspecified kind is a plain fact.
473        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        // A query unrelated to either fact: the preference still surfaces (it is
499        // a constraint), which is the behavioral difference the enum encodes.
500        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        // Re-open (simulating a new process/session) and recall.
541        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        // Same subject (case-insensitive) → replaced, not accumulated.
566        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    /// Recall must run the FULL memgine path, not Fast.
585    ///
586    /// The observable difference on the assistant's real data shape is **fact
587    /// ordering**. The four-layer model is relevance-*ascending* — most relevant
588    /// last, so it sits closest to the model's recency attention — and that
589    /// ranking is PPR-scored, which Fast skips entirely. On Fast the assistant
590    /// emitted facts in creation order: recall was not relevance-ranked at all,
591    /// while the docs advertised spreading activation.
592    ///
593    /// So this asserts the matching fact lands LAST despite being created FIRST.
594    /// Under Fast it comes back first and this fails, which is the regression
595    /// worth catching: switching one call back to `build_context_fast` would
596    /// silently downgrade the flagship agent to keyword recall over flat notes.
597    ///
598    /// (Known-unknowns extraction, the other Full-only step, reads *conversation*
599    /// nodes; assistant notes ingest as facts, so it does not fire here.)
600    #[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        // The match is remembered FIRST, so creation order and relevance order
606        // disagree — otherwise the assertion would pass either way.
607        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    /// A sink that returns a fixed set of peer facts on pull (append is a no-op).
658    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    /// A peer fact, for the `PeerSync` fixtures.
670    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        // Untrimmed subject → the mirror sees the same normalized subject the
684        // local store uses, so both draw the entity boundary in the same place.
685        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        // One-shot `car do`: no sink attached, remember still works.
698        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        // No local notes; the fact exists only via a peer's sync.
721        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    /// car#665. A `preference` is saved as a memgine *constraint*, which context
740    /// assembly includes in every session regardless of the query; a `fact`
741    /// surfaces only when the query matches it. Reconstructing every peer-synced
742    /// note as `NoteKind::default()` collapsed that distinction, so a standing
743    /// rule set on device A quietly stopped being a standing rule on device B —
744    /// with no error anywhere.
745    ///
746    /// The assertion is on the *layer* the note lands in, mirroring the local
747    /// `preference_becomes_an_always_included_constraint`. Mere presence in the
748    /// context does not discriminate: with a small store `build_context`
749    /// lists an unmatched fact under Current Facts anyway. Active Constraints is
750    /// what makes a rule unconditional, so that is what must be asserted.
751    #[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        // The control: the same note synced as a plain fact — which is what the
775        // receiving device used to reconstruct for *every* peer note — must not
776        // reach that layer. Without this the assertion above would also pass if
777        // the layer heading were always emitted, proving nothing.
778        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        // Local set editor=vim; a peer later set editor=emacs. Option B: local is
815        // authoritative for its own subjects, so the peer edit is SUPPRESSED
816        // (conservative local authority, not cross-device newest-wins). Pinned so
817        // nobody "fixes" it by accident.
818        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        // "Pet" (local) and "pet" (peer) are the same entity — local wins.
839        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        // A pull error must never fail recall — it answers from local memory.
867        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        // A sync hiccup must never fail the local remember — the fact is saved.
883        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        // And it persisted locally despite the sync error.
892        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    // Fold two same-subject facts through the REAL car-sync fold to prove the
899    // write format converges correctly on the grow-only Knowledge tier. The
900    // earlier stable-per-subject key collapsed these to one op and kept the
901    // EARLIEST (dropping the update); content-addressing keeps both, and the
902    // read model (newest-by-HLC per subject) recovers the edit.
903    #[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        // Both edits survive as distinct immutable entities (no update dropped).
923        assert_eq!(entries.len(), 2);
924        // The read model converges a subject to its newest-by-HLC body.
925        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        // Same {subject, body} → same content-addressed key → one entity, no spew.
950        let state = fold(&[a, b]);
951        assert_eq!(state.log_entries("knowledge").len(), 1);
952    }
953}