Files
clawmates/crates/cm-runtime/src/brain.rs
T
Omar SobhandClaude Opus 5 13f7fb3aff feat(memory): missions remember their verdicts, per repository
Until now missions wrote no memory. The chat path records every turn into
the claw's .brain, but a mission's crew is minted per mission, so a brain
keyed by agent would be written once and never read. What persists across
missions is the repository: mission_memory keeps one .brain per repo_id,
writes each judge verdict into it (reason when met, sanitized guidance when
not — the operator reason may quote the acceptance text), and recalls
against the next phase's task text into the brief, under a heading all
three executors carry because it rides on the task.

Recall is BM25 over the keyword index, no embedder; the harness asserts the
brief carries the section once the repo has one judged mission behind it,
and says 'first mission' rather than failing before that. OpenClaw's
flush-before-compaction was the other half of this item and is moot here:
the chat loop has no compaction and already remembers both halves of
every turn.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-20 22:11:55 -05:00

140 lines
5.9 KiB
Rust

//! Best-effort brain augmentation for the chat path.
//!
//! Each turn we open the claw's local working `.brain` (cm-brain / ClawhDF5),
//! recall relevant memory, record the turn, and compose a system prompt on top
//! of the claw's Postgres-authoritative one. Any failure falls back to the plain
//! prompt — the brain must never break chat.
//!
//! Skills are **indexed, not inlined**: the prompt lists what the claw has and
//! what each is for, and `skills.read` fetches a body on demand.
//!
//! The local file is a working cache of the claw's brain (canonical home is
//! ClawBrainHub); memory accrues here and is pushed back on save/publish.
use std::path::PathBuf;
use cm_brain::ClawBrain;
/// Where the working `.brain` files live. Shared with `cm_api::mission_memory`,
/// which keeps the per-repository brains beside the per-claw ones.
pub fn brain_dir() -> PathBuf {
std::env::var("CLAWMATES_BRAIN_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("clawmates-brains"))
}
/// Compose the system prompt for a claw, augmenting `base_prompt` with its
/// identity (AGENTS.md + personality), skills, and recalled memory, and recording
/// the user turn. Best-effort: returns `base_prompt` unchanged on any brain error.
pub fn compose_system(
agent_id: &str,
base_prompt: &str,
skills: &[(String, String, String)], // (title, description, body)
user_text: &str,
session_label: &str,
) -> String {
match try_compose(agent_id, base_prompt, skills, user_text, session_label) {
Ok(s) => s,
Err(e) => {
eprintln!("cm-runtime: brain augmentation skipped for {agent_id}: {e}");
base_prompt.to_string()
}
}
}
/// Record the assistant's reply in the claw's brain so recall returns whole
/// exchanges rather than just the user's half.
///
/// Best-effort and silent on failure, like [`compose_system`] — memory is an
/// enhancement and must never fail a completed turn. Empty replies (a turn that
/// only made tool calls) are skipped so they don't dilute the keyword index.
pub fn remember_reply(agent_id: &str, text: &str, session_label: &str) {
if text.trim().is_empty() {
return;
}
let path = brain_dir().join(format!("claw_{agent_id}.h5"));
match ClawBrain::open_or_create(&path, agent_id) {
Ok(mut brain) => {
let _ = brain.remember("assistant", text, session_label);
}
Err(e) => eprintln!("cm-runtime: brain reply-memory skipped for {agent_id}: {e}"),
}
}
fn try_compose(
agent_id: &str,
base_prompt: &str,
skills: &[(String, String, String)],
user_text: &str,
session_label: &str,
) -> Result<String, cm_brain::BrainError> {
let path = brain_dir().join(format!("claw_{agent_id}.h5"));
let mut brain = ClawBrain::open_or_create(&path, agent_id)?;
// First touch: seed the brain's definition (Postgres stays authoritative;
// the live prompt below always uses the DB values, so this is just so the
// .brain is a complete, portable artifact for push-back).
if brain.system_prompt().is_none() {
if !base_prompt.is_empty() {
brain.set_system_prompt(base_prompt)?;
}
for (name, _description, body) in skills {
brain.set_skill(name, body)?;
}
}
let recalled = brain.recall(user_text, 4);
// Record the user's turn so future sessions can recall it (best-effort).
let _ = brain.remember("user", user_text, session_label);
let mut out = String::with_capacity(base_prompt.len() + 512);
// Base identity: the Postgres-authoritative system prompt, or — if it's empty
// (e.g. a brain pulled from the Hub that carries its identity in soul_md) —
// the brain's own system prompt (which falls back to soul_md).
if base_prompt.trim().is_empty() {
if let Some(sp) = brain.system_prompt() {
out.push_str(&sp);
}
} else {
out.push_str(base_prompt);
}
// `agent_md` ("how I operate") and `personality` are deliberately NOT
// injected. Both are standing behavioural instruction — house style, coding
// preferences, tone — and their bodies are the team template's `brain_seed`
// prose ("prefer let-else over deep nesting", "anti-patterns: unwrap() in
// library code"). That is exactly the kind of correction written for weaker
// models: a current frontier model either does it unprompted or does it
// fine differently, and the text cost a fixed toll on every single turn.
//
// They remain in the brain, editable from the dashboard and carried in the
// portable artifact — this is about what earns a place in the prompt, not
// about discarding the data. The claw's DB `system_prompt` still goes in
// above: identity and purpose are information, not correction.
// Skills are indexed, not inlined. Bodies average ~3.5 KB (~900 tokens)
// each and were previously concatenated in full on every turn, unbounded in
// the number installed — by far the largest thing in the prompt. The claw
// now sees what it has and what each is for, and calls `skills.read` for a
// body when one is actually relevant. Same summary-and-fetch contract the
// mission path already gets from the `clawmates_skills` MCP server.
if !skills.is_empty() {
out.push_str("\n\n## Your skills\n");
out.push_str("Call `skills.read` with a skill's name to read it in full.\n");
for (name, description, _body) in skills {
if description.trim().is_empty() {
out.push_str(&format!("- {name}\n"));
} else {
out.push_str(&format!("- {name}{description}\n"));
}
}
}
if !recalled.is_empty() {
out.push_str("\n\n## Relevant memory from past sessions\n");
for r in &recalled {
out.push_str("- ");
out.push_str(r);
out.push('\n');
}
}
Ok(out)
}