Skip to main content

car_memgine/
note_store.rs

1//! The on-disk note store behind CAR's durable memory.
2//!
3//! One JSON file — `<CAR_HOME>/memory/assistant.json` by default — holding a
4//! flat `Vec<Note>`, re-ingested into a fresh [`MemgineEngine`] on load. This
5//! is what makes "the assistant remembers across restarts" true.
6//!
7//! ## Why this lives in `car-memgine`
8//!
9//! It was private to `car-server-core`'s assistant, which is the only place
10//! that could reach it — and `car-server-core` depends on `car-mcp`, not the
11//! other way round, so the MCP server had no way to read or write the same
12//! store. It therefore ran on a throwaway in-memory graph: facts a host wrote
13//! over MCP vanished when stdin closed, and `memory_query` never saw anything
14//! the user had actually remembered (car#972 §1).
15//!
16//! The fix needs one definition of the format reachable from both. Copying it
17//! into `car-mcp` would have made two writers to one file agree only by
18//! coincidence; here, a change to the format is a compile error in every
19//! caller instead of a silent divergence in what one process reads and another
20//! writes.
21//!
22//! ## Ids are positional, and that is load-bearing
23//!
24//! [`ingest`] derives a node id from the note's **index** in the file, so
25//! reloading the same file reconstructs the same graph — nodes keep their
26//! identity, and edges into them survive a restart. Two consequences worth
27//! knowing before editing:
28//!
29//! - Notes must be appended, never reordered. Reordering silently re-points
30//!   every id after the change.
31//! - A writer that rebuilds the store from scratch must ingest in file order.
32//!
33//! ## Concurrency
34//!
35//! There is no lock. Callers mutate with [`load`] → modify → [`save`], which
36//! narrows but does not close the window where two processes writing at once
37//! lose one side's append. That is acceptable for the current writers (an
38//! interactive assistant and an editor plugin, both driven by one human) and
39//! is NOT acceptable for an automated fleet. Anything that needs stronger
40//! guarantees should go through the daemon, which owns a single engine.
41
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45
46use crate::proactive::ProactiveMemorySave;
47use crate::MemgineEngine;
48
49/// The model-facing memory taxonomy. Each variant routes to genuinely
50/// different downstream behavior in memgine — this is the behavioral hint the
51/// tool schema carries instead of prose in a system prompt telling the model
52/// what is "worth keeping".
53#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum NoteKind {
56    /// Retrieved when relevant to the query (the Facts layer).
57    #[default]
58    Fact,
59    /// A standing instruction. Saved as a memgine *constraint*, so it lands in
60    /// the Active Constraints layer that context assembly includes in every
61    /// session regardless of query.
62    Preference,
63    /// Procedural evidence — what was attempted and whether it worked.
64    Procedure,
65}
66
67impl NoteKind {
68    /// Strict parse for a value a model supplied. An unknown kind is an error
69    /// the model can see and correct.
70    pub fn parse(raw: Option<&str>) -> Result<Self, String> {
71        match raw.map(str::trim) {
72            None | Some("") | Some("fact") => Ok(Self::Fact),
73            Some("preference") => Ok(Self::Preference),
74            Some("procedure") => Ok(Self::Procedure),
75            Some(other) => Err(format!(
76                "unknown kind '{other}' (expected fact, preference, or procedure)"
77            )),
78        }
79    }
80
81    /// The wire spelling, matching the serde `rename_all = "lowercase"` and
82    /// the vocabulary [`Self::parse`] accepts.
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Self::Fact => "fact",
86            Self::Preference => "preference",
87            Self::Procedure => "procedure",
88        }
89    }
90
91    /// Lenient read-side parse for a value that came off the sync wire.
92    ///
93    /// Unlike [`Self::parse`], an unrecognized kind is NOT an error: it means
94    /// a peer running a newer build used a variant this device does not know
95    /// about, and degrading that to a plain fact is strictly better than
96    /// dropping the fact or failing the recall.
97    pub fn from_wire(raw: Option<&str>) -> Self {
98        Self::parse(raw).unwrap_or_default()
99    }
100
101    /// Map the `constraint`/`pattern` vocabulary the MCP tool schema and the
102    /// `memory.persist` wire format use onto this one.
103    ///
104    /// Those surfaces predate `NoteKind` and speak a different dialect: they
105    /// carry a free-form `kind` where only the exact string `"constraint"`
106    /// means anything, and everything else (including the documented default
107    /// `"pattern"`) is a plain fact. Both dialects now write the SAME file, so
108    /// the translation has to live somewhere explicit rather than being
109    /// re-derived — a `"constraint"` that round-trips as a `Fact` silently
110    /// demotes a standing rule to something recall only sometimes surfaces.
111    pub fn from_constraint_dialect(raw: Option<&str>) -> Self {
112        match raw.map(str::trim) {
113            Some("constraint") => Self::Preference,
114            Some("procedure") => Self::Procedure,
115            _ => Self::Fact,
116        }
117    }
118
119    /// Whether this note is a standing rule, and therefore a memgine
120    /// constraint rather than a query-matched fact.
121    pub fn is_constraint(self) -> bool {
122        self == Self::Preference
123    }
124}
125
126/// One remembered note, as stored on disk.
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct Note {
129    pub subject: String,
130    pub body: String,
131    /// How the fact is used at recall time. `#[serde(default)]` so notes
132    /// written before the field existed load as plain facts rather than
133    /// failing the whole store's deserialization.
134    #[serde(default)]
135    pub kind: NoteKind,
136}
137
138/// Default durable-memory path: `memory/assistant.json` under the CAR state
139/// root — `CAR_HOME` when set, otherwise `~/.car`, and a relative `.car` when
140/// neither resolves.
141pub fn default_path() -> PathBuf {
142    car_home::root_or_relative()
143        .join("memory")
144        .join("assistant.json")
145}
146
147/// Read the store, or an empty set if it is missing or unreadable.
148///
149/// Deliberately lenient. A missing file is the first run. A corrupt file is a
150/// bad outcome either way, and refusing to start is worse than starting empty
151/// — the alternative is an assistant that cannot run at all because one note
152/// was truncated by a crash mid-write.
153///
154/// The cost of that leniency is real and worth stating: a corrupt store reads
155/// as "no memories", and the next [`save`] overwrites it. Callers that can
156/// afford to should distinguish the two with [`load_checked`].
157pub fn load(path: &Path) -> Vec<Note> {
158    load_checked(path).unwrap_or_default()
159}
160
161/// Read the store, distinguishing "absent" from "present but unreadable".
162///
163/// `Ok(vec![])` means there is no store yet. `Err` means one exists and could
164/// not be parsed — a caller about to overwrite it should say so rather than
165/// silently discarding whatever it held.
166pub fn load_checked(path: &Path) -> Result<Vec<Note>, String> {
167    let raw = match std::fs::read_to_string(path) {
168        Ok(raw) => raw,
169        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
170        Err(e) => return Err(format!("read {}: {e}", path.display())),
171    };
172    serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))
173}
174
175/// Write the store, creating the parent directory if needed.
176///
177/// Writes the whole set rather than appending: the file is small, and
178/// correctness beats cleverness on the store that holds the user's memory.
179pub fn save(path: &Path, notes: &[Note]) -> Result<(), String> {
180    if let Some(parent) = path.parent() {
181        std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
182    }
183    let serialized = serde_json::to_string_pretty(notes).map_err(|e| format!("serialize: {e}"))?;
184    std::fs::write(path, serialized).map_err(|e| format!("write {}: {e}", path.display()))
185}
186
187/// Ingest one note as a memgine fact.
188///
189/// The id is derived from `idx` so a reload re-creates the same graph — see
190/// the module docs on why notes must be appended rather than reordered.
191pub fn ingest(engine: &mut MemgineEngine, idx: usize, note: &Note) {
192    let save = ProactiveMemorySave {
193        id: Some(format!("assistant-note-{idx}")),
194        subject: note.subject.clone(),
195        body: note.body.clone(),
196        tags: vec!["assistant_memory".to_string()],
197        confidence: Some("high".to_string()),
198        tenant_id: None,
199        // A preference is a standing rule: mark it a constraint so context
200        // assembly surfaces it in the always-included Active Constraints layer
201        // rather than only when the query happens to match.
202        is_constraint: note.kind.is_constraint(),
203    };
204    match note.kind {
205        NoteKind::Procedure => engine.save_proactive_procedural(save),
206        NoteKind::Fact | NoteKind::Preference => engine.save_proactive_knowledge(save),
207    };
208}
209
210/// Ingest a whole store, in file order.
211pub fn ingest_all(engine: &mut MemgineEngine, notes: &[Note]) {
212    for (idx, note) in notes.iter().enumerate() {
213        ingest(engine, idx, note);
214    }
215}
216
217/// Build a fresh engine holding exactly the notes at `path`.
218pub fn engine_from(path: &Path) -> (MemgineEngine, Vec<Note>) {
219    let notes = load(path);
220    let mut engine = MemgineEngine::new(None);
221    ingest_all(&mut engine, &notes);
222    (engine, notes)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn note(subject: &str, kind: NoteKind) -> Note {
230        Note {
231            subject: subject.into(),
232            body: "body".into(),
233            kind,
234        }
235    }
236
237    #[test]
238    fn a_missing_store_is_the_first_run_not_an_error() {
239        let dir = tempfile::tempdir().unwrap();
240        let p = dir.path().join("nope.json");
241        assert_eq!(load_checked(&p), Ok(Vec::new()));
242        assert!(load(&p).is_empty());
243    }
244
245    #[test]
246    fn a_corrupt_store_is_distinguishable_from_an_empty_one() {
247        // `load` degrades to empty so the assistant can still start; the whole
248        // point of `load_checked` is that a caller about to overwrite the file
249        // can tell it held something first.
250        let dir = tempfile::tempdir().unwrap();
251        let p = dir.path().join("bad.json");
252        std::fs::write(&p, "{ not json").unwrap();
253        assert!(load(&p).is_empty());
254        assert!(load_checked(&p).is_err());
255    }
256
257    #[test]
258    fn notes_round_trip_through_the_file() {
259        let dir = tempfile::tempdir().unwrap();
260        let p = dir.path().join("memory.json");
261        let notes = vec![
262            note("pet", NoteKind::Fact),
263            note("always use tabs", NoteKind::Preference),
264            note("deploy runbook", NoteKind::Procedure),
265        ];
266        save(&p, &notes).unwrap();
267        assert_eq!(load(&p), notes);
268    }
269
270    #[test]
271    fn save_creates_the_parent_directory() {
272        let dir = tempfile::tempdir().unwrap();
273        let p = dir.path().join("nested").join("deeper").join("memory.json");
274        save(&p, &[note("x", NoteKind::Fact)]).unwrap();
275        assert_eq!(load(&p).len(), 1);
276    }
277
278    #[test]
279    fn a_note_written_before_kind_existed_loads_as_a_fact() {
280        let dir = tempfile::tempdir().unwrap();
281        let p = dir.path().join("legacy.json");
282        std::fs::write(&p, r#"[{"subject":"s","body":"b"}]"#).unwrap();
283        assert_eq!(load(&p)[0].kind, NoteKind::Fact);
284    }
285
286    #[test]
287    fn the_constraint_dialect_maps_onto_the_note_taxonomy() {
288        // The bug this prevents: an MCP client writes kind "constraint", it
289        // round-trips as a plain Fact, and a standing rule silently stops
290        // appearing in the always-included constraints layer.
291        assert_eq!(
292            NoteKind::from_constraint_dialect(Some("constraint")),
293            NoteKind::Preference
294        );
295        assert!(NoteKind::from_constraint_dialect(Some("constraint")).is_constraint());
296        // "pattern" is the documented default of that dialect, and is a fact.
297        assert_eq!(
298            NoteKind::from_constraint_dialect(Some("pattern")),
299            NoteKind::Fact
300        );
301        assert_eq!(NoteKind::from_constraint_dialect(None), NoteKind::Fact);
302        assert_eq!(
303            NoteKind::from_constraint_dialect(Some("procedure")),
304            NoteKind::Procedure
305        );
306    }
307
308    #[test]
309    fn strict_parse_rejects_what_lenient_parse_degrades() {
310        assert!(NoteKind::parse(Some("nonsense")).is_err());
311        assert_eq!(NoteKind::from_wire(Some("nonsense")), NoteKind::Fact);
312    }
313
314    #[test]
315    fn reloading_a_store_reconstructs_the_same_node_ids() {
316        // Positional ids are what let edges into a remembered fact survive a
317        // restart. If this breaks, memory still "works" and the graph quietly
318        // stops being a graph.
319        let notes = vec![note("a", NoteKind::Fact), note("b", NoteKind::Preference)];
320        let ids = |notes: &[Note]| {
321            let mut e = MemgineEngine::new(None);
322            ingest_all(&mut e, notes);
323            let mut got: Vec<String> = e
324                .graph
325                .inner
326                .node_indices()
327                .filter_map(|i| e.graph.inner.node_weight(i))
328                .filter_map(|n| n.fact_id.clone())
329                .collect();
330            got.sort();
331            got
332        };
333        let first = ids(&notes);
334        assert!(!first.is_empty(), "ingest produced no fact nodes");
335        assert_eq!(first, ids(&notes));
336    }
337}