2026-08-22 21:43:23 -07:00
|
|
|
//! `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/<tool>-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,
|
|
|
|
|
};
|
2026-08-26 13:50:22 -07:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use sha2::{Digest, Sha256};
|
2026-08-22 21:43:23 -07:00
|
|
|
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<Vec<Event>> {
|
|
|
|
|
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::<Event>(l).ok())
|
|
|
|
|
.collect())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn load_lessons() -> Result<Vec<Lesson>> {
|
|
|
|
|
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<String, Vec<Lesson>> = 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(())
|
|
|
|
|
}
|
2026-08-26 13:50:22 -07:00
|
|
|
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(())
|
|
|
|
|
}
|