Skip to main content

mati_core/analysis/
auto_memory.rs

1//! Auto-memory import — parse Claude Code's per-project memory directory
2//! into dev notes (F10).
3//!
4//! Claude Code writes free-form notes to
5//! `~/.claude/projects/<project-slug>/memory/`: an index file `MEMORY.md`
6//! plus one `.md` file per memory, each with `---`-delimited frontmatter and
7//! a markdown body. That channel is model-authored and project-level, not
8//! file-scoped — this module turns each memory file into a `dev_note:*`
9//! record, queryable via `mem_get`/`mem_query` but never injected into
10//! bootstrap and never gated behind `mati review`. It never writes back to
11//! the source directory.
12//!
13//! Earlier versions of this importer (F10, `cacc778`) wrote
14//! `gotcha:auto-memory-*` candidates instead. That was the wrong record
15//! type: unconfirmed gotchas sit in the `mati review` queue, and
16//! confirming one makes it eligible for global bootstrap injection with no
17//! `affected_files` gate, which risked crowding out real gotchas under the
18//! bootstrap token budget. `import_auto_memory` now writes `dev_note:*`
19//! only; see [`crate::store::gotcha_ops::apply_gotcha_tombstone`] callers in
20//! `cli::show::export` for the one-time cleanup of stores that already ran
21//! the old importer.
22//!
23//! The frontmatter shape below was read directly off this project's own
24//! memory directory, not off any spec:
25//!
26//! ```text
27//! ---
28//! name: project-store-daemon-single-owner
29//! description: "Store/daemon single-owner invariant: defect found ..."
30//! metadata:
31//!   node_type: memory
32//!   type: project
33//!   originSessionId: 95126fa6-3d8c-49e1-90de-ca652782c6d1
34//!   modified: 2026-08-02T03:33:27.084Z
35//! ---
36//!
37//! body markdown...
38//! ```
39//!
40//! `modified` and `originSessionId` are sometimes absent (older memories
41//! predate those fields). Frontmatter is parsed by hand instead of pulling
42//! in a YAML crate — the shape is a handful of flat `key: value` lines plus
43//! one nested `metadata:` block, and the format is unversioned upstream, so
44//! unrecognized keys are ignored rather than rejected. A file this parser
45//! can't make sense of is skipped, not dropped silently — see
46//! [`AutoMemoryImport::skipped_files`].
47
48use std::path::{Path, PathBuf};
49use std::time::{SystemTime, UNIX_EPOCH};
50
51use anyhow::Result;
52use slugify::slugify;
53
54use crate::store::record::{
55    Category, ConfidenceScore, DeviceId, Priority, QualityScore, Record, RecordLifecycle,
56    RecordSource, RecordVersion, StalenessScore,
57};
58
59/// Result of importing a project's auto-memory directory.
60pub struct AutoMemoryImport {
61    /// One `dev_note:auto-memory-*` record per memory file that parsed
62    /// cleanly.
63    pub records: Vec<Record>,
64    /// Files that could not be read or carried no usable content, paired
65    /// with a human-readable reason. Never aborts the run.
66    pub skipped_files: Vec<(PathBuf, String)>,
67}
68
69/// Locate `~/.claude/projects/<project-slug>/memory/` for a project root.
70///
71/// The slug is Claude Code's own directory naming: the project's absolute
72/// path with every `/` replaced by `-` (verified against this project's own
73/// `~/.claude/projects/-Users-...-mati/` directory). `project_root` is
74/// expected to already be absolute (callers pass `std::env::current_dir()`);
75/// this does not canonicalize or resolve symlinks, so a path Claude Code saw
76/// through a different symlink alias would miss.
77pub fn auto_memory_dir(project_root: &Path) -> Result<PathBuf> {
78    let slug = project_root.to_string_lossy().replace('/', "-");
79    let home = dirs::home_dir()
80        .ok_or_else(|| anyhow::anyhow!("cannot determine home directory (HOME not set)"))?;
81    Ok(home
82        .join(".claude")
83        .join("projects")
84        .join(slug)
85        .join("memory"))
86}
87
88/// Parse every memory file in `dir` into a dev-note record.
89///
90/// Returns an empty result (not an error) if `dir` doesn't exist — no
91/// auto-memory directory is a normal state, not a failure. `MEMORY.md`
92/// itself is the index and is skipped, not parsed as a memory.
93pub fn import_auto_memory(
94    dir: &Path,
95    device_id: DeviceId,
96    logical_clock_start: u64,
97) -> Result<AutoMemoryImport> {
98    let entries = match std::fs::read_dir(dir) {
99        Ok(e) => e,
100        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
101            return Ok(AutoMemoryImport {
102                records: vec![],
103                skipped_files: vec![],
104            });
105        }
106        Err(e) => return Err(e.into()),
107    };
108
109    let mut paths: Vec<PathBuf> = entries
110        .filter_map(|e| e.ok())
111        .map(|e| e.path())
112        .filter(|p| {
113            p.extension().and_then(|e| e.to_str()) == Some("md")
114                && p.file_name().and_then(|n| n.to_str()) != Some("MEMORY.md")
115        })
116        .collect();
117    // Deterministic order: directory iteration order is unspecified, and
118    // stable output makes a re-run diffable and this module's tests reliable.
119    paths.sort();
120
121    let now = SystemTime::now()
122        .duration_since(UNIX_EPOCH)
123        .unwrap_or_default()
124        .as_secs();
125
126    let mut records = Vec::with_capacity(paths.len());
127    let mut skipped_files = Vec::new();
128    let mut clock = logical_clock_start;
129
130    for path in &paths {
131        let content = match std::fs::read_to_string(path) {
132            Ok(c) => c,
133            Err(e) => {
134                skipped_files.push((path.clone(), e.to_string()));
135                continue;
136            }
137        };
138        match parse_memory_file(&content) {
139            Some(parsed) => {
140                records.push(memory_to_record(&parsed, path, device_id, clock, now));
141                clock += 1;
142            }
143            None => skipped_files.push((
144                path.clone(),
145                "no frontmatter description and no body content".to_string(),
146            )),
147        }
148    }
149
150    Ok(AutoMemoryImport {
151        records,
152        skipped_files,
153    })
154}
155
156// ── Frontmatter parsing ──────────────────────────────────────────────────────
157
158struct ParsedMemory {
159    name: Option<String>,
160    description: Option<String>,
161    memo_type: Option<String>,
162    body: String,
163}
164
165/// Parse one memory file's frontmatter + body.
166///
167/// Returns `None` when there is nothing worth writing: no description and
168/// an empty body. Missing or malformed frontmatter degrades to "no name, no
169/// description" rather than failing outright — the body still carries the
170/// note.
171fn parse_memory_file(content: &str) -> Option<ParsedMemory> {
172    let (frontmatter, body) = split_frontmatter(content);
173    let body = body.trim().to_string();
174
175    let mut name = None;
176    let mut description = None;
177    let mut memo_type = None;
178
179    if let Some(fm) = frontmatter {
180        for line in fm.lines() {
181            if line.starts_with(char::is_whitespace) {
182                // Nested block (currently only `metadata:`). The only
183                // sub-key this importer reads is `type` — the rest
184                // (node_type, originSessionId, modified) don't map to
185                // anything a gotcha candidate carries.
186                if let Some((key, val)) = split_yaml_kv(line.trim_start()) {
187                    if key == "type" {
188                        memo_type = Some(val);
189                    }
190                }
191                continue;
192            }
193            if let Some((key, val)) = split_yaml_kv(line) {
194                match key.as_str() {
195                    "name" => name = Some(val),
196                    "description" => description = Some(val),
197                    _ => {} // unknown top-level key — ignore, don't reject the file
198                }
199            }
200        }
201    }
202
203    if description.as_deref().unwrap_or("").trim().is_empty() && body.is_empty() {
204        return None;
205    }
206
207    Some(ParsedMemory {
208        name,
209        description,
210        memo_type,
211        body,
212    })
213}
214
215/// Split `---`-delimited frontmatter from the body.
216///
217/// Returns `(None, content)` unchanged if the file doesn't open with a
218/// `---` line, or if no closing `---` is found (malformed frontmatter) — in
219/// both cases the whole file is treated as body text rather than discarded.
220fn split_frontmatter(content: &str) -> (Option<String>, String) {
221    let mut lines = content.lines();
222    match lines.next() {
223        Some("---") => {}
224        _ => return (None, content.to_string()),
225    }
226
227    let mut fm_lines = Vec::new();
228    let mut closed = false;
229    for line in lines.by_ref() {
230        if line == "---" {
231            closed = true;
232            break;
233        }
234        fm_lines.push(line);
235    }
236
237    if !closed {
238        return (None, content.to_string());
239    }
240
241    let body: Vec<&str> = lines.collect();
242    (Some(fm_lines.join("\n")), body.join("\n"))
243}
244
245/// Split a single frontmatter line into `key`/`value`, unquoting the value
246/// if it's YAML double-quoted. Returns `None` for lines with no value (e.g.
247/// `metadata:`, which just opens a nested block).
248fn split_yaml_kv(line: &str) -> Option<(String, String)> {
249    let (key, rest) = line.split_once(':')?;
250    let key = key.trim();
251    if key.is_empty() {
252        return None;
253    }
254    let val = rest.trim();
255    if val.is_empty() {
256        return None;
257    }
258    Some((key.to_string(), unquote_yaml_value(val)))
259}
260
261/// Strip a YAML double-quoted value and unescape `\"`.
262///
263/// Values are single-line in every sample this was built against — a
264/// frontmatter description containing a literal `:` (which would otherwise
265/// break `split_yaml_kv`'s first-colon split) is exactly why the real files
266/// quote it, e.g. `"Store/daemon single-owner invariant: defect found..."`.
267fn unquote_yaml_value(val: &str) -> String {
268    if val.len() >= 2 && val.starts_with('"') && val.ends_with('"') {
269        val[1..val.len() - 1].replace("\\\"", "\"")
270    } else {
271        val.to_string()
272    }
273}
274
275// ── Record construction ──────────────────────────────────────────────────────
276
277fn memory_to_record(
278    parsed: &ParsedMemory,
279    path: &Path,
280    device_id: DeviceId,
281    logical_clock: u64,
282    now: u64,
283) -> Record {
284    let file_stem = path
285        .file_stem()
286        .and_then(|s| s.to_str())
287        .unwrap_or("memory");
288    let base_name = parsed
289        .name
290        .clone()
291        .filter(|n| !n.trim().is_empty())
292        .unwrap_or_else(|| file_stem.to_string());
293    let slug = slugify!(&base_name, max_length = 60);
294    let key = format!("dev_note:auto-memory-{slug}");
295
296    let (rule, reason) = rule_and_reason(parsed);
297
298    let value = if reason.is_empty() {
299        rule
300    } else {
301        format!("{rule} because {reason}")
302    };
303
304    let mut tags = vec!["source:auto-memory".to_string()];
305    if let Some(t) = parsed.memo_type.as_deref().filter(|t| !t.is_empty()) {
306        tags.push(format!("auto-memory:{t}"));
307    }
308
309    let mut record = Record {
310        key,
311        value,
312        category: Category::DevNote,
313        priority: Priority::Normal,
314        tags,
315        created_at: now,
316        updated_at: now,
317        ref_url: None,
318        staleness: StalenessScore::fresh(),
319        lifecycle: RecordLifecycle::Active,
320        version: RecordVersion {
321            device_id,
322            logical_clock,
323            wall_clock: now,
324        },
325        quality: QualityScore::layer0_default(),
326        access_count: 0,
327        last_accessed: 0,
328        source: RecordSource::Import,
329        confidence: ConfidenceScore::for_new_record(&RecordSource::Import),
330        gap_analysis_score: 0.0,
331        payload: None,
332    };
333    record.quality = crate::health::quality::analyze(&record);
334    record
335}
336
337/// Prefer the frontmatter description as the rule (it already reads like a
338/// summary in every sample) with the full body as the reason. Falls back to
339/// the body's first non-empty line when there's no description at all.
340fn rule_and_reason(parsed: &ParsedMemory) -> (String, String) {
341    if let Some(d) = parsed.description.as_ref().filter(|d| !d.trim().is_empty()) {
342        return (d.clone(), parsed.body.clone());
343    }
344
345    let mut lines = parsed.body.lines();
346    let first = lines
347        .find(|l| !l.trim().is_empty())
348        .unwrap_or("")
349        .trim()
350        .to_string();
351    let rest = lines.collect::<Vec<_>>().join("\n").trim().to_string();
352
353    if first.is_empty() {
354        ("untitled auto-memory note".to_string(), rest)
355    } else {
356        (first, rest)
357    }
358}
359
360// ── Tests ─────────────────────────────────────────────────────────────────────
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    const SAMPLE: &str = "\
367---
368name: project-ci-unavailable
369description: \"GitHub Actions minutes are exhausted and won't be renewed — local gates are the only validation\"
370metadata:
371  node_type: memory
372  type: project
373  originSessionId: 1a0cfc6e-b7d8-458a-b26b-fb6c9f69100f
374  modified: 2026-08-07T01:13:09.131Z
375---
376
377As of 2026-08-04 the mati repo's GitHub Actions quota is exhausted.
378
379**Why:** deliberate cost decision.
380";
381
382    // ── split_frontmatter ────────────────────────────────────────────────────
383
384    #[test]
385    fn split_frontmatter_extracts_both_parts() {
386        let (fm, body) = split_frontmatter(SAMPLE);
387        let fm = fm.expect("frontmatter present");
388        assert!(fm.contains("name: project-ci-unavailable"));
389        assert!(fm.contains("  type: project"));
390        assert!(body.trim_start().starts_with("As of 2026-08-04"));
391        assert!(body.contains("**Why:**"));
392    }
393
394    #[test]
395    fn split_frontmatter_missing_delimiter_returns_whole_file_as_body() {
396        let content = "Just a plain note, no frontmatter.\nSecond line.";
397        let (fm, body) = split_frontmatter(content);
398        assert!(fm.is_none());
399        assert_eq!(body, content);
400    }
401
402    #[test]
403    fn split_frontmatter_unclosed_block_returns_whole_file_as_body() {
404        let content = "---\nname: broken\nno closing delimiter here";
405        let (fm, body) = split_frontmatter(content);
406        assert!(fm.is_none());
407        assert_eq!(body, content);
408    }
409
410    // ── split_yaml_kv / unquote_yaml_value ──────────────────────────────────
411
412    #[test]
413    fn split_yaml_kv_unquoted() {
414        let (k, v) = split_yaml_kv("name: project-ci-unavailable").unwrap();
415        assert_eq!(k, "name");
416        assert_eq!(v, "project-ci-unavailable");
417    }
418
419    #[test]
420    fn split_yaml_kv_quoted_with_embedded_colon() {
421        let (k, v) = split_yaml_kv(
422            "description: \"Store/daemon single-owner invariant: defect found 2026-07-23\"",
423        )
424        .unwrap();
425        assert_eq!(k, "description");
426        assert_eq!(
427            v,
428            "Store/daemon single-owner invariant: defect found 2026-07-23"
429        );
430    }
431
432    #[test]
433    fn split_yaml_kv_quoted_with_escaped_quotes() {
434        let (k, v) =
435            split_yaml_kv("description: \"do not \\\"fix\\\" them without asking\"").unwrap();
436        assert_eq!(k, "description");
437        assert_eq!(v, "do not \"fix\" them without asking");
438    }
439
440    #[test]
441    fn split_yaml_kv_nested_block_opener_has_no_value() {
442        assert!(split_yaml_kv("metadata: ").is_none());
443        assert!(split_yaml_kv("metadata:").is_none());
444    }
445
446    // ── parse_memory_file ────────────────────────────────────────────────────
447
448    #[test]
449    fn parse_memory_file_reads_name_description_type_and_body() {
450        let parsed = parse_memory_file(SAMPLE).expect("sample parses");
451        assert_eq!(parsed.name.as_deref(), Some("project-ci-unavailable"));
452        assert_eq!(
453            parsed.description.as_deref(),
454            Some("GitHub Actions minutes are exhausted and won't be renewed — local gates are the only validation")
455        );
456        assert_eq!(parsed.memo_type.as_deref(), Some("project"));
457        assert!(parsed.body.contains("**Why:**"));
458    }
459
460    #[test]
461    fn parse_memory_file_no_frontmatter_still_uses_body() {
462        let parsed = parse_memory_file("Just a plain note with real content.")
463            .expect("body-only content still parses");
464        assert!(parsed.name.is_none());
465        assert!(parsed.description.is_none());
466        assert_eq!(parsed.body, "Just a plain note with real content.");
467    }
468
469    #[test]
470    fn parse_memory_file_empty_everything_is_none() {
471        let content = "---\nname: empty\n---\n\n";
472        assert!(parse_memory_file(content).is_none());
473    }
474
475    #[test]
476    fn parse_memory_file_unknown_frontmatter_keys_are_ignored() {
477        let content = "\
478---
479name: has-extra-field
480description: \"a real description\"
481future_field: something new upstream added
482---
483
484body text here
485";
486        let parsed = parse_memory_file(content).expect("unknown keys don't reject the file");
487        assert_eq!(parsed.description.as_deref(), Some("a real description"));
488    }
489
490    // ── rule_and_reason ──────────────────────────────────────────────────────
491
492    #[test]
493    fn rule_and_reason_prefers_description() {
494        let parsed = ParsedMemory {
495            name: None,
496            description: Some("Do the thing.".to_string()),
497            memo_type: None,
498            body: "Full body text.".to_string(),
499        };
500        let (rule, reason) = rule_and_reason(&parsed);
501        assert_eq!(rule, "Do the thing.");
502        assert_eq!(reason, "Full body text.");
503    }
504
505    #[test]
506    fn rule_and_reason_falls_back_to_first_body_line() {
507        let parsed = ParsedMemory {
508            name: None,
509            description: None,
510            memo_type: None,
511            body: "First line is the rule.\nRest is reason.\nMore reason.".to_string(),
512        };
513        let (rule, reason) = rule_and_reason(&parsed);
514        assert_eq!(rule, "First line is the rule.");
515        assert_eq!(reason, "Rest is reason.\nMore reason.");
516    }
517
518    // ── memory_to_record / import_auto_memory ───────────────────────────────
519
520    #[test]
521    fn memory_to_record_is_a_dev_note_and_tagged() {
522        let parsed = parse_memory_file(SAMPLE).unwrap();
523        let record = memory_to_record(
524            &parsed,
525            Path::new("/home/x/.claude/projects/foo/memory/project_ci_unavailable.md"),
526            uuid::Uuid::nil(),
527            1,
528            1000,
529        );
530        assert_eq!(record.category, Category::DevNote);
531        assert!(record.key.starts_with("dev_note:auto-memory-"));
532        assert!(record.tags.contains(&"source:auto-memory".to_string()));
533        assert!(record.tags.contains(&"auto-memory:project".to_string()));
534        assert!(
535            record.payload.is_none(),
536            "dev notes carry plain text in `value`, no structured payload"
537        );
538        assert!(record.value.contains("GitHub Actions"));
539    }
540
541    #[test]
542    fn import_auto_memory_missing_dir_returns_empty_not_error() {
543        let result = import_auto_memory(Path::new("/nonexistent/memory/dir"), uuid::Uuid::nil(), 0);
544        let import = result.unwrap();
545        assert!(import.records.is_empty());
546        assert!(import.skipped_files.is_empty());
547    }
548
549    #[test]
550    fn import_auto_memory_skips_index_and_malformed_but_keeps_going() {
551        let dir = tempfile::TempDir::new().unwrap();
552        std::fs::write(dir.path().join("MEMORY.md"), "- [Title](x.md) — desc\n").unwrap();
553        std::fs::write(dir.path().join("good.md"), SAMPLE).unwrap();
554        std::fs::write(dir.path().join("empty.md"), "---\nname: empty\n---\n\n").unwrap();
555
556        let import = import_auto_memory(dir.path(), uuid::Uuid::nil(), 0).unwrap();
557        assert_eq!(
558            import.records.len(),
559            1,
560            "MEMORY.md excluded, empty.md skipped"
561        );
562        assert_eq!(import.skipped_files.len(), 1);
563        assert_eq!(import.skipped_files[0].0.file_name().unwrap(), "empty.md");
564    }
565
566    #[test]
567    fn import_auto_memory_unreadable_file_is_skipped_not_fatal() {
568        let dir = tempfile::TempDir::new().unwrap();
569        std::fs::write(dir.path().join("good.md"), SAMPLE).unwrap();
570        // A directory with a .md extension can't be read_to_string'd —
571        // simulates an unreadable/corrupt entry without touching permissions.
572        std::fs::create_dir(dir.path().join("bad.md")).unwrap();
573
574        let import = import_auto_memory(dir.path(), uuid::Uuid::nil(), 0).unwrap();
575        assert_eq!(import.records.len(), 1);
576        assert_eq!(import.skipped_files.len(), 1);
577    }
578
579    #[test]
580    fn auto_memory_dir_replaces_slashes_with_dashes() {
581        // Matches this project's own observed directory:
582        // ~/.claude/projects/-Users-ioni-Documents-Tools-projects-mati-projects-mati/memory/
583        let home = dirs::home_dir().unwrap();
584        let dir = auto_memory_dir(Path::new(
585            "/Users/ioni/Documents/Tools-projects/mati-projects/mati",
586        ))
587        .unwrap();
588        assert_eq!(
589            dir,
590            home.join(".claude")
591                .join("projects")
592                .join("-Users-ioni-Documents-Tools-projects-mati-projects-mati")
593                .join("memory")
594        );
595    }
596}