//! `mem capture | resolve | lookup | materialize` //! //! Storage layout under `$MEM_HOME` (default `~/.mem`): //! //! ```text //! events.jsonl authoritative, append-only //! lessons.json projection, rebuilt by `mem resolve` //! skills/-failures/SKILL.md projection, Claude Code convention //! MEMORY.md projection, CLAUDE.md-style @import target //! ``` //! //! Only `events.jsonl` is authoritative. Everything else is regenerable, which //! is the same invariant the full design applies to pgvector. use anyhow::{Context, Result}; use mem_core::lesson::{ derive_lessons, extract, lookup as lookup_lesson, render_injection, render_skill, tool_of_cmd, Confidence, Event, Lesson, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; pub fn mem_home() -> PathBuf { std::env::var("MEM_HOME") .map(PathBuf::from) .unwrap_or_else(|_| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); PathBuf::from(home).join(".mem") }) } fn events_path() -> PathBuf { mem_home().join("events.jsonl") } fn lessons_path() -> PathBuf { mem_home().join("lessons.json") } /// Cap stored output. A 50KB log adds nothing a signature does not already /// carry, and the log is append-only so it never shrinks. const OUTPUT_CAP: usize = 4096; pub fn cmd_capture(cmd: &str, exit: i32, output_file: Option<&Path>, cwd: Option<&str>) -> Result<()> { // Successes are recorded too: without them there is no fail -> success pair // to learn from. let raw = match output_file { Some(p) => fs::read_to_string(p).unwrap_or_default(), None => { use std::io::Read; let mut s = String::new(); let _ = std::io::stdin().read_to_string(&mut s); s } }; let tail: String = if raw.len() > OUTPUT_CAP { raw[raw.len() - OUTPUT_CAP..].to_string() } else { raw }; let cwd = cwd .map(str::to_string) .unwrap_or_else(|| std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_default()); let ev = Event { ts: OffsetDateTime::now_utc().format(&Rfc3339)?, cwd, cmd: cmd.to_string(), exit, output: tail, }; let p = events_path(); fs::create_dir_all(p.parent().unwrap())?; let mut f = fs::OpenOptions::new().create(true).append(true).open(&p)?; writeln!(f, "{}", serde_json::to_string(&ev)?)?; Ok(()) } fn load_events() -> Result> { let p = events_path(); if !p.exists() { return Ok(vec![]); } let s = fs::read_to_string(&p)?; Ok(s.lines() .filter(|l| !l.trim().is_empty()) .filter_map(|l| serde_json::from_str::(l).ok()) .collect()) } pub fn load_lessons() -> Result> { let p = lessons_path(); if !p.exists() { return Ok(vec![]); } Ok(serde_json::from_str(&fs::read_to_string(&p)?)?) } pub fn cmd_resolve(json: bool) -> Result<()> { let events = load_events()?; let mut derived = derive_lessons(&events, tool_of_cmd); // Preserve human confirmation across rebuilds. The projection is // regenerable, but the human's judgement about it is not. let previous = load_lessons().unwrap_or_default(); for l in derived.iter_mut() { if let Some(old) = previous.iter().find(|o| o.sig_sha == l.sig_sha) { if old.confidence == Confidence::Confirmed { l.confidence = Confidence::Confirmed; } } } fs::create_dir_all(mem_home())?; fs::write(lessons_path(), serde_json::to_string_pretty(&derived)?)?; if json { println!("{}", serde_json::to_string_pretty(&derived)?); } else { println!( "{} events -> {} lessons ({} recurring)", events.len(), derived.len(), derived.iter().filter(|l| l.seen >= 3).count() ); for l in &derived { println!(" [{}] seen {}x {}", l.tool, l.seen, l.raw.trim()); } } Ok(()) } /// Default similarity floor. Below this we abstain: an agent acts on the top /// result, so a weak match is worse than nothing. pub const DEFAULT_FLOOR: f32 = 0.55; pub fn cmd_lookup(tool: Option<&str>, cmd: Option<&str>, file: Option<&Path>, floor: f32) -> Result<()> { let raw = match file { Some(p) => fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?, None => { use std::io::Read; let mut s = String::new(); std::io::stdin().read_to_string(&mut s)?; s } }; let tool = tool .map(str::to_string) .or_else(|| cmd.map(tool_of_cmd)) .unwrap_or_else(|| "unknown".into()); let Some(sig) = extract(&tool, &raw) else { return Ok(()); // nothing extractable: stay silent }; let lessons = load_lessons()?; match lookup_lesson(&sig, &lessons, floor) { // Silence is the correct and common answer. None => Ok(()), Some(hit) => { print!("{}", render_injection(&hit, 600)); Ok(()) } } } pub fn cmd_materialize() -> Result<()> { let lessons = load_lessons()?; if lessons.is_empty() { println!("no lessons yet - run `mem resolve` after capturing some failures"); return Ok(()); } let mut by_tool: BTreeMap> = BTreeMap::new(); for l in lessons { by_tool.entry(l.tool.clone()).or_default().push(l); } let skills_dir = mem_home().join("skills"); let mut written = vec![]; for (tool, ls) in &by_tool { let dir = skills_dir.join(format!("{tool}-failures")); fs::create_dir_all(&dir)?; let path = dir.join("SKILL.md"); fs::write(&path, render_skill(tool, ls))?; written.push(path); } // A CLAUDE.md-style digest: only the recurring lessons, because this file // is loaded eagerly and every byte competes with the task. let mut digest = String::from("# Learned failures\n\nGenerated by `mem materialize`. Recurring failures only.\n\n"); for (tool, ls) in &by_tool { let recurring: Vec<&Lesson> = ls.iter().filter(|l| l.seen >= 3).collect(); if recurring.is_empty() { continue; } digest.push_str(&format!("## {tool}\n\n")); for l in recurring { digest.push_str(&format!( "- `{}` (seen {}x) -> {}\n", l.raw.trim(), l.seen, l.resolution.join(" && ") )); } digest.push('\n'); } let digest_path = mem_home().join("MEMORY.md"); fs::write(&digest_path, digest)?; println!("wrote {} skill(s):", written.len()); for p in written { println!(" {}", p.display()); } println!(" {}", digest_path.display()); println!("\nWire into Claude Code / pi:"); println!(" ln -s {} ~/.claude/skills/", skills_dir.display()); println!(" echo '@{}' >> ~/.claude/CLAUDE.md", digest_path.display()); Ok(()) } /// Skill draft frontmatter #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillFrontmatter { pub name: String, pub description: String, pub when_to_use: String, pub generated_from: String, pub generated_at: String, #[serde(skip_serializing_if = "Option::is_none")] pub argument_hint: Option, } impl SkillFrontmatter { /// Render as YAML frontmatter pub fn render(&self) -> String { let mut yaml = String::from("---\n"); yaml.push_str(&format!("name: {}\n", quoted(&self.name))); yaml.push_str(&format!("description: {}\n", quoted(&self.description))); yaml.push_str(&format!("when_to_use: {}\n", quoted(&self.when_to_use))); yaml.push_str(&format!("generated_from: {}\n", quoted(&self.generated_from))); yaml.push_str(&format!("generated_at: {}\n", quoted(&self.generated_at))); if let Some(hint) = &self.argument_hint { yaml.push_str(&format!("argument_hint: {}\n", quoted(hint))); } yaml.push_str("---\n"); yaml } } fn quoted(s: &str) -> String { format!("'{}'", s.replace("'", "''")) } /// Draft a skill from a memory note pub async fn cmd_skill_draft(project: &str, from: &str, dry_run: bool) -> Result<()> { // Generate stable hash of memory note identifier for provenance let mut hasher = Sha256::new(); hasher.update(format!("{}:{}", project, from).as_bytes()); let hash = format!("{:x}", hasher.finalize())[..16].to_string(); // Create skill frontmatter (placeholder) let frontmatter = SkillFrontmatter { name: format!("{}-{}", project, from.replace("/", "-")), description: format!("Handle {} in project {}", from, project), when_to_use: format!("When dealing with {} in {}", from, project), generated_from: hash.clone(), generated_at: OffsetDateTime::now_utc().to_string(), argument_hint: None, }; // Build output path let vault_dir = mem_home().join("vault"); let skills_drafts = vault_dir.join("skills").join("_drafts").join(format!("{}-{}", project, from)); let skill_file = skills_drafts.join("SKILL.md"); // Verify path is inside _drafts/ if !skill_file .components() .any(|c| c.as_os_str() == "_drafts") { anyhow::bail!("Safety check failed: output path must be inside _drafts/"); } let content = format!( "{}\n# {}: {}\n\nThis is a draft skill generated from memory note: {}\n\nEdit and refine before promoting to `skills/`.\n", frontmatter.render(), frontmatter.name, frontmatter.description, from ); if dry_run { println!("[dry-run] Would write {} bytes to {}", content.len(), skill_file.display()); println!("{}", content); return Ok(()); } fs::create_dir_all(&skills_drafts)?; fs::write(&skill_file, &content)?; println!("Drafted skill: {}", skill_file.display()); println!("Generated from: {}", hash); println!("\nReview and edit, then move to skills/ to promote:"); println!(" mv {}/SKILL.md {}/SKILL.md", skills_drafts.display(), vault_dir.join("skills").display()); Ok(()) }