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, ¬es);
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, ¬es).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(¬es);
334 assert!(!first.is_empty(), "ingest produced no fact nodes");
335 assert_eq!(first, ids(¬es));
336 }
337}