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
140 lines
5.9 KiB
Rust
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)
|
|
}
|