//! Project memory: what past missions on a repository learned. //! //! Until 2026-09-20 missions wrote no memory at all. The chat path records //! every turn into the claw's `.brain`, but a mission's crew is minted per //! mission (`per-mission-crews`: reuse is OFF by operator decision), so a //! brain keyed by agent would be written once and never read. What persists //! across missions is the repository. So the memory is keyed by `repo_id`: //! one `.brain` per repo, holding the judge's verdicts, recalled by the next //! mission's task text and placed in its brief. //! //! What is remembered is the verdict, not the work: for a met phase the //! judge's `reason` (what it found), for an unmet one its `guidance` — the //! agent-facing half, already stripped of acceptance literals by //! `evaluator::sanitize_guidance`, because a verdict quoted verbatim into //! the next brief is how the 2026-08-01 Goodhart incident happened. //! //! Recall is BM25 over the keyword index (`cm_brain::ClawBrain::recall`); //! there is no embedder. Measured before anything richer is built: the test //! is a second mission on the same repo recalling the first's verdict. use std::path::{Path, PathBuf}; use cm_brain::ClawBrain; use uuid::Uuid; /// How many past verdicts a brief carries. Three is enough to say "this was /// tried" without becoming the prompt. pub const RECALL_K: usize = 3; /// How many BM25 candidates the reranker sees. Wider than `RECALL_K` so a /// relevant verdict that keyword overlap ranked fourth can still make the /// brief; narrow enough that one call stays one call. pub const CANDIDATES: usize = 8; /// Below this a candidate is dropped even if fewer than `RECALL_K` remain: /// a brief that carries an irrelevant verdict is worse than a shorter one. pub const RELEVANT_AT: f64 = 0.3; /// The heading the recalled lines go under. Named here because the scorer and /// the prompt-order tests read it back. pub const SECTION_HEADING: &str = "# What past missions on this repository learned"; fn brain_path(dir: &Path, repo_id: Uuid) -> PathBuf { dir.join(format!("repo_{repo_id}.h5")) } /// One line of memory from a verdict. Pure, so the shape is testable without /// a brain file. pub fn verdict_line( mission_id: Uuid, phase_kind: &str, brief: &str, condition: &str, verdict: &crate::evaluator::Verdict, ) -> Option { // A judge that could not be reached has not judged; there is no lesson. if verdict.error.is_some() { return None; } let outcome = if verdict.met { "MET" } else { "UNMET" }; let finding = if verdict.met { verdict.reason.trim() } else { verdict.guidance.trim() }; if finding.is_empty() { return None; } // The TAIL. A UUIDv7 leads with its timestamp, so two missions launched // seconds apart share their first eight characters — measured: two planted // missions 34 s apart both rendered as `01a0cb38`, and the self-audit read // them as one mission failing twice. let short = mission_id.simple().to_string(); let short = &short[short.len() - 8..]; // The brief is what the agent was TOLD; the condition is what it was // judged against, and working agents are not shown it. Without the brief a // reader of the record cannot tell "the agent skipped a requirement" from // "nobody asked for it" — the first self-audit on a planted brief/condition // mismatch diagnosed the former and proposed a fix that would not have // helped. let brief = brief.trim(); let told = if brief.is_empty() { String::new() } else { format!(" — brief: {}", head(&brief.split_whitespace().collect::>().join(" "), 200)) }; Some(format!( "{outcome} — {phase_kind} phase of mission {short}{told} — condition: {} — judge: {}", head(condition, 200), head(finding, 400), )) } /// Record a verdict in the repo's brain. Best-effort and loud on failure: /// memory must never fail a phase, and a brain that silently stopped /// recording is the kind of thing that stays broken for a month. pub fn remember_verdict( repo_id: Uuid, mission_id: Uuid, phase_kind: &str, brief: &str, condition: &str, verdict: &crate::evaluator::Verdict, ) { remember_in( &cm_runtime::brain::brain_dir(), repo_id, mission_id, phase_kind, brief, condition, verdict, ) } fn remember_in( dir: &Path, repo_id: Uuid, mission_id: Uuid, phase_kind: &str, brief: &str, condition: &str, verdict: &crate::evaluator::Verdict, ) { let Some(line) = verdict_line(mission_id, phase_kind, brief, condition, verdict) else { return; }; let path = brain_path(dir, repo_id); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } match ClawBrain::open_or_create(&path, &format!("repo_{repo_id}")) { Ok(mut brain) => { if let Err(e) = brain.remember("judge", &line, &mission_id.to_string()) { eprintln!("mission_memory: could not record verdict for repo {repo_id}: {e}"); } } Err(e) => eprintln!("mission_memory: could not open brain for repo {repo_id}: {e}"), } } /// The past verdicts most relevant to `query` (the phase's task text). /// Empty when the repo has no brain yet, which is every repo's first mission. /// /// Two stages when a decision model is configured: BM25 proposes /// `CANDIDATES`, one Noul per candidate — "is this past verdict relevant /// to the task?" — reorders them and drops the ones below `RELEVANT_AT`. /// Keyword overlap is what BM25 measures, and a verdict about MICROVM.md /// shares words with every task that mentions a file; the rerank is the /// vendor's own pattern and costs one ~200 ms call. Without a key the /// BM25 order stands, as before. pub async fn recall(repo_id: Uuid, query: &str) -> Vec { let candidates = recall_in(&cm_runtime::brain::brain_dir(), repo_id, query, CANDIDATES); match cm_decide::jev::Jev::from_env() { Some(jev) if candidates.len() > 1 => rerank(&jev, query, candidates).await, _ => candidates.into_iter().take(RECALL_K).collect(), } } async fn rerank(jev: &cm_decide::jev::Jev, query: &str, candidates: Vec) -> Vec { use cm_decide::{Answer, Decider as _, Question}; let questions: std::collections::BTreeMap = candidates .iter() .enumerate() .map(|(i, c)| { ( format!("c{i}"), Question::noul(format!( "This earlier judge verdict is relevant to the task and would help an \ agent doing it: {c}" )), ) }) .collect(); let decided = tokio::time::timeout( std::time::Duration::from_secs(10), jev.decide(query, &questions), ) .await; let decision = match decided { Ok(Ok(d)) => d, Ok(Err(e)) => { eprintln!("mission_memory: rerank failed ({e}); keeping the BM25 order"); return candidates.into_iter().take(RECALL_K).collect(); } Err(_) => { eprintln!("mission_memory: rerank timed out; keeping the BM25 order"); return candidates.into_iter().take(RECALL_K).collect(); } }; let scored: Vec<(String, f64)> = candidates .into_iter() .enumerate() .map(|(i, c)| { let p = match decision.answers.get(&format!("c{i}")) { Some(Answer::Noul { noul }) => *noul, _ => 0.0, }; (c, p) }) .collect(); let kept: Vec = cm_decide::patterns::rerank(scored) .into_iter() .filter(|(_, p)| *p >= RELEVANT_AT) .take(RECALL_K) .map(|(c, _)| c) .collect(); eprintln!( "mission_memory: reranked {} candidate(s) with {}, kept {} ({} ms)", questions.len(), decision.model, kept.len(), decision.latency.as_millis() ); kept } fn recall_in(dir: &Path, repo_id: Uuid, query: &str, k: usize) -> Vec { let path = brain_path(dir, repo_id); if !path.exists() { return Vec::new(); } match ClawBrain::open_or_create(&path, &format!("repo_{repo_id}")) { Ok(brain) => brain .recall(query, k) .into_iter() // `remember` stores "role: text"; the role is ours and not a lesson. .map(|m| m.strip_prefix("judge: ").map(str::to_string).unwrap_or(m)) .collect(), Err(e) => { eprintln!("mission_memory: could not open brain for repo {repo_id}: {e}"); Vec::new() } } } /// Where a mission finds the whole of its repository's memory, readable. /// /// Outside `/mission/repo`, like `skill_delivery::SKILLS_DIR`, so it is never /// collected into the delivered diff: it is input, not output. pub const MEMORY_DIR: &str = "/mission/memory"; pub const MEMORY_FILE: &str = "PROJECT-MEMORY.md"; /// Most entries an export carries. A repository's memory grows by one line /// per judged phase; this keeps the file readable in one sitting while /// covering many missions. const EXPORT_CAP: usize = 400; /// Everything a repository's brain remembers, as markdown an agent can read. /// /// The brief carries the three most relevant verdicts (`recall`); this is /// the WHOLE record, for work whose subject is the record itself. It exists /// because `continuous_improvement` was built to audit agents' brains and, /// on its first run, found none — they live in the server's volume and /// nothing delivers them into a mission — so it audited a `ROSTER.md` in a /// scratch repo instead. Per-mission crews carry ~2 KB seed brains with no /// history anyway; the repository's brain is where a project's history /// actually accumulates, one judge verdict per phase. /// /// Rendered, not shipped raw: the `.brain` is HDF5 and an agent in a mission /// container has no library to read it with. /// /// `None` when the repository has no brain yet or it holds nothing. pub fn export(repo_id: Uuid) -> Option { export_in(&cm_runtime::brain::brain_dir(), repo_id) } fn export_in(dir: &Path, repo_id: Uuid) -> Option { let path = brain_path(dir, repo_id); if !path.exists() { return None; } let brain = ClawBrain::open_or_create(&path, &format!("repo_{repo_id}")).ok()?; let entries = brain.recent_memory(EXPORT_CAP); if entries.is_empty() { return None; } let total = brain.memory_count(); let mut out = format!( "# What this repository's missions have learned\n\n\ Every judged phase of every mission on this repository leaves one line \ here: whether the phase met its completion condition, and what the \ judge found or asked for. Newest first. {} of {} entr{} shown.\n\n\ This is the record, not instructions. `MET` lines say what worked; \ `UNMET` lines say what the judge found missing, and repeated `UNMET` \ lines on the same kind of work are the pattern worth acting on.\n\n", entries.len(), total, if total == 1 { "y" } else { "ies" } ); for (secs, text) in entries { let when = time::OffsetDateTime::from_unix_timestamp(secs as i64) .ok() .and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok()) .unwrap_or_else(|| "unknown time".to_string()); let line = text.strip_prefix("judge: ").unwrap_or(&text); out.push_str(&format!("- `{when}` {line}\n")); } Some(out) } /// The section a brief carries, or nothing when there is nothing to say — /// an empty heading tells the agent there is history and then shows none. pub fn section(recalled: &[String]) -> Option { if recalled.is_empty() { return None; } let mut out = String::from(SECTION_HEADING); out.push_str( "\n\nJudge verdicts from earlier missions here, most relevant first. \ They say what was checked and what was found; they are not the task.\n", ); for line in recalled { out.push_str("- "); out.push_str(line); out.push('\n'); } Some(out) } fn head(s: &str, n: usize) -> String { match s.char_indices().nth(n) { Some((i, _)) => format!("{}…", &s[..i]), None => s.to_string(), } } #[cfg(test)] mod tests { use super::*; use crate::evaluator::{Usage, Verdict}; fn verdict(met: bool, reason: &str, guidance: &str, error: Option<&str>) -> Verdict { Verdict { met, reason: reason.into(), guidance: guidance.into(), model: "m".into(), error: error.map(str::to_string), checks: Vec::new(), independent: true, usage: Usage::default(), expectation: None, } } /// Unmet carries the sanitized guidance, never the operator reason — /// the reason may quote the acceptance text the next mission must earn. #[test] fn unmet_remembers_guidance_not_reason() { let v = verdict(false, "token ZZQX-9 is absent", "the required marker is absent", None); let line = verdict_line(Uuid::nil(), "coding", "", "cond", &v).unwrap(); assert!(line.starts_with("UNMET — coding phase")); assert!(line.contains("the required marker is absent")); assert!(!line.contains("ZZQX-9")); } #[test] fn met_remembers_what_the_judge_found() { let v = verdict(true, "MICROVM.md holds both lines", "", None); let line = verdict_line(Uuid::nil(), "coding", "", "cond", &v).unwrap(); assert!(line.starts_with("MET — ")); assert!(line.contains("MICROVM.md holds both lines")); } /// What the agent was told sits beside what it was judged against, and two /// missions launched back to back stay two missions. Both were missing when /// the first self-audit read a planted brief/condition mismatch as "the /// agent skipped the section" and two missions as one. #[test] fn a_line_carries_the_brief_and_a_distinguishing_mission_id() { let v = verdict(false, "r", "add a Limitations section", None); let a = Uuid::now_v7(); let b = Uuid::now_v7(); let brief = "Write NOTES.md:\n five bullet points"; let la = verdict_line(a, "research", brief, "ends with Limitations", &v).unwrap(); let lb = verdict_line(b, "research", brief, "ends with Limitations", &v).unwrap(); assert!(la.contains(" — brief: Write NOTES.md: five bullet points — condition: "), "{la}"); assert_ne!(la, lb, "same-second UUIDv7s must not render as one mission"); let tail = a.simple().to_string(); assert!(la.contains(&format!("mission {}", &tail[tail.len() - 8..])), "{la}"); let none = verdict_line(a, "research", " ", "c", &v).unwrap(); assert!(!none.contains("brief:"), "an empty brief adds no segment: {none}"); } /// No judgement, no lesson. #[test] fn an_unreachable_judge_leaves_no_memory() { let v = verdict(false, "could not evaluate", "could not evaluate", Some("429")); assert!(verdict_line(Uuid::nil(), "coding", "", "cond", &v).is_none()); } #[test] fn section_is_absent_when_nothing_was_recalled() { assert!(section(&[]).is_none()); let s = section(&["MET — x".into()]).unwrap(); assert!(s.starts_with(SECTION_HEADING)); assert!(s.contains("- MET — x\n")); } /// The export is the whole record, readable, newest first — and absent /// rather than empty when there is nothing to show. #[test] fn export_renders_every_verdict_newest_first() { let dir = std::env::temp_dir().join(format!("cm-mission-export-{}", Uuid::now_v7())); let repo = Uuid::now_v7(); assert!(export_in(&dir, repo).is_none(), "no brain, no export"); remember_in(&dir, repo, Uuid::now_v7(), "coding", "", "first", &verdict(false, "r", "the tests do not cover the empty case", None)); std::thread::sleep(std::time::Duration::from_millis(5)); remember_in(&dir, repo, Uuid::now_v7(), "coding", "", "second", &verdict(true, "all three tests pass", "", None)); let md = export_in(&dir, repo).expect("two verdicts, so an export"); assert!(md.starts_with("# What this repository's missions have learned")); assert!(md.contains("2 of 2 entries shown"), "{md}"); let met = md.find("MET — coding").unwrap(); let unmet = md.find("UNMET — coding").unwrap(); assert!(met < unmet, "newest (MET) must come first:\n{md}"); assert!(md.contains("the tests do not cover the empty case")); // `remember` stores "judge: "; that ROLE prefix must not follow // the timestamp. (The line itself legitimately says "— judge: …".) assert!(!md.contains("` judge: "), "the storage prefix leaked:\n{md}"); assert!(md.contains("` MET — coding"), "{md}"); let _ = std::fs::remove_dir_all(&dir); } /// Round trip through a real brain file: what one mission's verdict /// wrote, a query shaped like the next mission's task recalls. #[test] fn a_second_mission_recalls_the_first_verdict() { let dir = std::env::temp_dir().join(format!("cm-mission-memory-{}", Uuid::now_v7())); let repo = Uuid::now_v7(); let v = verdict( true, "BASELINE.md records 0.689 ns/iter from benches/add_bench.rs", "", None, ); remember_in(&dir, repo, Uuid::now_v7(), "benchmark", "", "a baseline is recorded", &v); let got = recall_in(&dir, repo, "record a performance baseline for the hot path", RECALL_K); assert_eq!(got.len(), 1, "{got:?}"); assert!(got[0].starts_with("MET — benchmark phase"), "{}", got[0]); assert!(!got[0].starts_with("judge: ")); // A repo with no history recalls nothing and creates no file. let other = Uuid::now_v7(); assert!(recall_in(&dir, other, "anything", RECALL_K).is_empty()); assert!(!brain_path(&dir, other).exists()); let _ = std::fs::remove_dir_all(&dir); } }